From fd2abcbb76e9e70883e86e29b93a3f0b24e08f29 Mon Sep 17 00:00:00 2001 From: Iris Lightshard Date: Thu, 4 Jul 2024 19:27:29 -0600 Subject: [PATCH] building out frontend stuff... almost there --- build.sh | 32 +++++- models/msg.go | 50 ++++----- ts/adapter-element.ts | 209 +++++++++++++++++++++++++++++++++++ ts/index.ts | 4 + ts/message.ts | 48 +++----- ts/settings-element.ts | 2 +- ts/tabbar-element.ts | 2 +- ts/thread-summary-element.ts | 111 +++++++++++++++++++ ts/thread.ts | 69 ++++++++++++ ts/websocket.ts | 13 +-- 10 files changed, 470 insertions(+), 70 deletions(-) create mode 100644 ts/adapter-element.ts create mode 100644 ts/thread-summary-element.ts create mode 100644 ts/thread.ts diff --git a/build.sh b/build.sh index 5b3c346..66b69eb 100755 --- a/build.sh +++ b/build.sh @@ -1,8 +1,28 @@ #!/bin/sh -if [ ! -e ./src ]; then - mkdir ./src -fi - -npx tsc && -npx webpack --config webpack.config.js +case "$1" in + client) + if [ ! -e ./src ]; then + mkdir ./src + fi + buildlog=$(mktemp) + npx tsc 2>&1 | nobs | sed -e 's/\.ts\(/\.ts:/g' -e 's/,[0-9]+\)://g' > ${buildlog} + if [ -s ${buildlog} ]; then + cat ${buildlog} | head + rm ${buildlog} + else + npx webpack --config webpack.config.js + fi + ;; + server) + go mod tidy + go build + ;; + both) + $0 client + $0 server + ;; + *) + echo "USAGE: ${0} " + ;; +esac \ No newline at end of file diff --git a/models/msg.go b/models/msg.go index ea07478..8872413 100644 --- a/models/msg.go +++ b/models/msg.go @@ -6,42 +6,42 @@ import ( ) type Datagram struct { - Id string - Uri string - Protocol string - Adapter string - Type string - Target *string + Id string `json:"id"` + Uri string `json:"uri"` + Protocol string `json:"protocol"` + Adapter string `json:"adapter"` + Type string `json:"type"` + Target *string `json:"target,omitempty"` } type Message struct { Datagram - Author string - Content string - Attachments []Attachment - ReplyTo *string - Replies []string - ReplyCount int - Mentions []string - Created time.Time - Visibility string - Aux map[string]string + Author string `json:"author"` + Content string `json:"content"` + Attachments []Attachment `json:"attachments"` + ReplyTo *string `json:"replyTo"` + Replies []string `json:"replies"` + ReplyCount int `json:"replyCount"` + Mentions []string `json:"mentions"` + Created time.Time `json:"created"` + Edited *time.Time `json:"edited,omitempty"` + Visibility string `json:"visibility"` } type Author struct { Datagram - Name string - ProfileData interface{} - ProfilePic string - Messages []Message + Name string `json:"name"` + ProfileData interface{} `json:"profileData"` + ProfilePic string `json:"profilePic"` + Messages []string `json:"messages,omitempty"` } type Attachment struct { - Src string - ThumbSrc string - Desc string - CreatedAt time.Time - Size uint64 + Src string `json:"src"` + ThumbSrc string `json:"thumbSrc"` + Desc string `json:"desc"` + CreatedAt time.Time `json:"createdAt"` + Size uint64 `json:"size"` } type SocketData interface { diff --git a/ts/adapter-element.ts b/ts/adapter-element.ts new file mode 100644 index 0000000..0349a92 --- /dev/null +++ b/ts/adapter-element.ts @@ -0,0 +1,209 @@ +import util from "./util" +import { Message, Author } from "./message" +import { MessageThread } from "./thread" + +var _ = util._ +var $ = util.$ + +export class AdapterElement extends HTMLElement { + static observedAttributes = [ "data-latest", "data-view", "data-viewing" ] + + private _latest: string = "" ; + private _view: string = ""; + private _name: string = "" + private _viewing: string = ""; + + // TODO: use visibility of the thread to organize into DMs and public threads + private _threads: MessageThread[] = []; + private _orphans: Message[] = []; + + constructor() { + super(); + } + + connectedCallback() { + const name = this.getAttribute("data-name"); + this._name = name ?? ""; + this.buildThreads(); + this.attributeChangedCallback(); + } + + attributeChangedCallback() { + + // set the viewing subject if it's changed + const viewing = this.getAttribute("data-viewing"); + if (this._viewing != viewing && viewing != null) { + this._viewing = viewing; + // if the viewing subject changed (not to nothing), unset the view + // this will force it to refresh + if (this._viewing) { + this._view = ""; + } + } + + // initialize the view if it's changed + const view = this.getAttribute("data-view"); + if (this._view != view) { + console.log("view changed! let's go") + this._view = view ?? ""; + switch (this._view) { + case "index": + this.setIdxView(); + this.populateIdxView(); + break; + case "thread": + this.setThreadView(); + this.populateThreadView(); + break; + case "profile": + this.setProfileView(); + this.populateProfileView(); + break; + } + } + + // if latest changed, check if it's a message + const latest = this.getAttribute("latest"); + if (latest ?? "" != this._latest) { + this._latest = latest ?? ""; + console.log("about to update the index view"); + let datastore = _("datastore"); + console.log(datastore); + const latestMsg = datastore.messages.get(this._latest); + if (latestMsg) { + const rootId = this.placeMsg(this._latest); + // if rootId is null, this is an orphan and we don't need to actually do any updates yet + if (rootId) { + switch (this._view) { + case "index": + this.updateIdxView(this._latest, rootId); + break; + case "thread": + // if the the message is part of this thread, update it + case "profile": + // if the message is from this user, show it in their profile + break; + } + } + } else { + const latestAuthor = _("datastore")[this._name].profileCache.get(this._latest); + switch (this._view) { + case "index": + case "thread": + case "profile": + break; + } + } + // so, try to insert it into the threads + // then, switch on view + // if index, iterate through the topics and find the one to indicate new activity, + // if thread, if any relatives are in this thread, insert message appropriately + // if profile, if latest is this profile, update it + } + + } + + setIdxView() { + this.innerHTML = "" + } + + setThreadView() { + let html = `← return to index`; + html += ""; + this.innerHTML = html; + } + + setProfileView() { + let profile_bar = $("profile_bar"); + if (profile_bar) { + // clear any previous data + } else { + // insert the profileSidebar into the dom + } + } + + populateIdxView() { + // skip dm list for now + // public/unified list + const pl = $("public_list"); + if (pl) { + console.log(JSON.stringify(this._threads)); + for (const t of this._threads) { + console.log(t.root.data.id); + pl.append(`
  • `); + } + } + } + + updateIdxView(latest: string, rootId: string) { + const existingThread = this.querySelector("underbbs-thread-summary[msg='${this._latest}']"); + const thread = this._threads.find(t=>t.root.data.id == rootId); + if (existingThread && thread) { + existingThread.setAttribute("data-latest", `${thread.latest[Symbol.toPrimitive]("number")}`); + existingThread.setAttribute("data-len", `${thread.messageCount}`); + existingThread.setAttribute("data-new", "true"); + } else { + // unified/public list for now + const pl = $("public_list"); + if (pl && thread) { + pl.prepend(`
  • `); + } + } + } + + populateThreadView() { + } + + populateProfileView() { + } + + buildThreads() { + const datastore = _("datastore")[this._name]; + // make multiple passes over the store until every message is either + // placed in a thread, or orphaned and waiting for its parent to be returned + do{ + for (let k of datastore.messages.keys()) { + this.placeMsg(k); + } + } while (this._threads.reduce((sum: number, thread: MessageThread)=>{ + return sum + thread.messageCount; + }, 0) + this._orphans.length < datastore.messages.keys().length) + } + + placeMsg(k: string): string | null { + const msg = _("datastore")[this._name].messages.get(k); + if (msg.replyTo) { + for (let t of this._threads) { + // avoid processing nodes again on subsequent passes + if (t.findNode(t.root, msg.id)) { + return null; + } + + let x = t.findNode(t.root, msg.replyTo); + if (x) { + t.addReply(msg.replyTo, msg); + + // after adding, we try to adopt some orphans + const orphanChildren = this._orphans.filter(m=>m.replyTo == k); + for (let o of orphanChildren) { + let adopted = this.placeMsg(o.id); + if (adopted) { + this._orphans.splice(this._orphans.indexOf(o), 1); + } + } + return t.root.data.id; + } + } + if (this._orphans.filter(o=>o.id == msg.id).length == 0) { + this._orphans.push(msg); + // TODO: request the parent's data + } + return null; + } else { + this._threads.push(new MessageThread(msg)); + return k; + } + } + + +} \ No newline at end of file diff --git a/ts/index.ts b/ts/index.ts index 81e4c14..398fef6 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -4,6 +4,8 @@ import util from "./util" import { TabBarElement } from "./tabbar-element" import { MessageElement } from "./message-element" import { SettingsElement } from "./settings-element" +import { AdapterElement } from "./adapter-element" +import { ThreadSummaryElement } from "./thread-summary-element" var $ = util.$ var _ = util._ @@ -14,6 +16,8 @@ function main() { customElements.define("underbbs-tabbar", TabBarElement); customElements.define("underbbs-message", MessageElement); customElements.define("underbbs-settings", SettingsElement); + customElements.define("underbbs-adapter", AdapterElement); + customElements.define("underbbs-thread-summary", ThreadSummaryElement); tabbarInit(settings.adapters?.map((a:any)=>a.nickname) ?? []); diff --git a/ts/message.ts b/ts/message.ts index c9d1b02..dc2d799 100644 --- a/ts/message.ts +++ b/ts/message.ts @@ -1,47 +1,35 @@ -import {NDKEvent} from "@nostr-dev-kit/ndk" -import * as masto from "masto"; - -type APStatus = masto.mastodon.v1.Status; - export class Message { - public author: Author = new Author(); + public id: string = ""; + public uri: string = ""; public protocol: string = ""; + public adapter: string = ""; + public author: string = "" public content: string = ""; public attachments: Attachment[] = []; - public replyTo: Message | null = null; - public replies: Message[] = []; - public mentions: Author[] = []; + public replyTo: string | null = null; + public replies: string[] = []; + public mentions: string[] = []; public created: Date = new Date(); - public edited: Date = new Date(); + public edited: Date | null = null; public visibility: string = "public"; - - // this will contain additional data about what kind of message it is - public aux: any | null = null; - - public static fromNostr(event: NDKEvent): Message { - let self = new Message(); - // build out the message based on the contents of event - return self; - } - - public static fromMasto(status: APStatus): Message { - let self = new Message(); - // build out the message based on the contents of status - return self; - } } export class Author { public id: string = ""; + public uri: string = ""; + public protocol: string = ""; + public adapter: string = ""; public name: string = ""; - public profileData: string = ""; - public messages: Message[] = []; + public profileData: any = {}; + public profilePic: string = ""; + public messages: string[] = []; } export class Attachment { - public file: Uint8Array = new Uint8Array(); - public altText: string = ""; - public filename: string = ""; + public Src: string = ""; + public ThumbSrc: string = ""; + public Desc: string = ""; + public CreatedAt: Date = new Date(); } export default { Message, Attachment, Author } \ No newline at end of file diff --git a/ts/settings-element.ts b/ts/settings-element.ts index ea6734d..bde5a91 100644 --- a/ts/settings-element.ts +++ b/ts/settings-element.ts @@ -31,7 +31,7 @@ export class SettingsElement extends HTMLElement { return self; }, ""; - html += ""; + html += ""; self.innerHTML = html; let create = $("settings_adapter_create_btn"); diff --git a/ts/tabbar-element.ts b/ts/tabbar-element.ts index 5ecfef2..81f3c37 100644 --- a/ts/tabbar-element.ts +++ b/ts/tabbar-element.ts @@ -76,7 +76,7 @@ export class TabBarElement extends HTMLElement { return ()=>{ let x = $("mainarea_injectparent"); if (x) { - x.innerHTML = ``; + x.innerHTML = ``; self.setAttribute("data-currentadapter", adapter); } } diff --git a/ts/thread-summary-element.ts b/ts/thread-summary-element.ts new file mode 100644 index 0000000..196ed90 --- /dev/null +++ b/ts/thread-summary-element.ts @@ -0,0 +1,111 @@ +import util from "./util" +import { Message, Author } from "./message" +var _ = util._ +var $ = util.$ + +export class ThreadSummaryElement extends HTMLElement { + static observedAttributes = [ "data-len", "data-msg", "data-author", "data-latest", "data-created", "data-new" ]; + + private _len: number = 0;; + private _msg: Message | null = null;; + private _author: Author | null = null; + private _adapter: string = ""; + private _created: Date = new Date(); + private _latest: Date = new Date(); + private _new: boolean = false; + + constructor() { + super(); + } + + connectedCallback() { + this.innerHTML = "
    " + + // adapter shouldn't change, just set it here + this._adapter = this.getAttribute("data-adapter") ?? ""; + this.attributeChangedCallback(); + if (this._msg) { + this.addEventListener("click", this.viewThread(this), false); + } + } + + attributeChangedCallback() { + const datastore = _("datastore")[this._adapter]; + const msgId = this.getAttribute("data-msg"); + if (msgId && datastore && ((this._msg && this._msg.id != msgId) || !this._msg)) { + this._msg = datastore.messages.get(msgId); + if (this._msg) { + const threadText = this.querySelector(".thread_text"); + if (threadText) { + threadText.innerHTML = this._msg.content; + } + let author = datastore.profileCache.get(this._msg.author); + if (!author) { + // request it! + } + this._author = author || { id: this._msg.author }; + const threadAuthor = this.querySelector(".thread_author"); + if (threadAuthor && this._author) { + threadAuthor.innerHTML = this._author.profilePic + ? `${this._author.id} ${this._new ? "!" : ""}[${this._len}] created: ${this._created}, updated: ${this._latest}`; + } + } + } + + viewThread(self: ThreadSummaryElement) { + return () => { + const a = $(`adapter_${self._adapter}`); + if (a && self._msg) { + a.setAttribute("data-view", "thread"); + a.setAttribute("data-viewing", self._msg.id); + } + } + } +} \ No newline at end of file diff --git a/ts/thread.ts b/ts/thread.ts new file mode 100644 index 0000000..968c935 --- /dev/null +++ b/ts/thread.ts @@ -0,0 +1,69 @@ +import util from "./util" +import { Message } from "./message" +var _ = util._ +var $ = util.$ + +export class MessageNode { + public parent: MessageNode | null = null; + public children: MessageNode[] = []; + public data: Message; + + constructor(msg: Message, parent: MessageNode | null = null) { + this.data = msg; + this.parent = parent; + } + + findRoot(): MessageNode { + let self: MessageNode | null = this; + + while(self.parent) { + self = self.parent; + } + return self; + } +} + +export class MessageThread { + public root: MessageNode; + public messageCount: number; + public visibility: string; + public created: Date; + public latest: Date; + + constructor(first: Message) { + this.root = new MessageNode(first); + this.messageCount = 1; + this.visibility = first.visibility; + this.created = first.created; + this.latest = first.edited ? first.edited : first.created; + } + + addReply(parentID: string, reply: Message) { + let node = this.findNode(this.root, parentID); + if (node) { + node.children.push(new MessageNode(reply, node)); + this.messageCount++; + const mtime = reply.edited ? reply.edited : reply.created; + if (this.latest < mtime) { + this.latest = mtime; + } + } + } + + findNode(node: MessageNode, id: string): MessageNode | null { + if (node.data.id == id) { + return node; + } else { + for (let n of node.children) { + console.log("descending through children...") + const x = this.findNode(n, id); + if (x != null) { + return x; + } + } + } + return null; + } +} + + diff --git a/ts/websocket.ts b/ts/websocket.ts index b39cdc9..f4cb52e 100644 --- a/ts/websocket.ts +++ b/ts/websocket.ts @@ -7,8 +7,7 @@ var _ = util._ function connect() { - var datastore: AdapterState = {} - datastore = _("datastore", datastore); + let datastore = _("datastore", {}); const wsProto = location.protocol == "https:" ? "wss" : "ws"; const _conn = new WebSocket(`${wsProto}://${location.host}/subscribe`, "underbbs"); @@ -17,12 +16,8 @@ function connect() { console.log(JSON.stringify(e)); }); _conn.addEventListener("message", (e: any) => { - - // debugging - console.log(e) - - // now we'll figure out what to do with it const data = JSON.parse(e.data); + console.log(data); if (data.key) { _("skey", data.key) util.authorizedFetch("POST", "/api/adapters", JSON.stringify(_("settings").adapters)) @@ -43,6 +38,10 @@ function connect() { break; } // if the adapter is active, inject the web components + let adapter = $(`adapter_${data.adapter}`); + if (adapter) { + adapter.setAttribute("data-latest", data.id); + } // FOR HOTFETCHED DATA: // before fetching, we can set properties on the DOM, // so when those data return to us we know where to inject components!