diff --git a/adapter/honk.go b/adapter/honk.go index d416346..d41476e 100644 --- a/adapter/honk.go +++ b/adapter/honk.go @@ -248,6 +248,8 @@ func (self *HonkAdapter) donk(data map[string]string) error { fieldName = "donkdesc" case "content": fieldName = "noise" + case "replyto": + fieldName = "rid" } if fw, err = w.CreateFormField(fieldName); err != nil { return err @@ -292,6 +294,12 @@ func (self *HonkAdapter) Do(action string, data map[string]string) error { if exists { return self.donk(data) } + + replyto, exists := data["replyto"] + if exists { + honkForm["rid"] = []string{replyto} + } + res, err := http.PostForm(self.server+"/api", honkForm) if err != nil { return err diff --git a/frontend/ts/author-messages-element.ts b/frontend/ts/author-messages-element.ts index 9f54389..d9c0081 100644 --- a/frontend/ts/author-messages-element.ts +++ b/frontend/ts/author-messages-element.ts @@ -9,6 +9,8 @@ export class AuthorMessagesElement extends HTMLElement { private _id: string | null = null; private _adapter: string = ""; + private _interactable: boolean = false; + private _messages: Message[] = []; private _byAuthorTimer: BatchTimer | null = null; @@ -22,6 +24,7 @@ export class AuthorMessagesElement extends HTMLElement { this._adapter = this.getAttribute("data-adapter") ?? ""; const gateway = this.getAttribute("data-gateway") ?? ""; this._byAuthorTimer = new BatchTimer(gateway, this._adapter, "byAuthor"); + this._interactable = this.getAttribute("data-interactable") != null; } attributeChangedCallback(attr: string, prev: string, next: string) { @@ -43,7 +46,7 @@ export class AuthorMessagesElement extends HTMLElement { } let msg = datastore.messages.get(next); 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 if (existingIdx >= 0) { @@ -67,12 +70,12 @@ export class AuthorMessagesElement extends HTMLElement { // if we made it this far, let's create a node const e = document.createElement("li"); - e.innerHTML = `` + 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)) { + if (ogMsg && (ogMsg.renoteTime ?? ogMsg.created) <= (msg.renoteTime ?? msg.created)) { ul.insertBefore(e, ul.children[i]) e.children[0].setAttribute("data-latest", next); return; diff --git a/frontend/ts/create-message-element.ts b/frontend/ts/create-message-element.ts new file mode 100644 index 0000000..0103401 --- /dev/null +++ b/frontend/ts/create-message-element.ts @@ -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 = `
attachment?
` + } + + 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 + });; + } + } +} \ No newline at end of file diff --git a/frontend/ts/doer.ts b/frontend/ts/doer.ts new file mode 100644 index 0000000..6b14334 --- /dev/null +++ b/frontend/ts/doer.ts @@ -0,0 +1,16 @@ +import util from './util'; + +export class Doer { + private _reqFn: (doParams: any) => Promise; + + 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 { + return this._reqFn(doParams); + } +} \ No newline at end of file diff --git a/frontend/ts/message-element.ts b/frontend/ts/message-element.ts index 358659c..cc5fe6d 100644 --- a/frontend/ts/message-element.ts +++ b/frontend/ts/message-element.ts @@ -10,12 +10,14 @@ export class MessageElement extends HTMLElement { private _adapter: string | null = null; private _message: Message | null = null; + private _interactable: boolean = false; + private _replyWith: string | null = null; private _messageTimer: BatchTimer | null = null; constructor() { super(); - this.innerHTML = `
` + this.innerHTML = `
` } connectedCallback() { @@ -23,6 +25,8 @@ export class MessageElement extends HTMLElement { this._adapter = this.getAttribute("data-adapter"); const gateway = this.getAttribute("data-gateway") ?? ""; 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) { @@ -33,7 +37,7 @@ export class MessageElement extends HTMLElement { } this._id = next; this._message = null; - this.innerHTML = `
`; + this.innerHTML = `
`; if (this._messageTimer) { this._messageTimer.queue(next, 100); } @@ -51,6 +55,7 @@ export class MessageElement extends HTMLElement { const metadata = this.querySelector(".message_metadata"); const content = this.querySelector(".message_content"); const attachments = this.querySelector(".message_attachments"); + const interactions = this.querySelector(".message_interactions"); if (metadata) { if (this._message.renoteId) { metadata.innerHTML = `${this._message.renoter}${new Date(this._message.renoteTime ?? 0)}` @@ -62,7 +67,7 @@ export class MessageElement extends HTMLElement { if (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 = ""; attachments.innerHTML = html; } + if (this._interactable && interactions) { + interactions.innerHTML = `permalink` + 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; } } + + 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 + } } \ No newline at end of file diff --git a/frontend/ts/timeline-element.ts b/frontend/ts/timeline-element.ts index 2a4f0d8..472b1fb 100644 --- a/frontend/ts/timeline-element.ts +++ b/frontend/ts/timeline-element.ts @@ -9,6 +9,9 @@ export class TimelineElement extends HTMLElement { private _timeline: string | null = null; private _adapter: string = ""; + private _interactable: boolean = false; + private _replyWith: string | null = null; + private _messages: Message[] = []; private _subscriber: Subscriber | null = null; @@ -22,6 +25,8 @@ export class TimelineElement extends HTMLElement { this._adapter = this.getAttribute("data-adapter") ?? ""; const gateway = this.getAttribute("data-gateway") ?? ""; 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) { @@ -46,7 +51,7 @@ export class TimelineElement extends HTMLElement { 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))); + 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) { @@ -70,12 +75,12 @@ export class TimelineElement extends HTMLElement { // if we made it this far, let's create a node const e = document.createElement("li"); - e.innerHTML = `` + 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)) { + if (ogMsg && (ogMsg.renoteTime ?? ogMsg.created) <= (msg.renoteTime ?? msg.created)) { ul.insertBefore(e, ul.children[i]) e.children[0].setAttribute("data-latest", next); return; diff --git a/frontend/ts/websocket.ts b/frontend/ts/websocket.ts index 31fd876..bff8e2f 100644 --- a/frontend/ts/websocket.ts +++ b/frontend/ts/websocket.ts @@ -8,6 +8,7 @@ import {ProfileElement} from "./profile-element" import {MessageElement} from "./message-element" import {TimelineElement} from "./timeline-element" import {TimelineFilterElement} from "./timeline-filter-element" +import {CreateMessageElement} from "./create-message-element" export class DatagramSocket { public static skey: string | null = null; @@ -132,6 +133,7 @@ function init() { customElements.define("underbbs-author-messages", AuthorMessagesElement); customElements.define("underbbs-timeline", TimelineElement); customElements.define("underbbs-timeline-filter", TimelineFilterElement); + customElements.define("underbbs-create-message", CreateMessageElement); console.log("underbbs initialized!") } diff --git a/server/api.go b/server/api.go index 55cd93f..c54a467 100644 --- a/server/api.go +++ b/server/api.go @@ -223,14 +223,22 @@ func apiAdapterDo(next http.Handler, subscribers map[*Subscriber][]adapter.Adapt return } 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 { w.WriteHeader(500) return } 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) return } @@ -265,7 +273,7 @@ func (self *BBSServer) apiMux() http.Handler { self.subscribers, )) - rtr.Post(`adapters/(?P\S+)/do`, ProtectWithSubscriberKey( + rtr.Post(`/adapters/(?P\S+)/do`, ProtectWithSubscriberKey( apiAdapterDo(renderer.JSON("data"), self.subscribers), self.subscribers, ))