remove old components, get subscribe/timeline working with honk adapter

This commit is contained in:
Iris Lightshard 2024-12-05 21:05:01 -07:00
parent b00f26b17d
commit d61c786ffd
Signed by: Iris Lightshard
GPG key ID: 688407174966CAF3
23 changed files with 235 additions and 570 deletions

View file

@ -7,7 +7,7 @@ import (
type Adapter interface { type Adapter interface {
Init(Settings, *chan SocketData) error Init(Settings, *chan SocketData) error
Name() string Name() string
Subscribe(string) []error Subscribe(string, *string) []error
Fetch(string, []string) error Fetch(string, []string) error
Do(string, map[string]string) error Do(string, map[string]string) error
DefaultSubscriptionFilter() string DefaultSubscriptionFilter() string

View file

@ -137,10 +137,11 @@ func getBodyJson(res *http.Response) []byte {
l := res.ContentLength l := res.ContentLength
// 4k is a reasonable max size if we get unknown length right? // 4k is a reasonable max size if we get unknown length right?
if l < 0 { if l < 0 {
l = 4096 l = 9999999
} }
jsonData := make([]byte, l) jsonData := make([]byte, l)
res.Body.Read(jsonData) res.Body.Read(jsonData)
return jsonData return jsonData
} }

View file

@ -22,8 +22,8 @@ type donk struct {
type honk struct { type honk struct {
ID int ID int
Honker string Honker string
Handles []string Handles string
Oonker *string Oonker string
XID string XID string
RID *string RID *string
Noise string Noise string
@ -33,6 +33,12 @@ type honk struct {
Date string Date string
} }
type honkResponse struct {
Honks []honk `json:"honks"`
ChatCount int `json:"chatcount"`
MeCount int `json:"mecount"`
}
type HonkAdapter struct { type HonkAdapter struct {
data *chan SocketData data *chan SocketData
nickname string nickname string
@ -98,19 +104,19 @@ func (self *HonkAdapter) Init(settings Settings, data *chan SocketData) error {
return nil return nil
} }
func (self *HonkAdapter) Subscribe(filter string) []error { func (self *HonkAdapter) Subscribe(filter string, target *string) []error {
if self.stop != nil { if self.stop != nil {
close(self.stop) close(self.stop)
self.maxId = 0 self.maxId = 0
} }
self.stop = make(chan bool) self.stop = make(chan bool)
go self.gethonks(filter) go self.gethonks(filter, target)
return nil return nil
} }
func (self *HonkAdapter) gethonks(filter string) { func (self *HonkAdapter) gethonks(filter string, target *string) {
for { for {
select { select {
@ -119,6 +125,7 @@ func (self *HonkAdapter) gethonks(filter string) {
return return
} }
default: default:
fmt.Println("bout to get honks")
honkForm := url.Values{ honkForm := url.Values{
"action": []string{"gethonks"}, "action": []string{"gethonks"},
"token": []string{self.token}, "token": []string{self.token},
@ -129,24 +136,32 @@ func (self *HonkAdapter) gethonks(filter string) {
} }
res, err := http.PostForm(self.server+"/api", honkForm) res, err := http.PostForm(self.server+"/api", honkForm)
if err != nil { if err != nil {
// return? fmt.Println("fucked up: " + err.Error())
self.stop <- true
return
} }
honksData := getBodyJson(res) fmt.Println("we got some honks")
honks := []honk{} hr := honkResponse{}
json.Unmarshal(honksData, &honks) err = json.NewDecoder(res.Body).Decode(&hr)
for _, h := range honks { if err != nil {
fmt.Println("malformed honks: " + err.Error())
self.stop <- true
return
}
for _, h := range hr.Honks {
if h.ID > self.maxId { if h.ID > self.maxId {
self.maxId = h.ID 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) 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) t, err := time.Parse(time.RFC3339, h.Date)
if err != nil { if err != nil {
t = time.Now() t = time.Now()
@ -154,8 +169,8 @@ func (self *HonkAdapter) toMsg(h honk) Message {
tt := t.UnixMilli() tt := t.UnixMilli()
a := h.Honker a := h.Honker
if h.Oonker != nil { if h.Oonker != "" {
a = *h.Oonker a = h.Oonker
} }
msg := Message{ msg := Message{
@ -175,9 +190,9 @@ func (self *HonkAdapter) toMsg(h honk) Message {
if h.Public { if h.Public {
msg.Visibility = "Public" msg.Visibility = "Public"
} }
if h.Oonker != nil { if h.Oonker != "" {
r := fmt.Sprintf("%s/bonk/%d", h.Honker, h.ID) r := fmt.Sprintf("%s/bonk/%d", h.Honker, h.ID)
msg.Renoter = h.Oonker msg.Renoter = &h.Oonker
msg.RenoteId = &r msg.RenoteId = &r
msg.RenoteTime = &tt msg.RenoteTime = &tt
} }
@ -188,6 +203,9 @@ func (self *HonkAdapter) toMsg(h honk) Message {
} }
msg.Attachments = append(msg.Attachments, a) msg.Attachments = append(msg.Attachments, a)
} }
if target != nil {
msg.Target = target
}
return msg return msg
} }

View file

@ -50,7 +50,7 @@ func (self *MastoAdapter) Init(settings Settings, data *chan SocketData) error {
return err return err
} }
func (self *MastoAdapter) Subscribe(filter string) []error { func (self *MastoAdapter) Subscribe(filter string, target *string) []error {
// TODO: decode separate timelines and hashtags // TODO: decode separate timelines and hashtags
// for now, the filter is just the timeline // for now, the filter is just the timeline

View file

@ -72,7 +72,7 @@ func (self *MisskeyAdapter) Init(settings Settings, data *chan SocketData) error
return nil return nil
} }
func (self *MisskeyAdapter) Subscribe(filter string) []error { func (self *MisskeyAdapter) Subscribe(filter string, target *string) []error {
// misskey streaming API is undocumented.... // misskey streaming API is undocumented....
// we could try to reverse engineer it by directly connecting to the websocket??? // we could try to reverse engineer it by directly connecting to the websocket???
// alternatively, we can poll timelines, mentions, etc with a cancellation channel, // alternatively, we can poll timelines, mentions, etc with a cancellation channel,

View file

@ -48,7 +48,7 @@ func (self *NostrAdapter) Init(settings Settings, data *chan SocketData) error {
return nil return nil
} }
func (self *NostrAdapter) Subscribe(filter string) []error { func (self *NostrAdapter) Subscribe(filter string, target *string) []error {
var filters nostr.Filters var filters nostr.Filters
err := json.Unmarshal([]byte(filter), &filters) err := json.Unmarshal([]byte(filter), &filters)
if err != nil { if err != nil {

View file

@ -14,9 +14,14 @@
</noscript> </noscript>
<div id="err_wrapper" style='display:none'><button id="err_close" onclick="closeErr()">x</button><div id="err_div"></div></div> <div id="err_wrapper" style='display:none'><button id="err_close" onclick="closeErr()">x</button><div id="err_div"></div></div>
<main> <main>
<div class='uicolumn'>
<details open><summary>settings</summary><div id="settings_parent"></div></details> <details open><summary>settings</summary><div id="settings_parent"></div></details>
<details open><summary>timeline</summary><div id="timeline_parent"></div></details>
</div>
<div class='uicolumn'>
<details open><summary>profile</summary><div id="profile_parent"></div></details> <details open><summary>profile</summary><div id="profile_parent"></div></details>
<details open><summary>honks</summary><div id="honks_parent"></div></details> <details open><summary>honks</summary><div id="honks_parent"></div></details>
</div>
</main> </main>
</body> </body>
<script src="./main.js" type="application/javascript"></script> <script src="./main.js" type="application/javascript"></script>

View file

@ -64,4 +64,7 @@ nav ul li a {
main { main {
padding: 2em; padding: 2em;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
} }

View file

@ -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 = "<ul id='boost_carousel'></ul><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 = 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 +=`<li><underbbs-thread-summary data-len="${t.messageCount}" data-adapter="${t.root.data.adapter}" data-msg="${t.root.data.id}" data-created="${t.created}" data-latest="${t.latest}"></underbbs-thread-summary></li>`;
}
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 = `<underbbs-thread-summary data-len="1" data-adapter="${thread.root.data.adapter}" data-msg="${thread.root.data.id}" data-latest="${thread.latest}" data-created="${thread.created}"></underbbs-thread-summary>`;
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;
}
}

View file

@ -21,13 +21,7 @@ export class AuthorMessagesElement extends HTMLElement {
this._id = this.getAttribute("data-target"); this._id = this.getAttribute("data-target");
this._adapter = this.getAttribute("data-adapter") ?? ""; this._adapter = this.getAttribute("data-adapter") ?? "";
const gateway = this.getAttribute("data-gateway") ?? ""; const gateway = this.getAttribute("data-gateway") ?? "";
this._byAuthorTimer = new BatchTimer((ids: string[])=>{ this._byAuthorTimer = new BatchTimer(gateway, this._adapter, "byAuthor");
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);
});
} }
attributeChangedCallback(attr: string, prev: string, next: string) { attributeChangedCallback(attr: string, prev: string, next: string) {

View file

@ -1,12 +1,20 @@
import util from './util'
export class BatchTimer { export class BatchTimer {
private _batch: string[]; private _batch: string[];
private _timer: number; private _timer: number;
private _reqFn: (id: string[])=>void; private _reqFn: (id: string[])=>void;
constructor(reqFn: (id: string[])=>void) { constructor(gateway: string, adapter: string, etype: string) {
this._batch = []; this._batch = [];
this._timer = new Date().getTime(); 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){ public queue(id: string, timeout: number){

View file

@ -1,15 +0,0 @@
export class BoostTileElement extends HTMLElement {
static observedAttributes = [ "data-boostid", "data-msgid", "data-author", "data-booster" ];
constructor() {
super();
this.innerHTML = "<div class='boost_booster'></div><div class='boost_author'></div><div class='boost_content'></div>";
}
connectedCallback() {
}
attributeChangedCallback(attr: string, prev: string, next: string) {
}
}

View file

@ -2,13 +2,11 @@ import util from "./util"
import {AdapterState, AdapterData} from "./adapter"; import {AdapterState, AdapterData} from "./adapter";
import {Message, Attachment, Author} from "./message" import {Message, Attachment, Author} from "./message"
import {Settings} from "./settings" import {Settings} from "./settings"
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"
import { ProfileElement } from "./profile-element" import { ProfileElement } from "./profile-element"
import { AuthorMessagesElement } from "./author-messages-element" import { AuthorMessagesElement } from "./author-messages-element"
import { TimelineElement } from "./timeline-element"
import {DatagramSocket} from "./websocket" import {DatagramSocket} from "./websocket"
function main() { function main() {
@ -19,21 +17,28 @@ function main() {
customElements.define("underbbs-settings", SettingsElement); customElements.define("underbbs-settings", SettingsElement);
customElements.define("underbbs-profile", ProfileElement); customElements.define("underbbs-profile", ProfileElement);
customElements.define("underbbs-author-messages", AuthorMessagesElement); customElements.define("underbbs-author-messages", AuthorMessagesElement);
customElements.define("underbbs-timeline", TimelineElement);
util._("closeErr", util.closeErr); util._("closeErr", util.closeErr);
let settingsParent = util.$("settings_parent"); let settingsParent = util.$("settings_parent");
if (settingsParent) { if (settingsParent) {
settingsParent.innerHTML = `<underbbs-settings data-adapters='${Settings._instance.adapters.map(a=>a.nickname).join(",")}'></underbbs-settings>` settingsParent.innerHTML = `<underbbs-settings data-adapters='${Settings._instance.adapters.map(a=>a.nickname).join(",")}' data-gateway=""></underbbs-settings>`
} }
let profileParent = util.$("profile_parent"); let profileParent = util.$("profile_parent");
if (profileParent) { if (profileParent) {
profileParent.innerHTML = "<underbbs-profile data-adapter='honk' data-target='https://cafe.nilfm.cc/u/nilix'></underbbs-profile>" profileParent.innerHTML = "<underbbs-profile data-adapter='honk' data-target='https://cafe.nilfm.cc/u/nilix' data-gateway=''></underbbs-profile>"
} }
let honksParent = util.$("honks_parent"); let honksParent = util.$("honks_parent");
if (honksParent) { if (honksParent) {
honksParent.innerHTML = "<underbbs-author-messages data-adapter='honk' data-target='https://cafe.nilfm.cc/u/nilix'></underbbs-author-messages>"; honksParent.innerHTML = "<underbbs-author-messages data-adapter='honk' data-target='https://cafe.nilfm.cc/u/nilix' data-gateway=''></underbbs-author-messages>";
}
let timelineParent = util.$("timeline_parent");
if (timelineParent) {
timelineParent.innerHTML = "<underbbs-timeline id='honkstream' data-adapter='honk' data-target='home' data-gateway=''></underbbs-timeline>";
} }
} }

View file

@ -22,13 +22,7 @@ export class MessageElement extends HTMLElement {
this._id = this.getAttribute("data-target"); this._id = this.getAttribute("data-target");
this._adapter = this.getAttribute("data-adapter"); this._adapter = this.getAttribute("data-adapter");
const gateway = this.getAttribute("data-gateway") ?? ""; const gateway = this.getAttribute("data-gateway") ?? "";
this._messageTimer = new BatchTimer((ids: string[])=>{ this._messageTimer = new BatchTimer(gateway, this._adapter ?? "", "message");
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);
});
} }
attributeChangedCallback(attr: string, prev: string, next: string) { attributeChangedCallback(attr: string, prev: string, next: string) {
@ -38,6 +32,8 @@ export class MessageElement extends HTMLElement {
return return
} }
this._id = next; this._id = next;
this._message = null;
this.innerHTML = `<div class="message_metadata"></div><div class="message_content"></div><div class="message_attachments"></div>`;
if (this._messageTimer) { if (this._messageTimer) {
this._messageTimer.queue(next, 100); this._messageTimer.queue(next, 100);
} }

View file

@ -1,6 +1,7 @@
export class Message { export class Message {
public id: string = ""; public id: string = "";
public uri: string = ""; public uri: string = "";
public target: string | null = null;
public protocol: string = ""; public protocol: string = "";
public adapter: string = ""; public adapter: string = "";
public author: string = "" public author: string = ""
@ -20,6 +21,7 @@ export class Message {
export class Author { export class Author {
public id: string = ""; public id: string = "";
public uri: string = ""; public uri: string = "";
public target: string | null = null;
public protocol: string = ""; public protocol: string = "";
public adapter: string = ""; public adapter: string = "";
public name: string = ""; public name: string = "";

View file

@ -24,13 +24,7 @@ export class ProfileElement extends HTMLElement {
this._id = this.getAttribute("data-target"); this._id = this.getAttribute("data-target");
this._adapter = this.getAttribute("data-adapter") ?? ""; this._adapter = this.getAttribute("data-adapter") ?? "";
const gateway = this.getAttribute("data-gateway") ?? ""; const gateway = this.getAttribute("data-gateway") ?? "";
this._authorTimer = new BatchTimer((ids: string[])=>{ this._authorTimer = new BatchTimer(gateway, this._adapter, "author");
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)
});
} }
attributeChangedCallback(attr: string, prev: string, next: string) { attributeChangedCallback(attr: string, prev: string, next: string) {

16
frontend/ts/subscriber.ts Normal file
View file

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

View file

@ -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 = "<ul><li><a id='tabbar_settings' href='#settings'>settings</a></li>";
html = this._adapters.reduce((self: string, a: string)=>{
self += `<li><a id="tabbar_${a}" href="#${a}">${a}</a></li>`;
return self;
}, html);
html += "</ul>";
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 = `<underbbs-settings data-adapters=${Settings._instance.adapters.map(a=>a.nickname).join(",") ?? []}></underbbs-settings>`;
self.setAttribute("data-currentadapter", "");
}
}
}
showAdapterFunc(self: TabBarElement, adapter: string): ()=>void {
return ()=>{
let x = util.$("mainarea_injectparent");
if (x) {
x.innerHTML = `<underbbs-adapter id="adapter_${adapter}" data-name="${adapter}" data-view=""></underbbs-adapter>`;
self.setAttribute("data-currentadapter", adapter);
}
}
}
}

View file

@ -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 = "<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.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 = <Author>{id: next};
const threadAuthor = this.querySelector(".thread_author");
if (threadAuthor) {
threadAuthor.innerHTML = `<a id="thread_${this._adapter}_${(this._msg ? this._msg.id : 0)}_${this._author.id}" href="#author?id=${this._author.id}">${this._author.id}</a>`;
}
} else {
this._author = authorData;
const threadAuthor = this.querySelector(".thread_author");
if (threadAuthor) {
threadAuthor.innerHTML = `<img src="${this._author.profilePic}" alt="${this._author.id}"/> <a id="thread_${this._adapter}_${(this._msg ? this._msg.id : 0)}_${this._author.id}" href="#author?id=${this._author.id}">${this._author.id}</a>`
}
}
}
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 = `<span>${this._new ? "!" : ""}[${this._len}] created: ${new Date(this._created)}, updated: ${new Date(this._latest)}</span>`;
}
}
}
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);
}
}
}
}

View file

@ -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 = `<ul class="messages_list"></ul>`;
}
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 = `<ul class="messages_list"></ul>`;
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 = `<underbbs-message data-adapter="${this._adapter}" data-target="${next}"></underbbs-message>`
// 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);
}
}
}
}
}

View file

@ -33,6 +33,11 @@ export class DatagramSocket {
const target = f.getAttribute("data-target"); const target = f.getAttribute("data-target");
f.setAttribute("data-target", 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 => { .catch(e => {
@ -57,7 +62,6 @@ export class DatagramSocket {
break; break;
} }
} }
console.log(store);
// go through each type of component and give it the latest if it's relevant to them // 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}"]`); let profileTargets = document.querySelectorAll(`underbbs-profile[data-adapter="${data.adapter}"][data-target="${data.id}"]`);
profileTargets.forEach(t=>{ profileTargets.forEach(t=>{
@ -74,6 +78,14 @@ export class DatagramSocket {
t.setAttribute("data-latest", data.renoteId); 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);
}
}
} }
} }

View file

@ -94,22 +94,7 @@ func apiConfigureAdapters(next http.Handler, subscribers map[*Subscriber][]adapt
return return
} }
log.Print("adapter initialized - subscribing with default filter") log.Print("adapter initialized; adding to array")
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")
adapters = append(adapters, a) adapters = append(adapters, a)
log.Print("adapter added to array") 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) { 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) next.ServeHTTP(w, req)
}) })
} }
@ -205,8 +223,8 @@ func (self *BBSServer) apiMux() http.Handler {
)) ))
// adapters/:name/subscribe // adapters/:name/subscribe
rtr.Post(`/adapters/(?P<id>\S+)/subscribe`, ProtectWithSubscriberKey( rtr.Post(`/adapters/(?P<adapter_id>\S+)/subscribe`, ProtectWithSubscriberKey(
apiAdapterSubscribe(renderer.JSON("data")), apiAdapterSubscribe(renderer.JSON("data"), self.subscribers),
self.subscribers, self.subscribers,
)) ))

View file

@ -5,7 +5,6 @@ module.exports = {
context: path.resolve(__dirname, 'frontend', '.js'), context: path.resolve(__dirname, 'frontend', '.js'),
entry: { entry: {
main: './index.js', main: './index.js',
serviceWorker: './serviceWorker.js',
}, },
output: { output: {
filename: '[name].js', filename: '[name].js',