diff --git a/adapter/adapter.go b/adapter/adapter.go index bd661be..6e0346b 100644 --- a/adapter/adapter.go +++ b/adapter/adapter.go @@ -7,7 +7,7 @@ import ( type Adapter interface { Init(Settings, *chan SocketData) error Name() string - Subscribe(string) []error + Subscribe(string, *string) []error Fetch(string, []string) error Do(string, map[string]string) error DefaultSubscriptionFilter() string diff --git a/adapter/anonAp.go b/adapter/anonAp.go index 08dd5b8..eea2e23 100644 --- a/adapter/anonAp.go +++ b/adapter/anonAp.go @@ -137,10 +137,11 @@ func getBodyJson(res *http.Response) []byte { l := res.ContentLength // 4k is a reasonable max size if we get unknown length right? if l < 0 { - l = 4096 + l = 9999999 } jsonData := make([]byte, l) res.Body.Read(jsonData) + return jsonData } diff --git a/adapter/honk.go b/adapter/honk.go index ff4ee74..d2d731a 100644 --- a/adapter/honk.go +++ b/adapter/honk.go @@ -22,8 +22,8 @@ type donk struct { type honk struct { ID int Honker string - Handles []string - Oonker *string + Handles string + Oonker string XID string RID *string Noise string @@ -33,6 +33,12 @@ type honk struct { Date string } +type honkResponse struct { + Honks []honk `json:"honks"` + ChatCount int `json:"chatcount"` + MeCount int `json:"mecount"` +} + type HonkAdapter struct { data *chan SocketData nickname string @@ -98,19 +104,19 @@ func (self *HonkAdapter) Init(settings Settings, data *chan SocketData) error { return nil } -func (self *HonkAdapter) Subscribe(filter string) []error { +func (self *HonkAdapter) Subscribe(filter string, target *string) []error { if self.stop != nil { close(self.stop) self.maxId = 0 } self.stop = make(chan bool) - go self.gethonks(filter) + go self.gethonks(filter, target) return nil } -func (self *HonkAdapter) gethonks(filter string) { +func (self *HonkAdapter) gethonks(filter string, target *string) { for { select { @@ -119,6 +125,7 @@ func (self *HonkAdapter) gethonks(filter string) { return } default: + fmt.Println("bout to get honks") honkForm := url.Values{ "action": []string{"gethonks"}, "token": []string{self.token}, @@ -129,24 +136,32 @@ func (self *HonkAdapter) gethonks(filter string) { } res, err := http.PostForm(self.server+"/api", honkForm) if err != nil { - // return? + fmt.Println("fucked up: " + err.Error()) + self.stop <- true + return } - honksData := getBodyJson(res) - honks := []honk{} - json.Unmarshal(honksData, &honks) - for _, h := range honks { + fmt.Println("we got some honks") + hr := honkResponse{} + err = json.NewDecoder(res.Body).Decode(&hr) + if err != nil { + fmt.Println("malformed honks: " + err.Error()) + self.stop <- true + return + } + for _, h := range hr.Honks { if h.ID > self.maxId { self.maxId = h.ID - msg := self.toMsg(h) - self.send(msg) } + msg := self.toMsg(h, target) + fmt.Println("gonna send a honk on the channel dawg") + self.send(msg) } time.Sleep(5 * time.Second) } } } -func (self *HonkAdapter) toMsg(h honk) Message { +func (self *HonkAdapter) toMsg(h honk, target *string) Message { t, err := time.Parse(time.RFC3339, h.Date) if err != nil { t = time.Now() @@ -154,8 +169,8 @@ func (self *HonkAdapter) toMsg(h honk) Message { tt := t.UnixMilli() a := h.Honker - if h.Oonker != nil { - a = *h.Oonker + if h.Oonker != "" { + a = h.Oonker } msg := Message{ @@ -175,9 +190,9 @@ func (self *HonkAdapter) toMsg(h honk) Message { if h.Public { msg.Visibility = "Public" } - if h.Oonker != nil { + if h.Oonker != "" { r := fmt.Sprintf("%s/bonk/%d", h.Honker, h.ID) - msg.Renoter = h.Oonker + msg.Renoter = &h.Oonker msg.RenoteId = &r msg.RenoteTime = &tt } @@ -188,6 +203,9 @@ func (self *HonkAdapter) toMsg(h honk) Message { } msg.Attachments = append(msg.Attachments, a) } + if target != nil { + msg.Target = target + } return msg } diff --git a/adapter/mastodon.go b/adapter/mastodon.go index a7efd1d..4aacd9f 100644 --- a/adapter/mastodon.go +++ b/adapter/mastodon.go @@ -50,7 +50,7 @@ func (self *MastoAdapter) Init(settings Settings, data *chan SocketData) error { return err } -func (self *MastoAdapter) Subscribe(filter string) []error { +func (self *MastoAdapter) Subscribe(filter string, target *string) []error { // TODO: decode separate timelines and hashtags // for now, the filter is just the timeline diff --git a/adapter/misskey.go b/adapter/misskey.go index eba427c..fa14206 100644 --- a/adapter/misskey.go +++ b/adapter/misskey.go @@ -72,7 +72,7 @@ func (self *MisskeyAdapter) Init(settings Settings, data *chan SocketData) error return nil } -func (self *MisskeyAdapter) Subscribe(filter string) []error { +func (self *MisskeyAdapter) Subscribe(filter string, target *string) []error { // misskey streaming API is undocumented.... // we could try to reverse engineer it by directly connecting to the websocket??? // alternatively, we can poll timelines, mentions, etc with a cancellation channel, diff --git a/adapter/nostr.go b/adapter/nostr.go index 04e805e..7d4abad 100644 --- a/adapter/nostr.go +++ b/adapter/nostr.go @@ -48,7 +48,7 @@ func (self *NostrAdapter) Init(settings Settings, data *chan SocketData) error { return nil } -func (self *NostrAdapter) Subscribe(filter string) []error { +func (self *NostrAdapter) Subscribe(filter string, target *string) []error { var filters nostr.Filters err := json.Unmarshal([]byte(filter), &filters) if err != nil { diff --git a/frontend/dist/index.html b/frontend/dist/index.html index c5e39f8..bbe4d0a 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -14,9 +14,14 @@
+
settings
+
timeline
+
+
profile
honks
+
diff --git a/frontend/dist/style.css b/frontend/dist/style.css index 788ec29..7ee7d6a 100644 --- a/frontend/dist/style.css +++ b/frontend/dist/style.css @@ -64,4 +64,7 @@ nav ul li a { main { padding: 2em; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto; } \ No newline at end of file diff --git a/frontend/ts/adapter-element.ts b/frontend/ts/adapter-element.ts deleted file mode 100644 index 92063da..0000000 --- a/frontend/ts/adapter-element.ts +++ /dev/null @@ -1,285 +0,0 @@ -import util from "./util" - -import { Message, Author } from "./message" -import { MessageThread } from "./thread" -import { AdapterState } from "./adapter" -import { BatchTimer } from "./batch-timer" - -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 = ""; - - private _convoyBatchTimer = new BatchTimer((ids: string[])=>{ - let url = `/api/adapters/${this._name}/fetch?entity_type=convoy`; - for (let id of ids) { - url += `&entity_id=${id}`; - } - util.authorizedFetch("GET", url, null) - }); - - // TODO: use visibility of the thread to organize into DMs and public threads - private _threads: MessageThread[] = []; - private _orphans: Message[] = []; - private _boosts: Message[] = []; - - constructor() { - super(); - } - - connectedCallback() { - const name = this.getAttribute("data-name"); - this._name = name ?? ""; - this.buildThreads(); - } - - attributeChangedCallback(attr: string, prev: string, next: string) { - switch (attr) { - case "data-viewing": - if (next != prev) { - this._viewing = next; - } - // probably only needed for profile view; we might be able to move this to child components - // keep it simple for now, but eventually we will need a way to force _view to update - break; - case "data-view": - if (!next) { - this._view = "index"; - } else { - this._view = next; - } - switch (this._view) { - case "index": - this.setIdxView(); - this.populateIdxView(); - break; - case "thread": - this.setThreadView(); - this.populateThreadView(); - break; - case "profile": - this.setProfileView(); - this.populateProfileView(); - break; - } - break; - case "data-latest": - let datastore = AdapterState._instance.data.get(this._name); - if (!datastore) { - // this shouldn't be possible - return; - } - if (prev != next && next) { - this._latest = next; - } - 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 UI 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.profileCache.get(this._latest); - if (latestAuthor) { - switch (this._view) { - case "index": - const threadsByThisAuthor = this._threads.filter(t=>t.root.data.author == this._latest); - for (let t of threadsByThisAuthor) { - let tse = this.querySelector(`underbbs-thread-summary[data-msg='${t.root.data.id}']`) - if (tse) { - tse.setAttribute("data-author", this._latest); - } - } - // also update any boosts by this author - case "thread": - case "profile": - break; - } - } - } - break; - } - } - - setIdxView() { - this.innerHTML = "" - } - - setThreadView() { - let html = `← return to index`; - html += ""; - this.innerHTML = html; - } - - setProfileView() { - let profile_bar = util.$("profile_bar"); - if (profile_bar) { - // clear any previous data - } else { - // insert the profileSidebar into the dom - } - } - - populateIdxView() { - // populate boost carousel - - // skip dm list for now - // public/unified list - const pl = util.$("public_list"); - if (pl) { - let html = ""; - for (const t of this._threads.sort((a: MessageThread, b: MessageThread) => b.latest - a.latest)) { - html +=`
  • `; - } - pl.innerHTML = html; - } - } - - updateIdxView(latest: string, rootId: string) { - const threadSelector = `underbbs-thread-summary[data-msg='${rootId}']` - const existingThread = document.querySelector(threadSelector); - const thread = this._threads.find(t=>t.root.data.id == rootId); - if (existingThread && thread) { - existingThread.setAttribute("data-latest", `${thread.latest}`); - existingThread.setAttribute("data-len", `${thread.messageCount}`); - existingThread.setAttribute("data-new", "true"); - } else { - // if latest is a boost, put it in the carousel - - // unified/public list for now - const pl = util.$("public_list"); - if (pl && thread) { - const li = document.createElement("li"); - li.innerHTML = ``; - let nextThread: Element | null = null; - for (let i = 0; i < pl.children.length; i++) { - const c = pl.children.item(i); - const latest = c?.children.item(0)?.getAttribute("data-latest") - if (latest && parseInt(latest) < thread.latest) { - nextThread = c; - break; - } - } - if (nextThread) { - nextThread.insertAdjacentElement('beforebegin', li); - } else { - pl.append(li); - } - } - } - } - - populateThreadView() { - } - - populateProfileView() { - } - - buildThreads() { - const datastore = AdapterState._instance.data.get(this._name); - if (!datastore) { - util.errMsg(this._name + " has no datastore!"); - return; - } - // make multiple passes over the store until every message is either - // placed in a thread, the boost carousel, 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._boosts.length + this._orphans.length < datastore.messages.size); - } - - placeMsg(k: string): string | null { - const datastore = AdapterState._instance.data.get(this._name); - if (!datastore) { - util.errMsg(this._name + " has no datastore!"); - return null; - } - const msg = datastore.messages.get(k); - if (!msg) { - util.errMsg(`message [${this._name}:${k}] doesn't exist`); - return null; - } - if (msg.renoteId) { - // fetch the referent thread and put the boost in the carousel - this._convoyBatchTimer.queue(msg.renoteId, 2000); - if (!this._boosts.some(m=>m.id == msg.id)) { - this._boosts.push(msg); - } - return null; - } - for (let t of this._threads) { - // avoid processing nodes again on subsequent passes - if (!msg || t.findNode(t.root, msg.id)) { - return null; - } - - if (msg.replyTo) { - let x = t.addReply(msg.replyTo, msg); - if (x) { - // 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 we made it this far, this message doesn't go in any existing thread - - // if it doesn't have a parent, we can make a new thread with it - if (!msg.replyTo) { - this._threads.push(new MessageThread(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 msg.id; - } - - // then, we should check if its parent is an orphan - const orphanedParent = this._orphans.find(o=>o.id == msg.replyTo); - if (orphanedParent) { - // then, try to place them both - - if (this.placeMsg(orphanedParent.id)) { - this._orphans.splice(this._orphans.indexOf(orphanedParent), 1); - return this.placeMsg(k); - } - } - - // otherwise we can orphan it and try to fill it in later - if (this._orphans.filter(o=>o.id == msg.id).length == 0) { - this._orphans.push(msg); - if (msg.replyTo) { - this._convoyBatchTimer.queue(k, 2000); - } - - } - return null; - } -} \ No newline at end of file diff --git a/frontend/ts/author-messages-element.ts b/frontend/ts/author-messages-element.ts index 4d06744..9f54389 100644 --- a/frontend/ts/author-messages-element.ts +++ b/frontend/ts/author-messages-element.ts @@ -21,13 +21,7 @@ export class AuthorMessagesElement extends HTMLElement { this._id = this.getAttribute("data-target"); this._adapter = this.getAttribute("data-adapter") ?? ""; const gateway = this.getAttribute("data-gateway") ?? ""; - this._byAuthorTimer = new BatchTimer((ids: string[])=>{ - let url = `${gateway}/api/adapters/${this._adapter}/fetch?entity_type=byAuthor`; - for (let id of ids) { - url += `&entity_id=${id}`; - } - util.authorizedFetch("GET", url, null); - }); + this._byAuthorTimer = new BatchTimer(gateway, this._adapter, "byAuthor"); } attributeChangedCallback(attr: string, prev: string, next: string) { diff --git a/frontend/ts/batch-timer.ts b/frontend/ts/batch-timer.ts index e0e02c9..f11158b 100644 --- a/frontend/ts/batch-timer.ts +++ b/frontend/ts/batch-timer.ts @@ -1,12 +1,20 @@ +import util from './util' + export class BatchTimer { private _batch: string[]; private _timer: number; private _reqFn: (id: string[])=>void; - constructor(reqFn: (id: string[])=>void) { + constructor(gateway: string, adapter: string, etype: string) { this._batch = []; this._timer = new Date().getTime(); - this._reqFn = reqFn; + this._reqFn = (ids: string[])=>{ + let url = `${gateway}/api/adapters/${adapter}/fetch?entity_type=${etype}`; + for (let id of ids) { + url += `&entity_id=${id}`; + } + util.authorizedFetch("GET", url, null); + }; } public queue(id: string, timeout: number){ diff --git a/frontend/ts/boost-tile-element.ts b/frontend/ts/boost-tile-element.ts deleted file mode 100644 index 29680ae..0000000 --- a/frontend/ts/boost-tile-element.ts +++ /dev/null @@ -1,15 +0,0 @@ -export class BoostTileElement extends HTMLElement { - - static observedAttributes = [ "data-boostid", "data-msgid", "data-author", "data-booster" ]; - - constructor() { - super(); - this.innerHTML = "
    "; - } - - connectedCallback() { - } - - attributeChangedCallback(attr: string, prev: string, next: string) { - } -} \ No newline at end of file diff --git a/frontend/ts/index.ts b/frontend/ts/index.ts index a8a894d..8cfae05 100644 --- a/frontend/ts/index.ts +++ b/frontend/ts/index.ts @@ -2,13 +2,11 @@ import util from "./util" import {AdapterState, AdapterData} from "./adapter"; import {Message, Attachment, Author} from "./message" import {Settings} from "./settings" -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" import { ProfileElement } from "./profile-element" import { AuthorMessagesElement } from "./author-messages-element" +import { TimelineElement } from "./timeline-element" import {DatagramSocket} from "./websocket" function main() { @@ -19,21 +17,28 @@ function main() { customElements.define("underbbs-settings", SettingsElement); customElements.define("underbbs-profile", ProfileElement); customElements.define("underbbs-author-messages", AuthorMessagesElement); + customElements.define("underbbs-timeline", TimelineElement); util._("closeErr", util.closeErr); + let settingsParent = util.$("settings_parent"); if (settingsParent) { - settingsParent.innerHTML = `` + settingsParent.innerHTML = `` } let profileParent = util.$("profile_parent"); if (profileParent) { - profileParent.innerHTML = "" + profileParent.innerHTML = "" } let honksParent = util.$("honks_parent"); if (honksParent) { - honksParent.innerHTML = ""; + honksParent.innerHTML = ""; + } + + let timelineParent = util.$("timeline_parent"); + if (timelineParent) { + timelineParent.innerHTML = ""; } } diff --git a/frontend/ts/message-element.ts b/frontend/ts/message-element.ts index 4650173..358659c 100644 --- a/frontend/ts/message-element.ts +++ b/frontend/ts/message-element.ts @@ -22,13 +22,7 @@ export class MessageElement extends HTMLElement { this._id = this.getAttribute("data-target"); this._adapter = this.getAttribute("data-adapter"); const gateway = this.getAttribute("data-gateway") ?? ""; - this._messageTimer = new BatchTimer((ids: string[])=>{ - let url = `${gateway}/api/adapters/${this._adapter}/fetch?entity_type=message`; - for (let id of ids) { - url += `&entity_id=${id}`; - } - util.authorizedFetch("GET", url, null); - }); + this._messageTimer = new BatchTimer(gateway, this._adapter ?? "", "message"); } attributeChangedCallback(attr: string, prev: string, next: string) { @@ -38,6 +32,8 @@ export class MessageElement extends HTMLElement { return } this._id = next; + this._message = null; + this.innerHTML = `
    `; if (this._messageTimer) { this._messageTimer.queue(next, 100); } diff --git a/frontend/ts/message.ts b/frontend/ts/message.ts index a25c111..1689687 100644 --- a/frontend/ts/message.ts +++ b/frontend/ts/message.ts @@ -1,6 +1,7 @@ export class Message { public id: string = ""; public uri: string = ""; + public target: string | null = null; public protocol: string = ""; public adapter: string = ""; public author: string = "" @@ -20,6 +21,7 @@ export class Message { export class Author { public id: string = ""; public uri: string = ""; + public target: string | null = null; public protocol: string = ""; public adapter: string = ""; public name: string = ""; diff --git a/frontend/ts/profile-element.ts b/frontend/ts/profile-element.ts index 63581ed..4ffd2df 100644 --- a/frontend/ts/profile-element.ts +++ b/frontend/ts/profile-element.ts @@ -24,13 +24,7 @@ export class ProfileElement extends HTMLElement { this._id = this.getAttribute("data-target"); this._adapter = this.getAttribute("data-adapter") ?? ""; const gateway = this.getAttribute("data-gateway") ?? ""; - this._authorTimer = new BatchTimer((ids: string[])=>{ - let url = `${gateway}/api/adapters/${this._adapter}/fetch?entity_type=author`; - for (let id of ids) { - url += `&entity_id=${id}`; - } - util.authorizedFetch("GET", url, null) - }); + this._authorTimer = new BatchTimer(gateway, this._adapter, "author"); } attributeChangedCallback(attr: string, prev: string, next: string) { diff --git a/frontend/ts/subscriber.ts b/frontend/ts/subscriber.ts new file mode 100644 index 0000000..5f79537 --- /dev/null +++ b/frontend/ts/subscriber.ts @@ -0,0 +1,16 @@ +import util from './util'; + +export class Subscriber { + private _reqFn: (filter: string)=>void; + + constructor(gateway: string, adapter: string, target: string | null) { + this._reqFn = (filter: string) => { + let url = `${gateway}/api/adapters/${adapter}/subscribe` + util.authorizedFetch("POST", url, JSON.stringify({filter, target})); + } + } + + subscribe(filter: string) { + this._reqFn(filter); + } +} \ No newline at end of file diff --git a/frontend/ts/tabbar-element.ts b/frontend/ts/tabbar-element.ts deleted file mode 100644 index 0d50ccc..0000000 --- a/frontend/ts/tabbar-element.ts +++ /dev/null @@ -1,84 +0,0 @@ -import util from "./util" -import {Settings} from "./settings" - -export class TabBarElement extends HTMLElement { - static observedAttributes = [ "data-adapters", "data-currentadapter" ] - - private _adapters: string[] = []; - private _currentAdapter: string | null = null; - - constructor() { - super(); - } - - connectedCallback() { - if (this._currentAdapter) { - this.showAdapterFunc(this, this._currentAdapter)(); - } else { - this.showSettings(this)(); - } - } - - attributeChangedCallback(attr: string, prev: string, next: string) { - switch (attr) { - case "data-adapters": - if (next) { - this._adapters = next.split(",") - } else { - this._adapters = []; - } - break; - case "data-currentadapter": - this._currentAdapter = next || null; - break; - } - - let html = ""; - - this.innerHTML = html; - // now we can query the child elements and add click handlers to them - var s = util.$("tabbar_settings"); - if (s) { - s.addEventListener("click", this.showSettings(this), false); - if (!this._currentAdapter) { - s.classList.add("tabbar_current"); - } - } - for (let i of this._adapters) { - var a = util.$(`tabbar_${i}`); - if (a) { - a.addEventListener("click", this.showAdapterFunc(this, i), false); - if (this._currentAdapter == i) { - a.classList.add("tabbar_current"); - } - } - } - - } - - showSettings(self: TabBarElement): ()=>void { - return () => { - let x = util.$("mainarea_injectparent"); - if (x) { - x.innerHTML = `a.nickname).join(",") ?? []}>`; - self.setAttribute("data-currentadapter", ""); - } - } - } - - showAdapterFunc(self: TabBarElement, adapter: string): ()=>void { - return ()=>{ - let x = util.$("mainarea_injectparent"); - if (x) { - x.innerHTML = ``; - self.setAttribute("data-currentadapter", adapter); - } - } - } -} diff --git a/frontend/ts/thread-summary-element.ts b/frontend/ts/thread-summary-element.ts deleted file mode 100644 index e5edff2..0000000 --- a/frontend/ts/thread-summary-element.ts +++ /dev/null @@ -1,113 +0,0 @@ -import util from "./util" -import { Message, Author } from "./message" -import { AdapterState } from "./adapter" -import { BatchTimer } from "./batch-timer" - -export class ThreadSummaryElement extends HTMLElement { - static observedAttributes = [ "data-msg", "data-len", "data-author", "data-created", "data-latest", "data-new" ]; - - private _len: number = 0;; - private _msg: Message | null = null;; - private _author: Author | null = null; - private _adapter: string = ""; - private _created: number = 0; - private _latest: number = 0; - private _new: boolean = false; - - private _authorTimer: BatchTimer; - - constructor() { - super(); - this.innerHTML = "
    " - - // adapter shouldn't change, just set it here - this._adapter = this.getAttribute("data-adapter") ?? ""; - this.addEventListener("click", this.viewThread(this), false); - this._authorTimer = new BatchTimer((ids: string[])=>{ - let url = `/api/adapters/${this._adapter}/fetch?entity_type=author`; - for (let id of ids) { - url += `&entity_id=${id}`; - } - util.authorizedFetch("GET", url, null) - }); - } - - connectedCallback() { - - } - - attributeChangedCallback(attr: string, prev: string, next: string) { - - const datastore = AdapterState._instance.data.get(this._adapter); - if (!datastore) { - return; - } - let metadataChanged = false; - - switch (attr) { - case "data-msg": - if (next && next != prev) { - this._msg = datastore.messages.get(next) || null; - if (this._msg) { - // TODO: use attachment alttext or fallback text if no msg content - // TODO: handle boosts, quotes properly - - const threadText = this.querySelector(".thread_text"); - if (threadText) { - threadText.innerHTML = this._msg.content; - } - this.setAttribute("data-author", this._msg.author); - } - } - break; - case "data-author": - if (next) { - let authorData= datastore.profileCache.get(next); - if (!authorData) { - this._authorTimer.queue(next, 2000); - this._author = {id: next}; - const threadAuthor = this.querySelector(".thread_author"); - if (threadAuthor) { - threadAuthor.innerHTML = `${this._author.id}`; - } - } else { - this._author = authorData; - const threadAuthor = this.querySelector(".thread_author"); - if (threadAuthor) { - threadAuthor.innerHTML = `${this._author.id} ${this._author.id}` - } - } - } - break; - case "data-len": - this._len = parseInt(next); - metadataChanged = true; - break; - case "data-latest": - this._latest = parseInt(next); - metadataChanged = true; - break; - case "data-new": - this._new = next ? true : false; - metadataChanged = true; - break; - } - - if (metadataChanged) { - const threadMeta = this.querySelector(".thread_metadata"); - if (threadMeta) { - threadMeta.innerHTML = `${this._new ? "!" : ""}[${this._len}] created: ${new Date(this._created)}, updated: ${new Date(this._latest)}`; - } - } - } - - viewThread(self: ThreadSummaryElement) { - return () => { - const a = util.$(`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/frontend/ts/timeline-element.ts b/frontend/ts/timeline-element.ts new file mode 100644 index 0000000..58d402e --- /dev/null +++ b/frontend/ts/timeline-element.ts @@ -0,0 +1,91 @@ +import { Author, Message } from "./message" +import util from "./util" +import { Subscriber } from "./subscriber" +import { AdapterState } from "./adapter" + +export class TimelineElement extends HTMLElement { + static observedAttributes = [ "data-latest", "data-adapter", "data-target" ]; + + private _timeline: string | null = null; + private _adapter: string = ""; + + private _messages: Message[] = []; + private _subscriber: Subscriber | null = null; + + constructor() { + super(); + this.innerHTML = ``; + } + + connectedCallback() { + this._timeline = this.getAttribute("data-target"); + this._adapter = this.getAttribute("data-adapter") ?? ""; + const gateway = this.getAttribute("data-gateway") ?? ""; + this._subscriber = new Subscriber(gateway, this._adapter ?? "", this.getAttribute("id") ?? null); + } + + attributeChangedCallback(attr: string, prev: string, next: string) { + switch (attr) { + case "data-target": + if (!next) { + return + } + this._timeline = next; + this.innerHTML = ``; + if (this._subscriber) { + this._subscriber.subscribe(next); + } + break; + case "data-latest": + let datastore = AdapterState._instance.data.get(this._adapter); + if (!datastore) { + console.log("no data yet, wait for some to come in maybe..."); + return; + } + let msg = datastore.messages.get(next); + if (msg) { + console.log(msg); + const existingIdx = this._messages.findIndex(m=>(m.renoteId ?? m.id) == (msg.renoteId ?? msg.id) && ((m.edited ?? m.created) < (msg.edited ?? msg.created))); + + // first we update the backing data store + if (existingIdx >= 0) { + this._messages[existingIdx] = msg; + } else if (!this._messages.some(m=>(m.renoteId ?? m.id) == (msg.renoteId ?? msg.id))) { + this._messages.push(msg); + } + + + const ul = this.children[0]; + if (ul) { + // first pass through the dom, try to update a message if it's there + for (let i = 0; i < ul.childElementCount; i++){ + const id = ul.children[i]?.children[0]?.getAttribute("data-target"); + const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id); + if (ogMsg && existingIdx >= 0) { + ul.children[i]?.children[0]?.setAttribute("data-latest", id ?? ""); + return; + } + } + + // if we made it this far, let's create a node + const e = document.createElement("li"); + e.innerHTML = `` + // second pass, try to place it in reverse-chronological order + for (let i = 0; i < ul.childElementCount; i++){ + const id = ul.children[i]?.children[0]?.getAttribute("data-target"); + const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id); + if (ogMsg && (ogMsg.renoteTime ?? ogMsg.created) < (msg.renoteTime ?? msg.created)) { + ul.insertBefore(e, ul.children[i]) + e.children[0].setAttribute("data-latest", next); + return; + } + } + + // final pass, we must be the earliest child (or maybe the first one to be rendered) + ul.append(e); + e.children[0].setAttribute("data-latest", next); + } + } + } + } +} \ No newline at end of file diff --git a/frontend/ts/websocket.ts b/frontend/ts/websocket.ts index 5c51979..a03274e 100644 --- a/frontend/ts/websocket.ts +++ b/frontend/ts/websocket.ts @@ -33,6 +33,11 @@ export class DatagramSocket { const target = f.getAttribute("data-target"); f.setAttribute("data-target", target ?? ""); }); + const timelines = document.querySelectorAll("underbbs-timeline"); + timelines.forEach(t=>{ + const target = t.getAttribute("data-target"); + t.setAttribute("data-target", target ?? ""); + }); } }) .catch(e => { @@ -57,7 +62,6 @@ export class DatagramSocket { break; } } - console.log(store); // go through each type of component and give it the latest if it's relevant to them let profileTargets = document.querySelectorAll(`underbbs-profile[data-adapter="${data.adapter}"][data-target="${data.id}"]`); profileTargets.forEach(t=>{ @@ -74,6 +78,14 @@ export class DatagramSocket { t.setAttribute("data-latest", data.renoteId); }); } + if (data.target) { + console.log("data has target: " + data.target); + let e = document.querySelector(`underbbs-timeline#${data.target}[data-adapter="${data.adapter}"]`); + if (e) { + console.log("setting latest...") + e.setAttribute("data-latest", data.id); + } + } } } diff --git a/server/api.go b/server/api.go index 7660dbe..6fa092f 100644 --- a/server/api.go +++ b/server/api.go @@ -94,22 +94,7 @@ func apiConfigureAdapters(next http.Handler, subscribers map[*Subscriber][]adapt return } - log.Print("adapter initialized - subscribing with default filter") - - errs := a.Subscribe(a.DefaultSubscriptionFilter()) - if errs != nil { - errMsg := "" - for _, e := range errs { - log.Print("processing an error") - errMsg += fmt.Sprintf("- %s\n", e.Error()) - } - util.AddContextValue(req, "data", errMsg) - w.WriteHeader(500) - next.ServeHTTP(w, req) - return - } - - log.Print("adapter ready for use; adding to array") + log.Print("adapter initialized; adding to array") adapters = append(adapters, a) log.Print("adapter added to array") @@ -136,9 +121,42 @@ func apiGetAdapters(next http.Handler) http.Handler { }) } -func apiAdapterSubscribe(next http.Handler) http.Handler { +type subscribeParams struct { + Filter string `json:"filter"` + Target *string `json:"target,omitempty"` +} + +func apiAdapterSubscribe(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(201) + // get subscriber key + skey, err := getSubscriberKey(req) + if err != nil { + w.WriteHeader(500) + return + } + subscriber := getSubscriberByKey(skey, subscribers) + if subscriber == nil { + w.WriteHeader(404) + return + } + + adapters := subscribers[subscriber] + + urlParams := req.Context().Value("params").(map[string]string) + adapter := urlParams["adapter_id"] + sp := subscribeParams{} + err = json.NewDecoder(req.Body).Decode(&sp) + for _, a := range adapters { + if a.Name() == adapter { + fmt.Printf("adapter.subscribe call: %s {%s, %s}\n", adapter, sp.Filter, *sp.Target) + a.Subscribe(sp.Filter, sp.Target) + + w.WriteHeader(201) + + next.ServeHTTP(w, req) + } + } + w.WriteHeader(404) next.ServeHTTP(w, req) }) } @@ -205,8 +223,8 @@ func (self *BBSServer) apiMux() http.Handler { )) // adapters/:name/subscribe - rtr.Post(`/adapters/(?P\S+)/subscribe`, ProtectWithSubscriberKey( - apiAdapterSubscribe(renderer.JSON("data")), + rtr.Post(`/adapters/(?P\S+)/subscribe`, ProtectWithSubscriberKey( + apiAdapterSubscribe(renderer.JSON("data"), self.subscribers), self.subscribers, )) diff --git a/webpack.config.js b/webpack.config.js index 366c1bb..4388bc8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,7 +5,6 @@ module.exports = { context: path.resolve(__dirname, 'frontend', '.js'), entry: { main: './index.js', - serviceWorker: './serviceWorker.js', }, output: { filename: '[name].js',