add CreateMessageElement and enable interactivity on messages (replies)
This commit is contained in:
parent
264be94427
commit
2ff69b9d38
8 changed files with 186 additions and 12 deletions
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
101
frontend/ts/create-message-element.ts
Normal file
101
frontend/ts/create-message-element.ts
Normal 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
16
frontend/ts/doer.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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!")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
))
|
))
|
||||||
|
|
Loading…
Reference in a new issue