diff --git a/README.md b/README.md deleted file mode 100644 index 05412b9..0000000 --- a/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Graffiti Javascript Library - -This library enables any webpage to interface with the [Graffiti server](https://github.com/graffiti-garden/graffiti-server). It also includes a plugin that extends it to operate with the [Vue.js framework](https://vuejs.org/). - -Check out the live [demo](https://graffiti.garden/graffiti-js/demo) of the library and plugin in action. The demo's source code is in the [`/demo`](https://github.com/graffiti-garden/graffiti-js/tree/main/demo) folder. diff --git a/demo/components/chat.js b/components/chat.js similarity index 100% rename from demo/components/chat.js rename to components/chat.js diff --git a/demo/components/comment.js b/components/comment.js similarity index 100% rename from demo/components/comment.js rename to components/comment.js diff --git a/demo/components/like-button.js b/components/like-button.js similarity index 100% rename from demo/components/like-button.js rename to components/like-button.js diff --git a/demo/components/moderation.js b/components/moderation.js similarity index 100% rename from demo/components/moderation.js rename to components/moderation.js diff --git a/demo/components/name.js b/components/name.js similarity index 100% rename from demo/components/name.js rename to components/name.js diff --git a/demo/components/private-messaging.js b/components/private-messaging.js similarity index 100% rename from demo/components/private-messaging.js rename to components/private-messaging.js diff --git a/graffiti.js b/graffiti.js deleted file mode 100644 index 65c6ece..0000000 --- a/graffiti.js +++ /dev/null @@ -1,351 +0,0 @@ -import Auth from './src/auth.js' -import GraffitiArray from './src/array.js' - -export default class { - - // There needs to be a new object map for each tag - constructor( - graffitiURL="https://graffiti.garden", - objectConstructor=()=>({})) { - - this.graffitiURL = graffitiURL - this.open = false - this.eventTarget = new EventTarget() - this.tagMap = objectConstructor() // tag->{count, Set(uuid)} - this.objectMap = objectConstructor() // uuid->object - this.GraffitiArray = GraffitiArray(this) - - this.#initialize() - } - - async #initialize() { - // Perform authorization - this.authParams = await Auth.connect(this.graffitiURL) - - // Rewrite the URL - this.wsURL = new URL(this.graffitiURL) - this.wsURL.host = "app." + this.wsURL.host - if (this.wsURL.protocol == 'https:') { - this.wsURL.protocol = 'wss:' - } else { - this.wsURL.protocol = 'ws:' - } - if (this.authParams.token) { - this.wsURL.searchParams.set("token", this.authParams.token) - } - - // Commence connection - this.#connect() - } - - // Wait for the connection to be - // open (state=true) or closed (state=false) - async connectionState(state) { - if (this.open != state) { - await new Promise(resolve => { - this.eventTarget.addEventListener( - state? "open": "closed", ()=> resolve()) - }) - } - } - - #connect() { - this.ws = new WebSocket(this.wsURL) - this.ws.onmessage = this.#onMessage.bind(this) - this.ws.onclose = this.#onClose.bind(this) - this.ws.onopen = this.#onOpen.bind(this) - } - - // authorization functions - get myID() { return this.authParams.myID } - toggleLogIn() { - this.myID? Auth.logOut() : Auth.logIn(this.graffitiURL) - } - - async #onClose() { - console.error("lost connection to graffiti server, attemping reconnect soon...") - this.open = false - this.eventTarget.dispatchEvent(new Event("closed")) - await new Promise(resolve => setTimeout(resolve, 2000)) - this.#connect() - } - - async #request(msg) { - if (!this.open) { - throw "Can't make request! Not connected to graffiti server" - } - - // Create a random message ID - const messageID = crypto.randomUUID() - - // Create a listener for the reply - const dataPromise = new Promise(resolve => { - this.eventTarget.addEventListener('$'+messageID, (e) => { - resolve(e.data) - }) - }) - - // Send the request - msg.messageID = messageID - this.ws.send(JSON.stringify(msg)) - - // Await the reply - const data = await dataPromise - delete data.messageID - - if ('error' in data) { - throw data - } else { - return data.reply - } - } - - #onMessage(event) { - const data = JSON.parse(event.data) - - if ('messageID' in data) { - // It's a reply - // Forward it back to the sender - const messageEvent = new Event('$'+data.messageID) - messageEvent.data = data - this.eventTarget.dispatchEvent(messageEvent) - - } else if ('update' in data) { - this.#updateCallback(data['update']) - - } else if ('remove' in data) { - this.#removeCallback(data['remove']) - - } else if (data.type == 'error') { - if (data.reason == 'authorization') { - Auth.logOut() - } - throw data - } - } - - #updateCallback(object) { - const uuid = this.#objectUUID(object) - - // Add the UUID to the tag map - let subscribed = false - for (const tag of object._tags) { - if (!(tag in this.tagMap)) continue - this.tagMap[tag].uuids.add(uuid) - subscribed = true - } - - if (!subscribed) return - - // Define object specific properties - if (!('_id' in object)) { - // Assign the object UUID - Object.defineProperty(object, '_id', { value: uuid }) - - // Add proxy functions so object modifications - // sync with the server - object = new Proxy(object, this.#objectHandler(object, true)) - } - - this.objectMap[uuid] = object - } - - #removeCallback(object) { - const uuid = this.#objectUUID(object) - - // Remove the UUID from all relevant tag maps - let supported = false - for (const tag in this.tagMap) { - if (this.tagMap[tag].uuids.has(uuid)) { - if (object._tags.includes(tag)) { - this.tagMap[tag].uuids.delete(uuid) - } else { - supported = true - } - } - } - - // If all tags have been removed, delete entirely - if (!supported && uuid in this.objectMap) { - delete this.objectMap[uuid] - } - } - - async update(object) { - object._by = this.myID - if (!object._key) object._key = crypto.randomUUID() - - // Immediately replace the object - this.#updateCallback(object) - - // Send it to the server - try { - await this.#request({ update: object }) - } catch(e) { - // Delete the temp object - this.#removeCallback(object) - throw e - } - } - - #objectHandler(object, root) { - return { - get: (target, prop, receiver)=> - this.#getObjectProperty(object, target, prop, receiver), - set: (target, prop, val, receiver)=> - this.#setObjectProperty(object, root, target, prop, val, receiver), - deleteProperty: (target, prop)=> - this.#deleteObjectProperty(object, root, target, prop) - } - } - - #getObjectProperty(object, target, prop, receiver) { - if (typeof target[prop] === 'object' && target[prop] !== null) { - return new Proxy(Reflect.get(target, prop, receiver), this.#objectHandler(object, false)) - } else { - return Reflect.get(target, prop, receiver) - } - } - - #setObjectProperty(object, root, target, prop, val, receiver) { - // Store the original, perform the update, - // sync with server and restore original if error - const originalObject = Object.assign({}, object) - if (Reflect.set(target, prop, val, receiver)) { - this.#removeCallback(originalObject) - this.#updateCallback(object) - this.#request({ update: object }).catch(e=> { - this.#removeCallback(object) - this.#updateCallback(originalObject) - throw e - }) - return true - } else { return false } - } - - #deleteObjectProperty(object, root, target, prop) { - const originalObject = Object.assign({}, object) - if (root && ['_key', '_by', '_tags'].includes(prop)) { - // This is a deletion of the whole object - this.#removeCallback(object) - this.#request({ remove: object._key }).catch(e=> { - this.#updateCallback(originalObject) - throw e - }) - return true - } else { - if (Reflect.deleteProperty(target, prop)) { - this.#request({ update: object }).catch(e=> { - this.#updateCallback(originalObject) - throw e - }) - return true - } else { return false } - } - } - - async myTags() { - return await this.#request({ ls: null }) - } - - async objectByKey(userID, objectKey) { - return await this.#request({ get: { - _by: userID, - _key: objectKey - }}) - } - - objects(...tags) { - tags = tags.filter(tag=> tag!=null) - for (const tag of tags) { - if (!(tag in this.tagMap)) { - throw `You are not subscribed to '${tag}'` - } - } - - // Merge by UUIDs from all tags and - // convert to relevant objects - const uuids = new Set(tags.map(tag=>[...this.tagMap[tag].uuids]).flat()) - const objects = [...uuids].map(uuid=> this.objectMap[uuid]) - - // Return an array wrapped with graffiti functions - return new this.GraffitiArray(...objects) - } - - async subscribe(...tags) { - tags = tags.filter(tag=> tag!=null) - // Look at what is already subscribed to - const subscribingTags = [] - for (const tag of tags) { - if (tag in this.tagMap) { - // Increase the count - this.tagMap[tag].count++ - } else { - // Create a new slot - this.tagMap[tag] = { - uuids: new Set(), - count: 1 - } - subscribingTags.push(tag) - } - } - - // Try subscribing in the background - // but don't raise an error since - // the subscriptions will happen once connected - if (subscribingTags.length) - try { - await this.#request({ subscribe: subscribingTags }) - } catch {} - } - - async unsubscribe(...tags) { - tags = tags.filter(tag=> tag!=null) - // Decrease the count of each tag, - // removing and marking if necessary - const unsubscribingTags = [] - for (const tag of tags) { - this.tagMap[tag].count-- - - if (!this.tagMap[tag].count) { - unsubscribingTags.push(tag) - delete this.tagMap[tag] - } - } - - // Unsubscribe from all remaining tags - if (unsubscribingTags.length) - try { - await this.#request({ unsubscribe: unsubscribingTags }) - } catch {} - } - - async #onOpen() { - console.log("connected to the graffiti socket") - this.open = true - this.eventTarget.dispatchEvent(new Event("open")) - - // Clear data - for (let tag in this.tagMap) { - this.tagMap[tag].uuids = new Set() - } - for (let uuid in this.objectMap) delete this.objectMap[uuid] - - // Resubscribe - const tags = Object.keys(this.tagMap) - if (tags.length) await this.#request({ subscribe: tags }) - } - - // Utility function to get a universally unique string - // that represents a particular object - #objectUUID(object) { - if (!object._by || !object._key) { - throw { - type: 'error', - content: 'the object you are trying to identify does not have an owner or key', - object - } - } - return object._by + object._key - } -} diff --git a/demo/index.html b/index.html similarity index 100% rename from demo/index.html rename to index.html diff --git a/plugins/vue/plugin.js b/plugins/vue/plugin.js deleted file mode 100644 index f04098e..0000000 --- a/plugins/vue/plugin.js +++ /dev/null @@ -1,76 +0,0 @@ -import { ref, reactive } from 'vue' -import Graffiti from '../../graffiti.js' - -export default { - install(app, options) { - - const graffitiURL = options && 'url' in options? - options.url : 'https://graffiti.garden' - - // Initialize graffiti with reactive entries - const graffiti = new Graffiti(graffitiURL, ()=>reactive({})) - - // Create a reactive variable that - // tracks connection state - const connectionState = ref(false) - ;(function waitForState(state) { - graffiti.connectionState(state).then(()=> { - connectionState.value = state - waitForState(!state) - })})(true) - Object.defineProperty(app.config.globalProperties, "$graffitiConnected", { - get: ()=> connectionState.value - }) - - // Latch on to the graffiti ID - // when the connection state first becomes true - let myID = null - Object.defineProperty(app.config.globalProperties, "$graffitiMyID", { - get: ()=> { - if (connectionState.value) myID = graffiti.myID - return myID - } - }) - - // Add static functions - for (const key of ['toggleLogIn', 'update', 'myTags', 'objectByKey']) { - const vueKey = '$graffiti' + key.charAt(0).toUpperCase() + key.slice(1) - app.config.globalProperties[vueKey] = graffiti[key].bind(graffiti) - } - - // A component for subscribing and - // unsubscribing to tags that returns - // a reactive array of the results - app.component('GraffitiObjects', { - - props: ['tags'], - - watch: { - tags: { - async handler(newTags, oldTags=[]) { - // Subscribe to the new tags - await graffiti.subscribe(...newTags) - // Unsubscribe to the existing tags - await graffiti.unsubscribe(...oldTags) - }, - immediate: true, - deep: true - } - }, - - // Handle unmounting too - unmount() { - graffiti.unsubscribe(this.tags) - }, - - computed: { - objects() { - return graffiti.objects(...this.tags) - } - }, - - template: '' - }) - - } -} diff --git a/src/array.js b/src/array.js deleted file mode 100644 index ae30012..0000000 --- a/src/array.js +++ /dev/null @@ -1,61 +0,0 @@ -// Extend the array class to expose update -// functionality, plus provide some -// useful helper methods -export default function(graffiti) { - - return class GraffitiArray extends Array { - - get mine() { - return this.filter(o=> o._by==graffiti.myID) - } - - get notMine() { - return this.filter(o=> o._by!=graffiti.myID) - } - - get authors() { - return [...new Set(this.map(o=> o._by))] - } - - removeMine() { - this.mine.map(o=> delete o._key) - } - - #getProperty(obj, propertyPath) { - // Split it up by periods - propertyPath = propertyPath.match(/([^\.]+)/g) - // Traverse down the path tree - for (const property of propertyPath) { - obj = obj[property] - } - return obj - } - - sortBy(propertyPath) { - - const sortOrder = propertyPath[0] == '-'? -1 : 1 - if (sortOrder < 0) propertyPath = propertyPath.substring(1) - - return this.sort((a, b)=> { - const propertyA = this.#getProperty(a, propertyPath) - const propertyB = this.#getProperty(b, propertyPath) - return sortOrder * ( - propertyA < propertyB? -1 : - propertyA > propertyB? 1 : 0 ) - }) - } - - groupBy(propertyPath) { - return this.reduce((chain, obj)=> { - const property = this.#getProperty(obj, propertyPath) - if (property in chain) { - chain[property].push(obj) - } else { - chain[property] = new GraffitiArray(obj) - } - return chain - }, {}) - } - - } -} diff --git a/src/auth.js b/src/auth.js deleted file mode 100644 index 8ee803d..0000000 --- a/src/auth.js +++ /dev/null @@ -1,125 +0,0 @@ -export default { - - async logIn(graffitiURL) { - // Generate a random client secret and state - const clientSecret = crypto.randomUUID() - const state = crypto.randomUUID() - - // The client ID is the secret's hex hash - const clientID = await this.sha256(clientSecret) - - // Store the client secret as a local variable - window.localStorage.setItem('graffitiClientSecret', clientSecret) - window.localStorage.setItem('graffitiClientID', clientID) - window.localStorage.setItem('graffitiAuthState', state) - - // Redirect to the login window - const loginURL = this.authURL(graffitiURL) - loginURL.searchParams.set('client_id', clientID) - loginURL.searchParams.set('redirect_uri', window.location.href) - loginURL.searchParams.set('state', state) - window.location.href = loginURL - }, - - async connect(graffitiURL) { - - // Check to see if we are already logged in - let token = window.localStorage.getItem('graffitiToken') - let myID = window.localStorage.getItem('graffitiID') - - if (!token || !myID) { - // Remove them both in case one exists - // and the other does not - token = myID = null - - // Check to see if we are redirecting back - const url = new URL(window.location) - if (url.searchParams.has('code')) { - - // Extract the code and state from the URL and strip it from the history - const code = url.searchParams.get('code') - const state = url.searchParams.get('state') - url.searchParams.delete('code') - url.searchParams.delete('state') - window.history.replaceState({}, '', url) - - // Get stored variables and remove them - const clientSecret = window.localStorage.getItem('graffitiClientSecret') - const clientID = window.localStorage.getItem('graffitiClientID') - const storedState = window.localStorage.getItem('graffitiAuthState') - window.localStorage.removeItem('graffitiClientSecret') - window.localStorage.removeItem('graffitiClientID') - window.localStorage.removeItem('graffitiAuthState') - - // Make sure state has been preserved - if (state != storedState) { - throw new Error("The state in local storage does not match the state sent by the server") - } - - // Construct the body of the POST - let form = new FormData() - form.append('client_id', clientID) - form.append('client_secret', clientSecret) - form.append('code', code) - - // Ask to exchange the code for a token - const tokenURL = this.authURL(graffitiURL) - tokenURL.pathname = '/token' - const response = await fetch(tokenURL, { - method: 'post', - body: form - }) - - // Make sure the response is OK - if (!response.ok) { - let reason = response.status + ": " - try { - reason += (await response.json()).detail - } catch (e) { - reason += response.statusText - } - - throw new Error(`The authorization code could not be exchanged for a token.\n\n${reason}`) - } - - // Parse out the token - const data = await response.json() - token = data.access_token - myID = data.owner_id - - // And make sure that the token is valid - if (!token || !myID) { - throw new Error(`The authorization token could not be parsed from the response.\n\n${data}`) - } - - // Store the token and ID - window.localStorage.setItem('graffitiToken', token) - window.localStorage.setItem('graffitiID', myID) - } - } - - return { myID, token } - - }, - - logOut() { - window.localStorage.removeItem('graffitiToken') - window.localStorage.removeItem('graffitiID') - window.location.reload() - }, - - authURL(graffitiURL) { - const url = new URL(graffitiURL) - url.host = "auth." + url.host - return url - }, - - async sha256(input) { - const encoder = new TextEncoder() - const inputBytes = encoder.encode(input) - const outputBuffer = await crypto.subtle.digest('SHA-256', inputBytes) - const outputArray = Array.from(new Uint8Array(outputBuffer)) - return outputArray.map(b => b.toString(16).padStart(2, '0')).join('') - } - -} diff --git a/src/logoot.js b/src/logoot.js deleted file mode 100644 index c8c7c02..0000000 --- a/src/logoot.js +++ /dev/null @@ -1,153 +0,0 @@ -export default { - - query(property) { - return { - [property]: { - $type: 'array', - $type: ['int', 'long'], - }, - $nor: [ - { [property]: { $gt: this.maxInt } }, - { [property]: { $lt: 0 } }, - ] - } - }, - - get before() { - return [] - }, - - get after() { - return [this.maxInt+1] - }, - - between(a, b, scale=100) { - // Strip zeros and find common length - const aLength = this.lengthWithoutZeros(a) - const bLength = this.lengthWithoutZeros(b) - const minLength = Math.min(aLength, bLength) - - // Initialize output - const out = [] - - // Find the break point where a[i] != b[i] - let i = 0 - while (i < minLength && a[i] == b[i]) { - out.push(a[i]) - i++ - } - - // Initialize upper and lower bounds for - // sampling the last digit - let lowerBound = 1 - let upperBound = this.maxInt - - if (i < minLength) { - // If the break happened before we hit - // the end of one of the arrays - - if (Math.abs(a[i] - b[i]) > 1) { - // If a[i] and b[i] are more than one - // away from each other, just sample - // between them - lowerBound = Math.min(a[i], b[i]) + 1 - upperBound = Math.max(a[i], b[i]) - 1 - } else { - // If they are one away no integers - // will fit in between, so add new layer - const lesser = (a[i] < b[i])? a : b - out.push(lesser[i]) - i++ - - while (i < lesser.length && lesser[i] >= this.maxInt) { - // If the lesser is at it's limit, - // we will need to add even more layers - out.push(lesser[i]) - i++ - } - - if (i < lesser.length) { - // Sample something greater than - // the lesser digit - lowerBound = lesser[i] + 1 - } - } - } else { - // The break happened because we hit - // the end of one of the arrays. - - if (aLength == bLength) { - // If they are entirely equal, - // there is nothing in between - // just return what we have - return out - } - - const longerLength = Math.max(aLength, bLength) - const longer = (a.length == longerLength)? a : b - while (i < longerLength && longer[i] == 0) { - // Skip past the zeros because we can't sample - // for digits less than zero - out.push(0) - i++ - } - - if (i < longerLength) { - if (longer[i] == 1) { - // If longer is at it's limit, - // we still need to add another layer - out.push(0) - } else { - upperBound = longer[i] - 1 - } - } - } - - // Create a random number in [0,1] but bias it to be small, - // so that numbers tend to increase by a small amount. - let random = Math.random() - random = -Math.log(1-random)/scale - random = Math.min(random, 1) - - // Finally, sample between the upper and lower bounds - out.push(Math.floor(random * (upperBound + 1 - lowerBound)) + lowerBound) - return out - }, - - compare(a, b) { - // Strip zeros and find common length - const aLength = this.lengthWithoutZeros(a) - const bLength = this.lengthWithoutZeros(b) - const minLength = Math.min(aLength, bLength) - - // See if there are any differences - for (let i = 0; i < minLength; i++) { - if (a[i] > b[i]) { - return 1 - } else if (a[i] < b[i]) { - return -1 - } - } - - // If they are all the same up til now, - // the longer one is bigger - if (aLength > bLength) { - return 1 - } else if (aLength < bLength) { - return -1 - } else { - return 0 - } - }, - - - lengthWithoutZeros(a) { - let length = a.length - while (length > 0 && a[length - 1] == 0) { - length-- - } - return length - }, - - maxInt: 9007199254740991, -} diff --git a/demo/style.css b/style.css similarity index 100% rename from demo/style.css rename to style.css