building out frontend stuff... almost there

This commit is contained in:
Iris Lightshard 2024-07-04 19:27:29 -06:00
parent c39c9f9412
commit fd2abcbb76
Signed by: Iris Lightshard
GPG key ID: 688407174966CAF3
10 changed files with 470 additions and 70 deletions

View file

@ -1,8 +1,28 @@
#!/bin/sh #!/bin/sh
if [ ! -e ./src ]; then case "$1" in
mkdir ./src client)
fi if [ ! -e ./src ]; then
mkdir ./src
npx tsc && fi
npx webpack --config webpack.config.js 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} <client|server|both>"
;;
esac

View file

@ -6,42 +6,42 @@ import (
) )
type Datagram struct { type Datagram struct {
Id string Id string `json:"id"`
Uri string Uri string `json:"uri"`
Protocol string Protocol string `json:"protocol"`
Adapter string Adapter string `json:"adapter"`
Type string Type string `json:"type"`
Target *string Target *string `json:"target,omitempty"`
} }
type Message struct { type Message struct {
Datagram Datagram
Author string Author string `json:"author"`
Content string Content string `json:"content"`
Attachments []Attachment Attachments []Attachment `json:"attachments"`
ReplyTo *string ReplyTo *string `json:"replyTo"`
Replies []string Replies []string `json:"replies"`
ReplyCount int ReplyCount int `json:"replyCount"`
Mentions []string Mentions []string `json:"mentions"`
Created time.Time Created time.Time `json:"created"`
Visibility string Edited *time.Time `json:"edited,omitempty"`
Aux map[string]string Visibility string `json:"visibility"`
} }
type Author struct { type Author struct {
Datagram Datagram
Name string Name string `json:"name"`
ProfileData interface{} ProfileData interface{} `json:"profileData"`
ProfilePic string ProfilePic string `json:"profilePic"`
Messages []Message Messages []string `json:"messages,omitempty"`
} }
type Attachment struct { type Attachment struct {
Src string Src string `json:"src"`
ThumbSrc string ThumbSrc string `json:"thumbSrc"`
Desc string Desc string `json:"desc"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt"`
Size uint64 Size uint64 `json:"size"`
} }
type SocketData interface { type SocketData interface {

209
ts/adapter-element.ts Normal file
View file

@ -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 = "<ul id='dm_list'></ul><ul id='public_list'></ul>"
}
setThreadView() {
let html = `<a href="#${this._name}">&larr; return to index</a>`;
html += "<ul id='msg_list'></ul>";
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(`<li><underbbs-thread-summary data-len="${t.messageCount}" data-adapter="${t.root.data.adapter}" data-msg="${t.root.data.id}" data-latest="${t.latest}" data-created="${t.created}" data-new=""></underbbs-thread-summary></li>`);
}
}
}
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(`<li><underbbs-thread-summary data-len="${thread.messageCount}" data-adapter="${thread.root.data.adapter}" data-msg="${thread.root.data.id}" data-latest="${thread.latest}" data-created="${thread.created}" data-new="true"></underbbs-thread-summary></li>`);
}
}
}
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;
}
}
}

View file

@ -4,6 +4,8 @@ import util from "./util"
import { TabBarElement } from "./tabbar-element" import { TabBarElement } from "./tabbar-element"
import { MessageElement } from "./message-element" import { MessageElement } from "./message-element"
import { SettingsElement } from "./settings-element" import { SettingsElement } from "./settings-element"
import { AdapterElement } from "./adapter-element"
import { ThreadSummaryElement } from "./thread-summary-element"
var $ = util.$ var $ = util.$
var _ = util._ var _ = util._
@ -14,6 +16,8 @@ function main() {
customElements.define("underbbs-tabbar", TabBarElement); customElements.define("underbbs-tabbar", TabBarElement);
customElements.define("underbbs-message", MessageElement); customElements.define("underbbs-message", MessageElement);
customElements.define("underbbs-settings", SettingsElement); customElements.define("underbbs-settings", SettingsElement);
customElements.define("underbbs-adapter", AdapterElement);
customElements.define("underbbs-thread-summary", ThreadSummaryElement);
tabbarInit(settings.adapters?.map((a:any)=>a.nickname) ?? []); tabbarInit(settings.adapters?.map((a:any)=>a.nickname) ?? []);

View file

@ -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 { export class Message {
public author: Author = new Author(); public id: string = "";
public uri: string = "";
public protocol: string = ""; public protocol: string = "";
public adapter: string = "";
public author: string = ""
public content: string = ""; public content: string = "";
public attachments: Attachment[] = []; public attachments: Attachment[] = [];
public replyTo: Message | null = null; public replyTo: string | null = null;
public replies: Message[] = []; public replies: string[] = [];
public mentions: Author[] = []; public mentions: string[] = [];
public created: Date = new Date(); public created: Date = new Date();
public edited: Date = new Date(); public edited: Date | null = null;
public visibility: string = "public"; 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 { export class Author {
public id: string = ""; public id: string = "";
public uri: string = "";
public protocol: string = "";
public adapter: string = "";
public name: string = ""; public name: string = "";
public profileData: string = ""; public profileData: any = {};
public messages: Message[] = []; public profilePic: string = "";
public messages: string[] = [];
} }
export class Attachment { export class Attachment {
public file: Uint8Array = new Uint8Array(); public Src: string = "";
public altText: string = ""; public ThumbSrc: string = "";
public filename: string = ""; public Desc: string = "";
public CreatedAt: Date = new Date();
} }
export default { Message, Attachment, Author } export default { Message, Attachment, Author }

View file

@ -31,7 +31,7 @@ export class SettingsElement extends HTMLElement {
return self; return self;
}, "<ul id='settings_adapterlist'>"); }, "<ul id='settings_adapterlist'>");
html += "</ul>"; html += "</ul>";
html += "<button id='settings_save_btn'>save</button>"; html += "<button id='settings_connect_btn'>connect</button>";
self.innerHTML = html; self.innerHTML = html;
let create = $("settings_adapter_create_btn"); let create = $("settings_adapter_create_btn");

View file

@ -76,7 +76,7 @@ export class TabBarElement extends HTMLElement {
return ()=>{ return ()=>{
let x = $("mainarea_injectparent"); let x = $("mainarea_injectparent");
if (x) { if (x) {
x.innerHTML = `<underbbs-adapter data-name="${adapter}"></underbbs-adapter>`; x.innerHTML = `<underbbs-adapter id="adapter_${adapter}" data-name="${adapter}" data-view="index"></underbbs-adapter>`;
self.setAttribute("data-currentadapter", adapter); self.setAttribute("data-currentadapter", adapter);
} }
} }

View file

@ -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 = "<div class='thread_summary'><div class='thread_author'></div><div class='thread_text'></div><div class='thread_metadata'></div></div>"
// 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 || <Author>{ id: this._msg.author };
const threadAuthor = this.querySelector(".thread_author");
if (threadAuthor && this._author) {
threadAuthor.innerHTML = this._author.profilePic
? `<img src="${this._author.profilePic}" alt="${this._author.id}"/> <a id="thread_${this._adapter}_${this._msg.id}_${this._author.id}" href="#author?id=${this._author.id}>${this._author.id}</a>`
: `<a id="thread_${this._adapter}_${this._msg.id}_${this._author.id}" href="#author?id=${this._author.id}>${this._author.id}</a>`;
}
const authorId = this.getAttribute("data-author");
if (authorId) {
let author = datastore?.profileCache?.get(this._msg?.author);
if (author) {
this._author = author;
const threadAuthor = this.querySelector(".thread_author");
if (threadAuthor && this._author) {
threadAuthor.innerHTML = this._author.profilePic
? `<img src="${this._author.profilePic}" alt="${this._author.id}"/> <a id="thread_${this._adapter}_${this._msg.id}_${this._author.id}" href="#author?id=${this._author.id}>${this._author.id}</a>`
: `<a id="thread_${this._adapter}_${this._msg.id}_${this._author.id}" href="#author?id=${author.id}>${this._author.id}</a>` ;
}
}
}
} }
const l = parseInt(this.getAttribute("data-len") ?? "0");
const latest = new Date(this.getAttribute("data-latest") ?? 0);
const created = new Date(this.getAttribute("data-created")?? 0);
const newness = this.getAttribute("data-new") ? true : false;
let metadataChanged = false;
if (l != this._len) {
metadataChanged = true;
this._len = l;
}
if (latest != this._latest) {
metadataChanged = true;
this._latest = latest;
}
if (created != this._created) {
metadataChanged = true;
this._created = created;
}
if (newness != this._new) {
metadataChanged = true;
this._new = newness;
}
if (metadataChanged) {
const threadMeta = this.querySelector(".thread_metadata");
if (threadMeta) {
threadMeta.innerHTML = `<span>${this._new ? "!" : ""}[${this._len}] created: ${this._created}, updated: ${this._latest}</span>`;
}
}
}
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);
}
}
}
}

69
ts/thread.ts Normal file
View file

@ -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;
}
}

View file

@ -7,8 +7,7 @@ var _ = util._
function connect() { function connect() {
var datastore: AdapterState = {} let datastore = <AdapterState>_("datastore", {});
datastore = _("datastore", datastore);
const wsProto = location.protocol == "https:" ? "wss" : "ws"; const wsProto = location.protocol == "https:" ? "wss" : "ws";
const _conn = new WebSocket(`${wsProto}://${location.host}/subscribe`, "underbbs"); const _conn = new WebSocket(`${wsProto}://${location.host}/subscribe`, "underbbs");
@ -17,12 +16,8 @@ function connect() {
console.log(JSON.stringify(e)); console.log(JSON.stringify(e));
}); });
_conn.addEventListener("message", (e: any) => { _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); const data = JSON.parse(e.data);
console.log(data);
if (data.key) { if (data.key) {
_("skey", data.key) _("skey", data.key)
util.authorizedFetch("POST", "/api/adapters", JSON.stringify(_("settings").adapters)) util.authorizedFetch("POST", "/api/adapters", JSON.stringify(_("settings").adapters))
@ -43,6 +38,10 @@ function connect() {
break; break;
} }
// if the adapter is active, inject the web components // 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: // FOR HOTFETCHED DATA:
// before fetching, we can set properties on the DOM, // before fetching, we can set properties on the DOM,
// so when those data return to us we know where to inject components! // so when those data return to us we know where to inject components!