Skip to content

Instantly share code, notes, and snippets.

@carbide-public
Created April 27, 2019 15:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save carbide-public/7056ce783457a8ecb4aa3d79dd849245 to your computer and use it in GitHub Desktop.
Save carbide-public/7056ce783457a8ecb4aa3d79dd849245 to your computer and use it in GitHub Desktop.
Chatterbase
import React from 'react'
import firebase from 'firebase'
import {liveData, actions} from './storage.js'
import GroupsList from './GroupsList.jsx'
try { firebase.app() } catch (e){
firebase.initializeApp({
apiKey: "AIzaSyBm9oAcCktnQlaxNS1GvyraDGV7QtA6d78",
authDomain: "bastard-183be.firebaseapp.com",
databaseURL: "https://bastard-183be.firebaseio.com",
storageBucket: ""
})
}
const LoginButtons = () => (
<div>{
firebase.auth().currentUser ? "Logged in" : ['Facebook', 'Google', 'Twitter'].map( m => {
let meth = firebase.auth[m+"AuthProvider"]
return <button onClick={ () => firebase.auth().signInWithPopup( new meth() ) }>Join w {m}</button>
}
)}
</div>
)
const FB_ROOT = firebase.database().ref('chattytest')
export default class ChatterbaseApp extends React.Component {
constructor(p){ super(p); this.state = {} }
componentWillMount(){ liveData(FB_ROOT, s => this.setState(s)) }
render(){
let {user, groups} = this.state
if (!user) return <LoginButtons />
let actionMethods = actions(FB_ROOT, {
uid: user.uid,
displayName: user.displayName
})
return <GroupsList groups={groups||{}} userId={user.uid} {...actionMethods} />
}
}
///**Chatterbase**\\\\Today we are going to build a little chatroom with _**declarative social**_ and _**soft automation**__\_\_\_\_.\_\\- _**Declarative Social**__\_\_\_\_._ The idea is to separate out a certain kind of logic from the rest of the code, and make it declarative, easy to understand, and editable by users. What we'll separate out in this way is the \_social flow_ of the software: the specification for which information is collected, from which users, who gets shown what, who gets notified, what all the roles and expectations are, and the timing for all of the above. We will put this information in an editable script (like this one) and that script will drive the rest of the software.
///
const exampleScript = `
organizer:
Who has suggestions for book ideas for our meeting on {date}?
members:
@organizer What about {bookIdea | bookIdea}?
-- 4 days --
organizer:
Great, lets all read {book | bookIdea} for {date}. Who's [attending]?
-- 4 days --
organizer:
@attending see you all soon!
`
///- **Soft automation.** Second, we make sure that this script is never _in charge_. Instead, the script will be used to make suggestions which the user can follow. But the user is always free to submit some _other_ data, send it to some _other_ person, or even to rewrite the script so the roles and rules change completely.\\
///**Motivation—the tyrranies of messaging and the tyrranies of automation**\\\\Most things that are accomplished by social software could be done by hand just using messaging—you could run a book club, organize 1000 workers in a construction site, or run a dating site just by using something like facebook messenger. You’d have to collect tasks and photos and profiles and everything by hand and forward them around, and so on. This would be awful. The tyrannies of coordination via messaging include:\\- **Endless checking,** to move things along,//- **Can’t get an overview**//- Interactions are limited to **short term utterances **rather than** long-term plans**//- And because there's no explicit process that means://- You're always **starting from scratch**,//- No **clarity about ongoing roles**,//- There are **covert, unintentional power dynamics**,//- There's nowhere to encode **organizational wisdom about process**\\But the automation of our lives—via bots or other social software—is awful in it’s own way. The tyrannies of bots include:\\- **Rigidity**. It's difficult for participants to do something different than what the script assumes, or to post data that doesn't fit schemas.//- _**Impersonal Authority**_. People report to the script or to dashboards, rather than to one another. The script becomes an awful authority. No one feels responsible for the broader goal, or to each other.//- _**Overmediation**_. These systems assume everything will be done through their screen-based interfaces. Simple social actions can't happen naturally through convesation in-person because they won't advance the script.//- _**Esoterism**_. Users with better ideas often can't change the way the automation works, or suggest alternatives, or even see the overall gist of the automation.\\The goals of **soft automation** and **declarative social** are to free ourselves from the tyrannies of messaging and from the tyrannies of conventional automation.\\
///**Declarative Social**\\\\Most social software is a kind of automated conversation between multiple parties. Usually the conversational nature of software is obscured because the participants are filling out forms or using widgets instead of typing sentences. But it seems most software could be decomposed cleanly into three different kinds of specifications:\\- **declarative views** for data and UI//- **numerical algorithms**//- **declarative social routing** information about which information to collect, when, from who, and who else should see it\\If that's true, this approach, which works for soft automation in chat, could—if augmented with other systems for declaring views on data and for making pure function algorithms—show us how to soften automation everywhere.\\
///**Soft automation in chat**\\\\So here's what it means for chat: with soft automation, we can start a thread in the chatroom that has a script associated with it, and the script says how to guide people along through a set of roles and expectations, maybe collecting some data, using notifications.\\Our goal is to make it easier for ordinary people to describe a set of social interactions they want automatically facilitated. To avoid the pitfalls above, our engine must support:\\- _**Discretion**_. It should be easy for each participant to do something different than what the script is suggesting to them. We will see if we can handle people accepting roles and then shedding them later, posting data that doesn't fit schemas, and the like.//- _**Fellowship**_. People should be accountable to one another, not to the script. That means that the script mostly makes suggestions, but is not the source of messages and instructions itself. The script and its author are not an authority, but lend authority to the players.//- _**Media-Independence**_. Ideally, the system should not assume that everything will be done through it's screen-based interfaces. The same script actions which can be fulfilled via text chat shuld also be fulfillable via direct, in-person interactions that are detected by the system, through synchronous or asynchronous voice, or through any other organic means by which the user can carry ou their role.//- _**Adaptation**_. It should be possible for a innovative user to investigate the scripts behind whatever she sees, find out who wrote them, and make edits that run live, in parallel in the same chatroom, that suggest other ways of coordinating.\\
///**Our strategy**\\\\To accomplish this, we will:\\- Separate out the part of programming that concerns **conversation flow** and **data collection/distribution** into a script which everyone can understand and modify//- Show all users easy-to-understand representations of the program state: as either **a thread of chat messages/actions**, and as **a browsable database**.//- Limit the script to making **suggestions** as to what kind of data each user should share or forward around to other users, so that no one has to interact directly with the script, or interact in a way that rigidly follows its expectations//- Design the script format so that **the same script lines can automate interactions across several mediums**: text chat, video chat, VR, and most importantly just crossing the room to talk with your colleagues\\Let’s begin.\\
///When we parse the example script above, we get an object of characters and cues. Each cue in the script has a set of conditions in which the script should cue a character
import Parser from './parser'
var script = Parser.parse(exampleScript)
///We can then use such a parsed script to generate suggestions for the users in a thread, based on roles they've joined as part of discussing things in the thread.
import suggestions from './suggestions.js'
let exampleThread = {
id: 'thread1',
groupId: 'group1',
ctime: new Date(),
roles: {
organizer: { joe: true },
members: { jim: true }
},
script: script
}
suggestions(exampleThread, script, 'joe')
let suggestionsForJim = suggestions(exampleThread, script, 'jim')
///Further below, we'll build a little chatroom interface that lets you do ordinary things\\- send ordinary messages//- start/join groups and threads\\And extraordinary things\\- attach scripts to threads//- join and leave roles as part of a thread//- collect/view thread data//- and follow suggestions\\\\\\In particular, we'll make:
///- a message composer that can show suggestions
import React from 'react'
import MessageComposer from './MessageComposer.jsx'
let x = <MessageComposer
suggestions={suggestionsForJim}
send={
(thread, text, suggestion) => console.log(text, suggestion)
}
/>
///- a message view that supports joining/leaving roles
import MessageView from './MessageView.jsx'
let exampleGroup = { id: 'group1', members: { joe: { uid: 'joe', displayName: 'Joe E' } }}
let exampleMessage = {
id: 'example01',
text: "Hello there buddy",
from: 'joe',
senders: ["organizer"],
casts: ['alpha', 'beta']
}
let y = <MessageView
{...exampleMessage}
userId="jim"
thread={exampleThread}
group={exampleGroup}
script={script}
cast={(thread, role, joined) => console.log(role,joined)}
/>
///- a cute way to start threads with or without scripts
import GroupFeed from './GroupFeed.jsx'
exampleThread.messages = { [exampleMessage.id]: exampleMessage }
exampleGroup.threads = { [exampleThread.id]: exampleThread }
let z = <GroupFeed group={exampleGroup} userId="jim" />
///- and a way to view a thread as either messages or data tables
//TBD
///Together in an app:
import App from './App.jsx'
let a = <App/>
import React from 'react'
import MessageView from './MessageView.jsx'
import MessageComposer from './MessageComposer.jsx'
import suggestions from './suggestions.js'
const ThreadTeaser = (props) => {
let {thread} = props
let messageIds = Object.keys(thread.messages || {}).sort()
let firstMessage = thread.messages && thread.messages[messageIds[0]]
return <MessageView {...firstMessage}
script={thread.script}
{...props}>
<div className="CardFooter">
{messageIds.length - 1} replies; <i>{new Date(thread.ctime).toLocaleString()}</i>
</div>
</MessageView>
}
class ThreadComposer extends React.Component {
constructor(props){
super(props)
this.state = { draftText: "" }
}
render(){
let {draftText=""} = this.state
let {group, userId, newThread} = this.props
return <form className="row Palette">
<input
value={draftText}
onChange={ ev => this.setState({draftText: ev.target.value}) }
placeholder="Type a message..."
/>
<button onClick={ev => {
ev.preventDefault()
if (!draftText) return
newThread(group, draftText)
this.setState({ draftText: "" })
}}>Submit</button>
<button onClick={ev=>{
ev.preventDefault()
let script = prompt('Enter a script!')
newThread(group, draftText, script)
this.setState({ draftText: "" })
}}>Start script</button>
</form>
}
}
const ThreadViewer = (props) => {
let {messages, onClose, thread, script, userId} = props
return <div className="Screen">
<header className="bar bar-nav">
<h1 className="title"> Thread </h1>
<button className="btn pull-left" onClick={onClose}> Close </button>
</header>
<div className="content">{
Object.values(messages).map(m => <MessageView {...m} {...props} />)
}</div>
<MessageComposer {...props} suggestions={suggestions(thread, script, userId)} />
</div>
}
export default class GroupFeed extends React.Component {
constructor(props){
super(props)
this.state = { selectedThread: null }
}
render(){
let {group, onClose} = this.props
let {members} = group
let {selectedThread} = this.state
if (selectedThread){
return <ThreadViewer
thread={selectedThread}
{...selectedThread}
{...this.props}
onClose={ () => this.setState({selectedThread:null}) }
/>
}
let threadIds = Object.keys(group.threads || {}).sort().reverse()
return <div className="Screen">
<header className="bar bar-nav">
<h1 className="title">
{group.id}
<p>
{Object.values(members).map(m => <b>{m.displayName}</b>)}
</p>
<button className="btn pull-left" onClick={onClose}> Close </button>
</h1>
</header>
<div className="content">
<ThreadComposer {...this.props} />
{
threadIds.map(threadId => (
<ThreadTeaser
thread={group.threads[threadId]}
onClick={() => this.setState({selectedThread: group.threads[threadId]})}
{...this.props}
/>
))
}
</div>
</div>
}
}
import React from 'react'
import GroupFeed from './GroupFeed.jsx'
let GroupCell = ({group, onClick}) => (
<div className="table-view-cell group" onClick={onClick}>
Group: {group.id}
<p>
{Object.values(group.members).map(m => <b>{m.displayName}</b>)}
</p>
</div>
)
export default class GroupsList extends React.Component {
constructor(props){
super(props)
this.state = { selectedGroup: null }
}
render(){
let {selectedGroup} = this.state
let {groups, newGroup} = this.props
if (selectedGroup){
return <GroupFeed
onClose={ () => this.setState({selectedGroup:null}) }
group={groups[selectedGroup] || { id: selectedGroup, members: {} }}
{...this.props}
/>
}
return (
<div className="Screen">
<header className="bar bar-nav">
<h1 className="title">Groups</h1>
<button className="pull-right" onClick={
() => this.setState({selectedGroup: newGroup()})
}>
add
</button>
</header>
<div className="content">
<div className="table-view">{
Object.values(this.props.groups).map(
g => <GroupCell group={g} {...this.props} onClick={
() => this.setState({selectedGroup: g.id})
} />
)
}</div>
</div>
</div>
)
}
}
import React from 'react'
import './styles.css'
export default class MessageComposer extends React.Component {
constructor(props){
super(props)
this.state = { open: false }
}
send(){
let {draftText, activeSuggestion} = this.state
let {thread, send} = this.props
if (!draftText) return
send(thread, draftText, activeSuggestion)
this.setState({ draftText: "", activeSuggestion: null })
}
render(){
let {open, draftText=""} = this.state
let {suggestions} = this.props
let row = <div className="row">
<button onClick={ () => this.setState({open:!this.state.open}) } >
{suggestions.length} suggs
</button>
<input
onChange={ ev => this.setState({draftText: ev.target.value}) }
value={draftText}
placeholder="Type a message..."
/>
<button onClick={() => this.send()}>send</button>
</div>
if (open){
// it's a list of suggestions
return <div className="composer">
<div className="table-view"> {
suggestions.map(s => (
<div onClick={
() => this.setState({
open: false, draftText: s.cue.text, activeSuggestion: s
})
} className="table-view-cell">
{s.cue.hint} {s.cue.text}
</div>
))
}</div>
{row}
</div>
} else {
// it's a toolbar and textfield
return <div className="composer"> {row} </div>
}
}
}
import React from 'react'
let CastingButton = ({role, thread, script, cast, userId}) => {
let title = role,
roleMembers = thread.roles[role] || {},
joined = roleMembers[userId],
roleSpec = script.characters[role] || {},
desc = roleSpec.description,
join = () => {
let confirmed = true
if (desc) confirmed = confirm(desc)
if (confirmed) cast(thread, role, true)
},
leave = () => {
if (confirm('Leave this role?')) return cast(thread, role, false)
}
if (joined) return <button onClick={leave}>{title} (joined)</button>
else return <button onClick={join}>Join as <b>{title}</b></button>
}
let Header = ({from, senders=[], thread, group}) => {
let fromUser = group.members[from]
return <div className="CardHeader">
<b>{fromUser.displayName}</b> <i>{senders.join(', ')}</i>
</div>
}
let Buttons = (props) => {
if (!props.casts || !props.casts.length) return null
return <div className="Section Buttons">
{props.casts.map(r => <CastingButton role={r} {...props} />)}
</div>
}
const MessageView = (props) => (
<div className="MessageView">
<Header {...props} />
<div className="Card" onClick={props.onClick}>
<div className="MainSection Body">{props.text}</div>
<Buttons {...props} />
</div>
{props.children}
</div>
)
export default MessageView
///So let's get on with parsing our example script.\\\\\\**Parser**\\\\We can define a few useful regexes:
let ROLE_IDENT = '([a-zA-Z][a-zA-Z0-9]*)'
let MENTION = '(@[a-zA-Z][a-zA-Z0-9]*)'
let DELAY = '(([0-9]+)\\s*(min|minutes|m|days|d|day|minute))'
let RANGE = '([0-9]+)'
let EMPTY_LINE = /^\s*(\/\/.*)?$/
let SCENE_COMPONENT = `(${ROLE_IDENT}|${DELAY})`
let ROLES = `(${ROLE_IDENT}(,\\s*${ROLE_IDENT})*)`
let MENTIONS = new RegExp(`^(${MENTION}\\s*,?\\s*)+`)
let SCENE_COMPONENTS = `(${SCENE_COMPONENT}(,\\s*${SCENE_COMPONENT})*)`
let TITLE = /^"(.*)"\s*$/
let HINT = /^\s*\((.*)\)\s*$/
let SCENE = new RegExp(`^--\\s*${SCENE_COMPONENTS}\\s*--$`)
let CHARACTER = new RegExp(`^${ROLE_IDENT}\\.\\s+(.*?)$`)
let MESSAGE = new RegExp(`^${ROLES}:\\s+(.*?)$`)
let CASTBUTTON = new RegExp(`\\[(${RANGE}\\s+)?${ROLE_IDENT}\\]`, 'g')
///and build them into a simple parser
let Parser = {
inSeconds(n, unit){
return n * {s: 1, m: 60, h: 60*60, d: 60*60*24}[unit[0]]
},
parse(text){
this.script = { characters: {}, cues: [] }
var m, hint, scene, conditions = {}
// first, lines that start with indentation are part of the previous line
text = text.replace(/:\n[ \t]+(?=[^\s])/g, ': ')
text = text.replace(/.\n[ \t]+(?=[^\s])/g, '. ')
// every remaining line should either be:
text.split(/\n/).forEach(line => {
if (m = line.match(TITLE)){ // title
this.script.title = m[1]
} else if (m = line.match(CHARACTER)){ // character desc
this.script.characters[m[1]] = { description: m[2] }
} else if (m = line.match(HINT)){ // notification text
hint = m[1]
} else if (m = line.match(SCENE)){ // scene conditional
let components = m[1]
components = components.replace(new RegExp(DELAY, 'g'), (_, _2, n, unit) => {
n = this.inSeconds(n, unit)
if (conditions.delay) conditions.delay += n
else conditions.delay = n
return ''
})
components = components.replace(new RegExp(ROLE_IDENT, 'g'), r => {
conditions[r] = 'exists'
return ''
})
} else if (m = line.match(MESSAGE)){ // template message
let senders = m[1].split(/,\s*/)
let text = m[5]
let recipients = []
if (m = text.match(MENTIONS)){
recipients = m[0].match(new RegExp(MENTION, 'g')).map(x => x.slice(1))
text = text.replace(MENTIONS, '')
}
let subconditions = Object.assign({}, conditions)
let humanSenders = senders.filter(s => s.match(/^[a-z]/) && s !== 'members')
recipients.concat(humanSenders).forEach(r => subconditions[r] = 'exists')
let casts = []
while (m = CASTBUTTON.exec(text)){
let count = m[1] && m[2]
let role = m[3]
casts.push(role)
if (!this.script.characters[role]) this.script.characters[role] = {}
if (count){
this.script.characters[role].min = this.script.characters[role].max = count
} else {
if (!this.script.characters[role].min) this.script.characters[role].min = 1
}
}
this.script.cues.push({
id: this.script.cues.length,
prompt: hint || null,
conditions: subconditions,
senders: senders,
recipients: recipients,
text: text,
casts: casts,
isDraft: senders.length > 1 || !senders[0].match(/^[A-Z]/)
})
hint = null
} else if (!line.match(EMPTY_LINE)){ // or empty/comment
throw `Unrecognized: ${line}`
}
})
return this.script
}
}
export default Parser
import Parser from './parser.js'
import firebase from 'firebase'
let user, groups, query;
export function liveData(fbRoot, cb){
let go = () => {
cb({user, groups})
if (user && !query){
query = fbRoot.child('groups').orderByChild(`members/${user.uid}`)
query.on('value', s => { groups = s.val() || {}; go() })
}
}
firebase.auth().onAuthStateChanged(u => { user = u; go() })
user = firebase.auth().currentUser
go()
}
export function actions(fbRoot, user){
let messageFromText = (text) => {
return {
id: fbRoot.push().key,
from: user.uid,
text: text
}
}
let updateThread = (thread, update) => {
let thr = fbRoot.child(`groups/${thread.groupId}/threads/${thread.id}`)
thr.update(update)
let gr = fbRoot.child(`groups/${thread.groupId}`)
gr.child(`members/${user.uid}`).set(user)
}
return {
newThread(group, draftText, script = null){
let msg = messageFromText(draftText)
let thread = { id: msg.id, groupId: group.id }
updateThread(thread, {
id: thread.id,
groupId: group.id,
script: script && Parser.parse(script),
roles: { organizer: { [user.uid]: true } },
ctime: Date.now(),
[`messages/${msg.id}`]: msg
})
},
newGroup(){
let id = fbRoot.push().key
fbRoot.child(`groups/${id}`).set({ id, members: { [user.uid]: user } })
return id
},
send(thread, draftText, activeSuggestion){
let msg = messageFromText(draftText)
if (activeSuggestion){
msg.suggestionId = activeSuggestion.id
msg.cueId = activeSuggestion.cue.id
}
updateThread(thread, { [`messages/${msg.id}`]: msg })
},
cast(thread, role, joining = true){
updateThread(thread, { [`roles/${role}/${user.uid}`]: joining })
}
}
}
body {
font-family: sans-serif;
}
.row {
display:flex;
padding: 2px;
}
.row input {
flex: auto;
margin: 0px 2px;
}
.table-view-cell {
padding: 10px;
}
.MessageView .Card {
box-shadow: black 0px 0px 2px;
border-radius: 5px;
margin: 5px 10px;
}
.MessageView {
margin-bottom: 20px;
}
.Card .Section, .Card .MainSection {
padding: 5px 10px;
}
.CardHeader, .CardFooter {
padding: 0px 20px;
font-size: 12px;
}
.CardHeader{
color: #555;
}
.CardFooter{
text-align: center;
color: #888;
}
.Palette {
background: #eee;
margin: 5px 10px;
padding: 2px 5px;
margin-bottom: 20px;
box-shadow: black 0px 1px 1px;
border-radius: 5px;
}
.Screen {
position: relative;
min-width: 400;
min-height: 920;
padding-top: 60px;
}
.Screen header {
position: absolute;
top: 0px;
height: 40px;
background: #eee;
width: 100%;
}
.Screen header h1 {
padding:0; margin:0;
text-align: center;
font-size: 18px;
}
.Screen header h1 p {
font-size: 12px;
font-weight: normal;
margin:0;
}
.Screen header button {
position: absolute;
top: 0px;
}
.Screen header button.pull-left { left: 0px; }
.Screen header button.pull-right { right: 0px; }
///**Generating suggestions**\\\\We take a (thread, script, userId) pair, and decide what notifications to that user will keep the script moving along in the thread.
function suggestions(thread, script, userId){
if (!script) return []
if (!thread.roles) thread.roles = {}
let castAsAnyOf = (roles) => roles.some(
r => thread.roles[r] && thread.roles[r][userId]
)
return script.cues.filter(m => { ///Each notification relates to a cue from the script. Conditions for a particular cue are met if:
if (!castAsAnyOf(m.senders)) return false ///- the user is casted as a sender of the cue
if (m.conditions.delay){ ///- any requested delay has elapsed
if (Date.now() - thread.ctime < m.conditions.delay) return false
}
for (var k in m.conditions){
if (m.conditions[k] == 'exists' && !thread.roles[k]) return false ///- any other necessary roles are casted
if (m.conditions[k] == 'known' && !thread[k]) return false ///- any necessary knowledge is known
return true
}
return true
}).map(m => ({ ///So cues cause suggestions when their conditions are met and the user is one of the senders
id: `draft-${m.id}-${userId}`,
cue: m,
groupId: thread.groupId,
threadId: thread.id
}))
}
export default suggestions
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment