diff --git a/src/webpage/channel.ts b/src/webpage/channel.ts index b5c96c4..3c46892 100644 --- a/src/webpage/channel.ts +++ b/src/webpage/channel.ts @@ -16,6 +16,7 @@ import { mute_config, readyjson, startTypingjson, + threadMember, } from "./jsontypes.js"; import {MarkDown} from "./markdown.js"; import {Member} from "./member.js"; @@ -120,6 +121,35 @@ class Channel extends SnowFlake { }, ); this.contextmenu.addSeperator(); + this.contextmenu.addButton( + () => I18n.threads.leave(), + function () { + fetch(this.info.api + "/channels/" + this.id + "/thread-members/@me", { + method: "DELETE", + headers: this.headers, + }); + }, + { + visible: function () { + return !!this.member; + }, + }, + ); + this.contextmenu.addButton( + () => I18n.threads.join(), + function () { + fetch(this.info.api + "/channels/" + this.id + "/thread-members/@me", { + method: "POST", + headers: this.headers, + }); + }, + { + visible: function () { + return !this.member; + }, + }, + ); + this.contextmenu.addSeperator(); //TODO notifcations icon this.contextmenu.addButton( () => I18n.guild.notifications(), @@ -501,6 +531,7 @@ class Channel extends SnowFlake { ); } last_pin_timestamp?: string; + member?: threadMember; constructor(json: channeljson | -1, owner: Guild, id: string = json === -1 ? "" : json.id) { super(id); this.idToNext = owner.localuser.idToNext; @@ -513,6 +544,7 @@ class Channel extends SnowFlake { if (json === -1) { return; } + this.member = json.member; this.rate_limit_per_user = json.rate_limit_per_user || 0; this.editing; this.type = json.type; @@ -527,7 +559,7 @@ class Channel extends SnowFlake { this.guild_id = json.guild_id; this.permission_overwrites = new Map(); this.permission_overwritesar = []; - for (const thing of json.permission_overwrites) { + for (const thing of json.permission_overwrites || []) { if (!this.permission_overwrites.has(thing.id)) { //either a bug in the server requires this, or the API is cursed this.permission_overwrites.set(thing.id, new Permissions(thing.allow, thing.deny)); @@ -560,6 +592,11 @@ class Channel extends SnowFlake { } this.setUpInfiniteScroller(); this.perminfo ??= {}; + const read = this.localuser.unknownRead.get(this.id); + if (read) { + this.readStateInfo(read); + this.localuser.unknownRead.delete(this.id); + } } get perminfo() { return this.guild.perminfo.channels[this.id]; @@ -714,7 +751,7 @@ class Channel extends SnowFlake { return a.position - b.position; }); } - resolveparent(_guild: Guild) { + resolveparent(_guild: Guild = this.owner) { const parentid = this.parent_id; if (!parentid) return false; this.parent = this.localuser.channelids.get(parentid); @@ -754,7 +791,7 @@ class Channel extends SnowFlake { return [build, position] as const; } static dragged: [Channel, HTMLDivElement] | [] = []; - html: WeakRef | undefined; + html: WeakRef | undefined; get visible() { return this.hasPermission("VIEW_CHANNEL"); } @@ -808,8 +845,12 @@ class Channel extends SnowFlake { } return icon; } + isThread() { + return this.type === 10 || this.type === 11 || this.type === 12; + } createguildHTML(admin = false): HTMLDivElement { - const div = document.createElement("div"); + const div = this.html?.deref() || document.createElement("div"); + div.innerHTML = ""; if (this.muted) { div.classList.add("muted"); @@ -820,6 +861,7 @@ class Channel extends SnowFlake { Math.min((this.mute_config?.end_time as number) - Date.now(), 2147483647), ); } + this.html = new WeakRef(div); if (!this.visible) { let quit = true; @@ -832,7 +874,7 @@ class Channel extends SnowFlake { return div; } } - div.draggable = admin; + div.draggable = admin && !this.isThread(); div.addEventListener("dragstart", (e) => { Channel.dragged = [this, div]; e.stopImmediatePropagation(); @@ -840,6 +882,14 @@ class Channel extends SnowFlake { div.addEventListener("dragend", () => { Channel.dragged = []; }); + const childrendiv = document.createElement("div"); + childrendiv.classList.add("channels"); + for (const channel of this.children.filter( + (_) => !_.isThread() || _.member || this.localuser.channelfocus === _, + )) { + childrendiv.appendChild(channel.createguildHTML(admin)); + } + if (this.type === 4) { this.sortchildren(); const caps = document.createElement("div"); @@ -855,7 +905,7 @@ class Channel extends SnowFlake { this.nameSpan = new WeakRef(myhtml); decdiv.appendChild(myhtml); caps.appendChild(decdiv); - const childrendiv = document.createElement("div"); + if (admin) { const addchannel = document.createElement("span"); addchannel.classList.add("addchannel", "svgicon", "svg-plus"); @@ -872,16 +922,12 @@ class Channel extends SnowFlake { Channel.contextmenu.bindContextmenu(decdiv, this, undefined); - for (const channel of this.children) { - childrendiv.appendChild(channel.createguildHTML(admin)); - } - childrendiv.classList.add("channels"); setTimeout((_: any) => { if (!this.perminfo.collapsed) { childrendiv.style.height = childrendiv.scrollHeight + "px"; } }, 100); - div.appendChild(childrendiv); + if (this.perminfo.collapsed) { decoration.classList.add("hiddencat"); childrendiv.style.height = "0px"; @@ -909,10 +955,11 @@ class Channel extends SnowFlake { handleColapse(); }; } else { + childrendiv.classList.add("threads"); div.classList.add("channel"); this.unreads(); Channel.contextmenu.bindContextmenu(div, this, undefined); - if (admin) { + if (admin && !this.isThread()) { this.coatDropDiv(div); } const button = document.createElement("button"); @@ -938,6 +985,7 @@ class Channel extends SnowFlake { this.updateVoiceUsers(); } } + div.appendChild(childrendiv); return div; } async moveForDrag(x: number) { @@ -1822,8 +1870,15 @@ class Channel extends SnowFlake { this.myhtml.classList.add("viewChannel"); } const id = ++Channel.genid; + if (this.localuser.channelfocus && this.localuser.channelfocus !== this) { this.localuser.channelfocus.infinite.delete(); + + if (this.localuser.channelfocus.isThread() && !this.localuser.channelfocus.member) { + const prev = this.localuser.channelfocus; + this.localuser.channelfocus = this; + prev.parent?.createguildHTML(); + } } else if (this.localuser.channelfocus === this && !aroundMessage) { if (this.lastmessageid) this.infinite.focus(aroundMessage || this.lastmessageid, !!aroundMessage, true); @@ -1833,6 +1888,13 @@ class Channel extends SnowFlake { this.guild.perminfo.prevchannel = this.id; this.localuser.channelfocus = this; + if (this.isThread() && !this.member) { + this.parent?.createguildHTML(); + if (this.myhtml) { + this.myhtml.classList.add("viewChannel"); + } + } + if ( this.nsfw && //@ts-ignore another hack (!this.perminfo.nsfwOk || !this.localuser.user.nsfw_allowed) @@ -2320,7 +2382,6 @@ class Channel extends SnowFlake { this.parent_id = undefined; } - this.children = []; this.guild_id = json.guild_id; const oldover = this.permission_overwrites; this.permission_overwrites = new Map(); diff --git a/src/webpage/guild.ts b/src/webpage/guild.ts index ef5f3d6..346bf93 100644 --- a/src/webpage/guild.ts +++ b/src/webpage/guild.ts @@ -1373,6 +1373,7 @@ class Guild extends SnowFlake { this.channels.push(temp); this.localuser.channelids.set(temp.id, temp); } + this.headchannels = []; for (const thing of this.channels) { const parent = thing.resolveparent(this); @@ -1380,6 +1381,11 @@ class Guild extends SnowFlake { this.headchannels.push(thing); } } + for (const thread of json.threads) { + const temp = new Channel(thread, this); + this.localuser.channelids.set(temp.id, temp); + temp.resolveparent(this); + } this.prevchannel = this.localuser.channelids.get(this.perminfo.prevchannel); this.stickers = json.stickers.map((_) => new Sticker(_, this)) || []; } @@ -1675,6 +1681,21 @@ class Guild extends SnowFlake { noti.classList.add("notiunread"); } } + async goToThread(threadId: string) { + if (!this.localuser.channelids.has(threadId)) { + const channelJson = (await ( + await fetch(this.info.api + "/channels/" + threadId, { + headers: this.headers, + }) + ).json()) as channeljson; + const channel = new Channel(channelJson, this); + this.localuser.channelids.set(channel.id, channel); + channel.resolveparent(this); + const par = this.localuser.channelids.get(channel.parent_id as string); + par?.createguildHTML(); + } + this.localuser.goToChannel(threadId); + } getHTML() { const sideContainDiv = document.getElementById("sideContainDiv"); if (sideContainDiv) { @@ -1737,6 +1758,9 @@ class Guild extends SnowFlake { if (channel) { await channel.getHTML(addstate, undefined, message); return; + } else { + await this.goToThread(ID); + return; } } if (this.prevchannel && ID !== null && this.prevchannel.visible) { @@ -1809,22 +1833,22 @@ class Guild extends SnowFlake { updateChannel(json: channeljson) { const channel = this.localuser.channelids.get(json.id); if (channel) { + const parent = channel.parent; channel.updateChannel(json); - this.headchannels = []; - for (const thing of this.channels) { - thing.children = []; - } - this.headchannels = []; - for (const thing of this.channels) { - const parent = thing.resolveparent(this); - if (!parent) { - this.headchannels.push(thing); + this.channels = this.channels.sort((a, b) => a.position - b.position); + if (parent !== channel.parent) { + if (parent) parent.children.filter((_) => _ !== channel); + channel.resolveparent(); + } else if (parent && !json.parent_id) { + this.headchannels.push(channel); + for (const thing of this.channels) { + thing.children = thing.children.filter((_) => _ !== channel); } } + for (const channel of this.channels) { channel.children = channel.children.sort((a, b) => a.position - b.position); } - this.channels = this.channels.sort((a, b) => a.position - b.position); this.printServers(); } } diff --git a/src/webpage/index.ts b/src/webpage/index.ts index 0ef673f..5aba729 100644 --- a/src/webpage/index.ts +++ b/src/webpage/index.ts @@ -84,9 +84,9 @@ if (window.location.pathname.startsWith("/channels")) { } regSwap(thisUser); - thisUser.initwebsocket().then(() => { + thisUser.initwebsocket().then(async () => { thisUser.loaduser(); - thisUser.init(); + await thisUser.init(); const loading = document.getElementById("loading") as HTMLDivElement; loading.classList.add("doneloading"); loading.classList.remove("loading"); diff --git a/src/webpage/jsontypes.ts b/src/webpage/jsontypes.ts index b7d8bde..740bf14 100644 --- a/src/webpage/jsontypes.ts +++ b/src/webpage/jsontypes.ts @@ -48,13 +48,7 @@ interface readyjson { guilds: guildjson[]; relationships: relationJson[]; read_state: { - entries: { - id: string; - channel_id: string; - last_message_id: string; - last_pin_timestamp: string; - mention_count: number; //in theory, the server doesn't actually send this as far as I'm aware - }[]; + entries: readStateEntry[]; partial: boolean; version: number; }; @@ -91,6 +85,13 @@ interface readyjson { }; }; } +export interface readStateEntry { + id: string; + channel_id: string; + last_message_id: string; + last_pin_timestamp: string; + mention_count: number; //in theory, the server doesn't actually send this as far as I'm aware +} export interface sessionJson { active: boolean; activities: []; //will need to find example of this @@ -455,6 +456,7 @@ type emojipjson = emojijson & { type guildjson = { application_command_counts: {[key: string]: number}; channels: channeljson[]; + threads: channeljson[]; data_mode: string; emojis: emojipjson[]; guild_scheduled_events: []; @@ -503,7 +505,6 @@ type guildjson = { roles: rolesjson[]; stage_instances: []; stickers: stickerJson[]; - threads: []; version: string; guild_hashes: {}; joined_at: string; @@ -530,7 +531,20 @@ type startTypingjson = { member?: memberjson; }; }; +export interface threadMember { + id: string; + user_id: string; + join_timestamp: string; + flags: number; + muted: boolean; + mute_config?: { + end_time?: string; + selected_time_window?: number; + }; + member?: memberjson; +} type channeljson = { + member: threadMember; id: string; rate_limit_per_user?: number; owner_id?: string; @@ -683,6 +697,7 @@ type messagejson = { emoji: emojijson; //very likely needs expanding me: boolean; }[]; + thread?: channeljson; interaction?: { id: string; type: 2 | 3; @@ -842,6 +857,18 @@ type wsjson = s: number; t: "MESSAGE_DELETE"; } + | { + op: 0; + t: "THREAD_MEMBERS_UPDATE"; + d: { + guild_id: string; + id: string; + member_count: number; + added_members?: [threadMember]; + removed_member_ids?: string[]; + }; + s: 3; + } | { op: 0; d: { diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index a0e86b4..9555ba7 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -14,6 +14,7 @@ import { messageCreateJson, messagejson, presencejson, + readStateEntry, readyjson, startTypingjson, wsjson, @@ -122,10 +123,10 @@ class Localuser { sessionStorage.setItem("currentuser", specialUser.uid); localStorage.setItem("userinfos", JSON.stringify(Localuser.users)); - thisUser.initwebsocket().then(() => { + thisUser.initwebsocket().then(async () => { const loaddesc = document.getElementById("load-desc") as HTMLElement; thisUser.loaduser(); - thisUser.init(); + await thisUser.init(); loading.classList.add("doneloading"); loaddesc.textContent = I18n.loaded(); loading.classList.remove("loading"); @@ -331,6 +332,7 @@ class Localuser { } } guildFolders: guildFolder[] = []; + unknownRead = new Map(); async gottenReady(ready: readyjson): Promise { await I18n.done; this.queryBlog(); @@ -406,6 +408,7 @@ class Localuser { for (const thing of ready.d.read_state.entries) { const channel = this.channelids.get(thing.channel_id); if (!channel) { + this.unknownRead.set(thing.channel_id, thing); continue; } channel.readStateInfo(thing); @@ -654,9 +657,9 @@ class Localuser { () => { if (this.swapped) return; loaddesc.textContent = I18n.retrying(); - this.initwebsocket().then(() => { + this.initwebsocket().then(async () => { this.loaduser(); - this.init(); + await this.init(); const loading = document.getElementById("loading") as HTMLElement; loading.classList.add("doneloading"); loading.classList.remove("loading"); @@ -695,6 +698,31 @@ class Localuser { } if (temp.op == 0) { switch (temp.t) { + case "THREAD_MEMBERS_UPDATE": { + const channel = this.channelids.get(temp.d.id); + if (!channel) return; + if (temp.d.added_members) { + for (const memb of temp.d.added_members) { + if (memb.user_id === this.user.id) { + channel.member = memb; + channel.parent?.createguildHTML(); + } else { + //TODO store these somewhere + } + } + } + if (temp.d.removed_member_ids) { + for (const id of temp.d.removed_member_ids) { + if (id === this.user.id) { + channel.member = undefined; + channel.parent?.createguildHTML(); + } else { + //TODO unstore these somewhere + } + } + } + break; + } case "INTERACTION_FAILURE": case "INTERACTION_CREATE": case "INTERACTION_SUCCESS": @@ -1507,7 +1535,7 @@ class Localuser { this.loadGuild(json.guild_id, true); } } - init(): void { + async init() { const location = window.location.href.split("/"); this.buildservers(); if (location[3] === "channels") { @@ -1515,7 +1543,7 @@ class Localuser { if (!guild) { return; } - guild.loadChannel(location[5], true, location[6]); + await guild.loadChannel(location[5], true, location[6]); this.channelfocus = this.channelids.get(location[5]); } } diff --git a/src/webpage/message.ts b/src/webpage/message.ts index e3093fd..b42eb9c 100644 --- a/src/webpage/message.ts +++ b/src/webpage/message.ts @@ -8,7 +8,14 @@ import {Localuser} from "./localuser.js"; import {Role} from "./role.js"; import {File} from "./file.js"; import {SnowFlake} from "./snowflake.js"; -import {emojijson, interactionEvents, memberjson, messagejson, userjson} from "./jsontypes.js"; +import { + channeljson, + emojijson, + interactionEvents, + memberjson, + messagejson, + userjson, +} from "./jsontypes.js"; import {Emoji} from "./emoji.js"; import {mobile} from "./utils/utils.js"; import {I18n} from "./i18n.js"; @@ -118,6 +125,33 @@ class Message extends SnowFlake { }, }, ); + + Message.contextmenu.addButton( + () => I18n.message.createThread(), + function () { + const dio = new Dialog(I18n.message.threadOptions()); + const opt = dio.options.addForm( + "", + (body) => { + const channelJson = body as channeljson; + this.guild.goToThread(channelJson.id); + dio.hide(); + }, + { + fetchURL: + this.info.api + "/channels/" + this.channel.id + "/messages/" + this.id + "/threads", + headers: this.headers, + }, + ); + opt.addTextInput(I18n.threads.name(), "name"); + dio.show(); + }, + { + visible: function () { + return this.channel.hasPermission("CREATE_PUBLIC_THREADS"); + }, + }, + ); Message.contextmenu.addButton( () => I18n.message.reactions(), function (this: Message) { @@ -335,6 +369,7 @@ class Message extends SnowFlake { } components?: Components; edited_timestamp: string | null = null; + thread?: Channel; giveData(messagejson: messagejson) { const func = this.channel.infinite.snapBottom(); for (const thing of Object.keys(messagejson)) { @@ -370,6 +405,14 @@ class Message extends SnowFlake { } else if (thing === "components" && messagejson.components) { this.components = new Components(messagejson.components, this); continue; + } else if (thing === "thread" && messagejson.thread) { + let thread = this.localuser.channelids.get(messagejson.thread.id); + if (!thread) { + thread = new Channel(messagejson.thread, this.guild); + thread.resolveparent(); + this.localuser.channelids.set(thread.id, thread); + this.thread = thread; + } } (this as any)[thing] = (messagejson as any)[thing]; } @@ -606,6 +649,15 @@ class Message extends SnowFlake { if (div === this.div) { this.div.classList.add("messagediv"); } + if (this.type === 21) { + this.channel.parent?.getmessage(this.message_reference?.message_id as string).then((_) => { + const gen = _?.generateMessage(undefined, undefined, div); + if (!gen) { + //idk + } + }); + return div; + } const editmode = this.channel.editing === this; if (!premessage && !dupe) { @@ -722,7 +774,7 @@ class Message extends SnowFlake { } } } - if (this.message_reference && this.type !== 6) { + if (this.message_reference && this.type !== 6 && this.type !== 18) { const replyline = document.createElement("div"); const minipfp = document.createElement("img"); @@ -1031,6 +1083,38 @@ class Message extends SnowFlake { afterText.classList.add("pinText"); text.append(afterText); + const time = document.createElement("span"); + time.textContent = " " + formatTime(new Date(this.timestamp)); + time.classList.add("timestamp"); + text.append(time); + div.classList.add("topMessage"); + } else if (this.type === 18) { + const text = document.createElement("div"); + build.appendChild(text); + + const m = I18n.message.pin("|||", "???").split("|||"); + if (m.length === 2) text.append(m.shift() as string); + + const username = document.createElement("span"); + username.textContent = this.author.name; + //this.author.profileclick(username); + this.author.bind(username, this.guild); + text.appendChild(username); + username.classList.add("username"); + const midText = m[0].split("???"); + + const afterText = document.createElement("span"); + afterText.textContent = midText[0]; + text.append(afterText); + + const threadName = document.createElement("span"); + threadName.textContent = this.content.rawString; + threadName.classList.add("pinText"); + text.append(threadName); + threadName.onclick = () => { + this.guild.goToThread(this.message_reference?.channel_id as string); + }; + const time = document.createElement("span"); time.textContent = " " + formatTime(new Date(this.timestamp)); time.classList.add("timestamp"); @@ -1111,6 +1195,25 @@ class Message extends SnowFlake { } this.bindButtonEvent(); + if (!dupe && this.thread) { + const thread = this.thread; + const threadBox = document.createElement("div"); + threadBox.classList.add("flexttb", "threadBox"); + const topRow = document.createElement("div"); + topRow.classList.add("flexltr"); + const title = document.createElement("span"); + title.textContent = thread.name; + topRow.onclick = () => { + this.localuser.goToChannel(thread.id); + }; + const messages = document.createElement("span"); + messages.classList.add("clickable"); + messages.textContent = I18n.message.messages("10"); + topRow.append(title, messages); + threadBox.append(topRow); + div.append(threadBox); + } + return div; } bindButtonEvent() { diff --git a/src/webpage/style.css b/src/webpage/style.css index ec5c728..5479ed3 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -1706,6 +1706,33 @@ span.instanceStatus { color: var(--primary-text-soft); overflow-y: auto; } +.threads { + button { + position: relative; + } + button::before { + content: ""; + position: absolute; + top: 0px; + left: 0px; + height: calc(50% - 1px); + width: 3px; + border-bottom: 2px solid var(--reply-line); + border-left: 2px solid var(--reply-line); + border-bottom-left-radius: 8px; + } + button::after { + content: ""; + position: absolute; + bottom: 0px; + left: 0px; + height: calc(50% - 1px); + width: 3px; + border-top: 2px solid var(--reply-line); + border-left: 2px solid var(--reply-line); + border-top-left-radius: 8px; + } +} .channels { overflow-y: hidden; transition: height 0.2s ease-in-out; @@ -1744,8 +1771,8 @@ span.instanceStatus { .channels .channelbutton { margin-left: 8px; } -.viewChannel .channelbutton, -.viewChannel .channelbutton:hover { +.viewChannel > .channelbutton, +.viewChannel > .channelbutton:hover { background: var(--channel-selected); font-weight: bold; color: var(--primary-text-prominent); @@ -4636,3 +4663,29 @@ img.error::after { object-fit: cover; opacity: 0.8; } +.threadBox { + padding: 6px; + background: #00000063; + border-radius: 4px; + margin: 6px; + min-width: 300px; + width: fit-content; + margin-left: 50px; + margin-top: 10px; + position: relative; + ::before { + content: ""; + position: absolute; + top: -8px; + left: -32px; + height: calc(100% - 8px); + width: 26px; + border-bottom: 2px solid var(--reply-line); + border-left: 2px solid var(--reply-line); + border-bottom-left-radius: 8px; + } + .clickable { + color: var(--link); + margin-left: auto; + } +} diff --git a/translations/en.json b/translations/en.json index a55e6df..f176fcb 100644 --- a/translations/en.json +++ b/translations/en.json @@ -137,6 +137,11 @@ "unmute": "Unmute Channel", "voice": "Voice" }, + "threads":{ + "leave":"Leave Thread", + "join":"Join Thread", + "name":"Name:" + }, "channelLink": "# $1", "clientDesc": "Client version: $1\n\n[Join the official Fermi guild]($2/invite/USgYJo?instance=https%3A%2F%2Fspacebar.chat)\n\n[Help translate Fermi](https://translatewiki.net/wiki/Translating:JankClient#sortable:3=desc) \n\n[Help create Fermi](https://github.com/MathMan05/Fermi)\n\n[Help maintain the server Fermi relies on](https://github.com/spacebarchat/server)\n\n[Read the blog](https://blog.fermi.chat/)\n\nCalculated rights: $3", "commands": { @@ -555,6 +560,7 @@ "timeoutTime": "Timeout Time:" }, "message": { + "messages":"$1 {{PLURAL:$1|Message|Messages}} >", "andMore": "$1, and more!", "attached": "Sent an attachment", "delete": "Delete message", @@ -564,8 +570,11 @@ "edited": "(edited)", "fullMessage": "Full message:", "pin": "$1 has pinned a message", + "pin": "$1 has created a thread: $2", "reactedWith": "Reacted with $1", "reactionAdd": "Add reaction", + "createThread":"Create Thread", + "threadOptions":"Thread options", "reactions": "View Reactions", "reactionsTitle": "Reactions", "retry": "Resend errored message",