add CreateMessageElement and enable interactivity on messages (replies)

This commit is contained in:
Iris Lightshard 2024-12-09 21:18:08 -07:00
parent 264be94427
commit 2ff69b9d38
Signed by: Iris Lightshard
GPG key ID: 688407174966CAF3
8 changed files with 186 additions and 12 deletions

View file

@ -248,6 +248,8 @@ func (self *HonkAdapter) donk(data map[string]string) error {
fieldName = "donkdesc" fieldName = "donkdesc"
case "content": case "content":
fieldName = "noise" fieldName = "noise"
case "replyto":
fieldName = "rid"
} }
if fw, err = w.CreateFormField(fieldName); err != nil { if fw, err = w.CreateFormField(fieldName); err != nil {
return err return err
@ -292,6 +294,12 @@ func (self *HonkAdapter) Do(action string, data map[string]string) error {
if exists { if exists {
return self.donk(data) return self.donk(data)
} }
replyto, exists := data["replyto"]
if exists {
honkForm["rid"] = []string{replyto}
}
res, err := http.PostForm(self.server+"/api", honkForm) res, err := http.PostForm(self.server+"/api", honkForm)
if err != nil { if err != nil {
return err return err

View file

@ -9,6 +9,8 @@ export class AuthorMessagesElement extends HTMLElement {
private _id: string | null = null; private _id: string | null = null;
private _adapter: string = ""; private _adapter: string = "";
private _interactable: boolean = false;
private _messages: Message[] = []; private _messages: Message[] = [];
private _byAuthorTimer: BatchTimer | null = null; private _byAuthorTimer: BatchTimer | null = null;
@ -22,6 +24,7 @@ export class AuthorMessagesElement extends HTMLElement {
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(gateway, this._adapter, "byAuthor"); this._byAuthorTimer = new BatchTimer(gateway, this._adapter, "byAuthor");
this._interactable = this.getAttribute("data-interactable") != null;
} }
attributeChangedCallback(attr: string, prev: string, next: string) { attributeChangedCallback(attr: string, prev: string, next: string) {
@ -43,7 +46,7 @@ export class AuthorMessagesElement extends HTMLElement {
} }
let msg = datastore.messages.get(next); let msg = datastore.messages.get(next);
if (msg) { if (msg) {
const existingIdx = this._messages.findIndex(m=>(m.renoteId ?? m.id) == (msg.renoteId ?? msg.id) && ((m.edited ?? m.created) < (msg.edited ?? msg.created))); 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 // first we update the backing data store
if (existingIdx >= 0) { if (existingIdx >= 0) {
@ -67,12 +70,12 @@ export class AuthorMessagesElement extends HTMLElement {
// if we made it this far, let's create a node // if we made it this far, let's create a node
const e = document.createElement("li"); const e = document.createElement("li");
e.innerHTML = `<underbbs-message data-adapter="${this._adapter}" data-target="${next}"></underbbs-message>` e.innerHTML = `<underbbs-message data-adapter="${this._adapter}" data-target="${next}" ${this._interactable ? "data-interactable" : ""}></underbbs-message>`
// second pass, try to place it in reverse-chronological order // second pass, try to place it in reverse-chronological order
for (let i = 0; i < ul.childElementCount; i++){ for (let i = 0; i < ul.childElementCount; i++){
const id = ul.children[i]?.children[0]?.getAttribute("data-target"); const id = ul.children[i]?.children[0]?.getAttribute("data-target");
const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id); const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id);
if (ogMsg && (ogMsg.renoteTime ?? ogMsg.created) < (msg.renoteTime ?? msg.created)) { if (ogMsg && (ogMsg.renoteTime ?? ogMsg.created) <= (msg.renoteTime ?? msg.created)) {
ul.insertBefore(e, ul.children[i]) ul.insertBefore(e, ul.children[i])
e.children[0].setAttribute("data-latest", next); e.children[0].setAttribute("data-latest", next);
return; return;

View file

@ -0,0 +1,101 @@
import { Doer } from './doer'
export class CreateMessageElement extends HTMLElement {
private _gateway: string = "";
private _adapter: string = ""
private _doer: Doer | null = null;
static observedAttributes = [ "data-replyto" ];
constructor() {
super();
this.innerHTML = `<form onsubmit="return false"><input readonly class="createmessage_replyto"></input><textarea class="createmessage_content"></textarea><details><summary>attachment?</summary><input type="file" class="createmessage_file"></input><input class="createmessage_desc"></input></details><input type="submit" value="post"></input></form>`
}
connectedCallback() {
this._gateway = this.getAttribute("data-gateway") ?? "";
this._adapter = this.getAttribute("data-adapter") ?? "";
this._doer = new Doer(this._gateway, this._adapter);
const postBtn = this.querySelector(`input[type="submit"]`);
if (postBtn) {
postBtn.addEventListener("click", this.doPost.bind(this));
}
}
attributeChangedCallback(attr: string, prev: string, next: string) {
switch (attr) {
// when we implement replies, we'll come back here
case "data-replyto":
const e = this.querySelector(".createmessage_replyto") as HTMLInputElement;
if (e) {
e.value = next;
}
default:
break;
}
}
resetInput(r: Response) {
if (r.ok) {
const f = this.querySelector("form") as HTMLFormElement;
if (f) {
f.reset();
}
}
}
doPost() {
const msgContent = this.querySelector(".createmessage_content") as HTMLTextAreaElement;
const msgAttachment = this.querySelector(".createmessage_file") as HTMLInputElement;
const msgAttachmentDesc = this.querySelector(".createmessage_desc") as HTMLInputElement;
const msgReplyTo = this.querySelector(".createmessage_replyto") as HTMLInputElement;
const doReq: any = {};
doReq.action = "post";
doReq.content = msgContent.value ?? "";
if (msgReplyTo && msgReplyTo.value) {
doReq.replyto = msgReplyTo.value;
}
if (msgAttachment.files && msgAttachment.files[0]) {
const r = new FileReader();
const self = this;
doReq.desc = msgAttachmentDesc.value ?? "";
r.onload = ()=>{
if (self && self._doer) {
doReq.file = r.result;
self._doer.do(doReq)
.then(r=>{
if (r.ok) {
msgContent.value = '';
msgAttachment.value = '';
msgAttachmentDesc.value = '';
msgReplyTo.value = '';
}
})
.catch(err=>{
// try to display an error locally on the component
});
}
}
r.readAsDataURL(msgAttachment.files[0]);
return;
}
if (this._doer) {
this._doer.do(doReq)
.then(r=>{
if (r.ok) {
msgContent.value = '';
msgAttachment.value = '';
msgAttachmentDesc.value = '';
msgReplyTo.value = '';
}
})
.catch(err=>{
// try to display an error locally on the component
});;
}
}
}

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

@ -0,0 +1,16 @@
import util from './util';
export class Doer {
private _reqFn: (doParams: any) => Promise<Response>;
constructor(gateway: string, adapter: string) {
this._reqFn = (doParams: any) => {
let url = `${gateway}/api/adapters/${adapter}/do`;
return util.authorizedFetch("POST", url, JSON.stringify(doParams));
}
}
do(doParams: any): Promise<Response> {
return this._reqFn(doParams);
}
}

View file

@ -10,12 +10,14 @@ export class MessageElement extends HTMLElement {
private _adapter: string | null = null; private _adapter: string | null = null;
private _message: Message | null = null; private _message: Message | null = null;
private _interactable: boolean = false;
private _replyWith: string | null = null;
private _messageTimer: BatchTimer | null = null; private _messageTimer: BatchTimer | null = null;
constructor() { constructor() {
super(); super();
this.innerHTML = `<div class="message_metadata"></div><div class="message_content"></div><div class="message_attachments"></div>` this.innerHTML = `<div class="message_metadata"></div><div class="message_content"></div><div class="message_attachments"></div><div class="message_interactions"></div>`
} }
connectedCallback() { connectedCallback() {
@ -23,6 +25,8 @@ export class MessageElement extends HTMLElement {
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(gateway, this._adapter ?? "", "message"); this._messageTimer = new BatchTimer(gateway, this._adapter ?? "", "message");
this._interactable = this.getAttribute("data-interactable") != null;
this._replyWith = this.getAttribute("data-replywith");
} }
attributeChangedCallback(attr: string, prev: string, next: string) { attributeChangedCallback(attr: string, prev: string, next: string) {
@ -33,7 +37,7 @@ export class MessageElement extends HTMLElement {
} }
this._id = next; this._id = next;
this._message = null; this._message = null;
this.innerHTML = `<div class="message_metadata"></div><div class="message_content"></div><div class="message_attachments"></div>`; this.innerHTML = `<div class="message_metadata"></div><div class="message_content"></div><div class="message_attachments"></div><div class="message_interactions"></div>`;
if (this._messageTimer) { if (this._messageTimer) {
this._messageTimer.queue(next, 100); this._messageTimer.queue(next, 100);
} }
@ -51,6 +55,7 @@ export class MessageElement extends HTMLElement {
const metadata = this.querySelector(".message_metadata"); const metadata = this.querySelector(".message_metadata");
const content = this.querySelector(".message_content"); const content = this.querySelector(".message_content");
const attachments = this.querySelector(".message_attachments"); const attachments = this.querySelector(".message_attachments");
const interactions = this.querySelector(".message_interactions");
if (metadata) { if (metadata) {
if (this._message.renoteId) { if (this._message.renoteId) {
metadata.innerHTML = `<span class="message_renoter">${this._message.renoter}</span><span class="message_renotetime">${new Date(this._message.renoteTime ?? 0)}</span>` metadata.innerHTML = `<span class="message_renoter">${this._message.renoter}</span><span class="message_renotetime">${new Date(this._message.renoteTime ?? 0)}</span>`
@ -62,7 +67,7 @@ export class MessageElement extends HTMLElement {
if (content) { if (content) {
content.innerHTML = this._message.content; content.innerHTML = this._message.content;
} }
if (attachments && this._message.attachments.length > 0) { if (attachments && this._message.attachments && this._message.attachments.length > 0) {
let html = "<ul>"; let html = "<ul>";
for (const a of this._message.attachments) { for (const a of this._message.attachments) {
// we can do it based on actual mimetype later but now let's just do an extension check // we can do it based on actual mimetype later but now let's just do an extension check
@ -104,8 +109,34 @@ export class MessageElement extends HTMLElement {
html += "</ul>"; html += "</ul>";
attachments.innerHTML = html; attachments.innerHTML = html;
} }
if (this._interactable && interactions) {
interactions.innerHTML = `<button class="message_reply">reply</button><button class="message_boost">boost</button><a target="_blank" href="${this._message.uri}">permalink</a>`
const replyBtn = this.querySelector(".message_reply");
const boostBtn = this.querySelector(".message_boost");
if (replyBtn) {
replyBtn.addEventListener("click", this.reply.bind(this));
}
if (boostBtn) {
boostBtn.addEventListener("click", this.boost.bind(this));
}
}
} }
break; break;
} }
} }
private reply() {
const e = document.querySelector(`#${this._replyWith}`);
if (e) {
e.setAttribute("data-replyto", this._id || "");
const txtArea = e.querySelector("textarea") as HTMLTextAreaElement;
if (txtArea) {
txtArea.focus();
}
}
}
private boost() {
// use a Doer to boost
}
} }

View file

@ -9,6 +9,9 @@ export class TimelineElement extends HTMLElement {
private _timeline: string | null = null; private _timeline: string | null = null;
private _adapter: string = ""; private _adapter: string = "";
private _interactable: boolean = false;
private _replyWith: string | null = null;
private _messages: Message[] = []; private _messages: Message[] = [];
private _subscriber: Subscriber | null = null; private _subscriber: Subscriber | null = null;
@ -22,6 +25,8 @@ export class TimelineElement extends HTMLElement {
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._subscriber = new Subscriber(gateway, this._adapter ?? "", this.getAttribute("id") ?? null); this._subscriber = new Subscriber(gateway, this._adapter ?? "", this.getAttribute("id") ?? null);
this._interactable = this.getAttribute("data-interactable") != null;
this._replyWith = this.getAttribute("data-replywith");
} }
attributeChangedCallback(attr: string, prev: string, next: string) { attributeChangedCallback(attr: string, prev: string, next: string) {
@ -46,7 +51,7 @@ export class TimelineElement extends HTMLElement {
let msg = datastore.messages.get(next); let msg = datastore.messages.get(next);
if (msg) { if (msg) {
console.log(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))); 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 // first we update the backing data store
if (existingIdx >= 0) { if (existingIdx >= 0) {
@ -70,12 +75,12 @@ export class TimelineElement extends HTMLElement {
// if we made it this far, let's create a node // if we made it this far, let's create a node
const e = document.createElement("li"); const e = document.createElement("li");
e.innerHTML = `<underbbs-message data-adapter="${this._adapter}" data-target="${next}"></underbbs-message>` e.innerHTML = `<underbbs-message data-adapter="${this._adapter}" data-target="${next}" ${this._interactable ? "data-interactable" : ""} ${this._replyWith ? "data-replywith='" + this._replyWith + "'" : ""}></underbbs-message>`
// second pass, try to place it in reverse-chronological order // second pass, try to place it in reverse-chronological order
for (let i = 0; i < ul.childElementCount; i++){ for (let i = 0; i < ul.childElementCount; i++){
const id = ul.children[i]?.children[0]?.getAttribute("data-target"); const id = ul.children[i]?.children[0]?.getAttribute("data-target");
const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id); const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id);
if (ogMsg && (ogMsg.renoteTime ?? ogMsg.created) < (msg.renoteTime ?? msg.created)) { if (ogMsg && (ogMsg.renoteTime ?? ogMsg.created) <= (msg.renoteTime ?? msg.created)) {
ul.insertBefore(e, ul.children[i]) ul.insertBefore(e, ul.children[i])
e.children[0].setAttribute("data-latest", next); e.children[0].setAttribute("data-latest", next);
return; return;

View file

@ -8,6 +8,7 @@ import {ProfileElement} from "./profile-element"
import {MessageElement} from "./message-element" import {MessageElement} from "./message-element"
import {TimelineElement} from "./timeline-element" import {TimelineElement} from "./timeline-element"
import {TimelineFilterElement} from "./timeline-filter-element" import {TimelineFilterElement} from "./timeline-filter-element"
import {CreateMessageElement} from "./create-message-element"
export class DatagramSocket { export class DatagramSocket {
public static skey: string | null = null; public static skey: string | null = null;
@ -132,6 +133,7 @@ function init() {
customElements.define("underbbs-author-messages", AuthorMessagesElement); customElements.define("underbbs-author-messages", AuthorMessagesElement);
customElements.define("underbbs-timeline", TimelineElement); customElements.define("underbbs-timeline", TimelineElement);
customElements.define("underbbs-timeline-filter", TimelineFilterElement); customElements.define("underbbs-timeline-filter", TimelineFilterElement);
customElements.define("underbbs-create-message", CreateMessageElement);
console.log("underbbs initialized!") console.log("underbbs initialized!")
} }

View file

@ -223,14 +223,22 @@ func apiAdapterDo(next http.Handler, subscribers map[*Subscriber][]adapter.Adapt
return return
} }
if f, exists := doReq["file"]; exists { if f, exists := doReq["file"]; exists {
rawFile, err := base64.StdEncoding.DecodeString(f) firstCommaIdx := strings.Index(f, ",")
rawFile, err := base64.StdEncoding.DecodeString(f[firstCommaIdx+1:])
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
return return
} }
doReq["file"] = string(rawFile) doReq["file"] = string(rawFile)
} }
a.Do(doReq["action"], doReq) action, ok := doReq["action"]
if ok {
delete(doReq, "action")
} else {
w.WriteHeader(422)
return
}
a.Do(action, doReq)
next.ServeHTTP(w, req) next.ServeHTTP(w, req)
return return
} }
@ -265,7 +273,7 @@ func (self *BBSServer) apiMux() http.Handler {
self.subscribers, self.subscribers,
)) ))
rtr.Post(`adapters/(?P<adapter-id>\S+)/do`, ProtectWithSubscriberKey( rtr.Post(`/adapters/(?P<adapter_id>\S+)/do`, ProtectWithSubscriberKey(
apiAdapterDo(renderer.JSON("data"), self.subscribers), apiAdapterDo(renderer.JSON("data"), self.subscribers),
self.subscribers, self.subscribers,
)) ))