diff --git a/src/webpage/channel.ts b/src/webpage/channel.ts index 649520a..885e6c9 100644 --- a/src/webpage/channel.ts +++ b/src/webpage/channel.ts @@ -1901,8 +1901,85 @@ class Channel extends SnowFlake { return; } - async forumSearch(text: string, div: HTMLDivElement) { - while (!this.hasFetchedForForum) await this.fetchForum(); + umap = new Map(); + search: [string, number, string][] = []; + hasAllThreads = false; + + async fetchThreads(text: string, offset: number) { + const filters = this.forumFilters; + const uid = `${filters.recentFirst}-${filters.sortActive}-${filters.tagMatchAll}-${filters.tags.join("-")}-${text}`; + const search = new URLSearchParams([ + ["archived", "true"], + ["limit", "25"], + ["sort_by", filters.sortActive ? "last_message_time" : "creation_time"], + ["sort_order", filters.recentFirst ? "desc" : "asc"], + ["tag_setting", filters.tagMatchAll ? "match_all" : "match_some"], + ["offset", offset + ""], + ["tag", filters.tags.join(",")], + ]); + const res = (await ( + await fetch(this.info.api + "/channels/" + this.id + "/threads/search?" + search, { + headers: this.headers, + }) + ).json()) as { + threads: channeljson[]; + members: memberjson[]; + messages: messagejson[]; + total_results: number; + has_more: boolean; + }; + for (const threadjson of res.threads) { + if (this.localuser.channelids.has(threadjson.id)) continue; + const thread = new Channel(threadjson, this.guild); + this.localuser.channelids.set(threadjson.id, thread); + thread.resolveparent(); + } + for (const message of res.messages) { + const thread = this.localuser.channelids.get(message.channel_id); + if (!thread) continue; + const m = this.localuser.messages.get(message.id); + if (!m) this.localuser.messages.set(message.id, new Message(message, thread)); + } + for (const member of res.members) { + Member.new(member, this.guild); + } + if (res.has_more) { + const lastid = res.threads.at(-1)?.id as string; + if (text) { + const arr = this.search.find(([id]) => id === uid); + if (arr) { + arr[1] = offset + 25; + arr[2] = lastid; + } else { + this.search.push([uid, offset + 25, lastid]); + if (this.search.length > 200) { + this.search.shift(); + } + } + } else { + this.umap.set(uid, [offset + 25, lastid]); + } + } else { + if (text) { + const arr = this.search.find(([id]) => id === uid); + if (arr) { + arr[1] = offset + 25; + arr[2] = "-1"; + } else { + this.search.push([uid, offset + 25, "-1"]); + if (this.search.length > 200) { + this.search.shift(); + } + } + } else { + this.umap.clear(); + this.search = []; + this.hasAllThreads = true; + } + } + } + + genCurSort(text: string, offset: number) { const filters = this.forumFilters; text = text.toLowerCase(); let match = this.children.filter((thread) => thread.name.toLowerCase().includes(text)); @@ -1911,7 +1988,7 @@ class Channel extends SnowFlake { match = match.filter((thread) => { const tags = new Set(thread.appliedTags); if (filters.tagMatchAll) { - return tags.isSubsetOf(tagSet); + return tagSet.isSubsetOf(tags); } else { return tags.intersection(tagSet).size; } @@ -1926,8 +2003,222 @@ class Channel extends SnowFlake { return +(Number(c1.id) > Number(c2.id)) ^ +filters.recentFirst ? 1 : -1; } }); - div.innerHTML = ""; - div.append(...(await Promise.all(match.map((_) => _.renderThread())))); + return [match.splice(offset, offset + 24), !!match.at(offset + 25)] as const; + } + + async findMatches(text: string, offset: number): Promise { + if (this.hasAllThreads) return this.genCurSort(text, offset); + + const filters = this.forumFilters; + const uid = `${filters.recentFirst}-${filters.sortActive}-${filters.tagMatchAll}-${filters.tags.join("-")}-${text}`; + let offset2 = 0; + let lastid = ""; + if (text) { + [, offset2, lastid] = this.search.find(([id]) => id === uid) || ["", 0, ""]; + } else { + [offset2, lastid] = this.umap.get(uid) || [0, ""]; + } + + if (lastid) { + const [list, more] = this.genCurSort(text, offset); + for (const elm of list) { + if (elm.id === lastid) { + await this.fetchThreads(text, offset2); + return this.genCurSort(text, offset); + } + } + return [list, more]; + } else { + await this.fetchThreads(text, offset2); + return this.genCurSort(text, offset); + } + } + + async forumSearch(text: string, div: HTMLDivElement) { + while (!this.hasFetchedForForum) await this.fetchForum(); + let offset = 0; + const flipPage = async (by: number) => { + offset += by; + const [match, more] = await this.findMatches(text, 0); + await renderDiv(match, more); + }; + + const renderDiv = async (threads: Channel[], more: boolean) => { + div.innerHTML = ""; + + const sortRow = document.createElement("div"); + sortRow.classList.add("flexltr", "forumSortRow"); + div.append(sortRow); + + const cMenu = new Contextmenu("sortAndStuff"); + const opts = I18n.forum.sortOptions; + + cMenu.addText(opts.sortby.title()); + cMenu.addButton( + opts.sortby.recent(), + () => { + this.forumFilters.sortActive = true; + flipPage(0); + }, + { + icon: { + css: this.forumFilters.sortActive ? "svg-select" : "svg-noSelect", + }, + }, + ); + cMenu.addButton( + opts.sortby.posted(), + () => { + this.forumFilters.sortActive = false; + flipPage(0); + }, + { + icon: { + css: this.forumFilters.sortActive ? "svg-noSelect" : "svg-select", + }, + }, + ); + cMenu.addSeperator(); + + cMenu.addText(opts.sortOrder.title()); + cMenu.addButton( + opts.sortOrder.recent(), + () => { + this.forumFilters.recentFirst = true; + flipPage(0); + }, + { + icon: { + css: this.forumFilters.recentFirst ? "svg-select" : "svg-noSelect", + }, + }, + ); + + cMenu.addButton( + opts.sortOrder.old(), + () => { + this.forumFilters.recentFirst = false; + flipPage(0); + }, + { + icon: { + css: this.forumFilters.recentFirst ? "svg-noSelect" : "svg-select", + }, + }, + ); + cMenu.addSeperator(); + + cMenu.addText(opts.tagMatch.title()); + cMenu.addButton( + opts.tagMatch.some(), + () => { + this.forumFilters.tagMatchAll = false; + flipPage(0); + }, + { + icon: {css: this.forumFilters.tagMatchAll ? "svg-noSelect" : "svg-select"}, + }, + ); + cMenu.addButton( + opts.tagMatch.all(), + () => { + this.forumFilters.tagMatchAll = true; + flipPage(0); + }, + { + icon: { + css: this.forumFilters.tagMatchAll ? "svg-select" : "svg-noSelect", + }, + }, + ); + + const sortOptionButton = document.createElement("button"); + sortOptionButton.textContent = opts.name(); + + sortOptionButton.onclick = (e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopImmediatePropagation(); + const box = sortOptionButton.getBoundingClientRect(); + cMenu.makemenu(box.left, box.bottom + 6); + }; + + const tags = document.createElement("div"); + tags.classList.add("forumTagSelect"); + + const allMenu = new Contextmenu("allTagMenu"); + + for (const tag of this.availableTags) { + const html = tag.makeHTML(); + if (this.forumFilters.tags.includes(tag.id)) html.classList.add("selected"); + html.onclick = () => { + if (this.forumFilters.tags.includes(tag.id)) { + this.forumFilters.tags = this.forumFilters.tags.filter((id) => id !== tag.id); + } else { + this.forumFilters.tags.push(tag.id); + this.forumFilters.tags.sort(); + } + flipPage(0); + }; + tags.append(html); + allMenu.addButton( + tag.name, + () => { + if (this.forumFilters.tags.includes(tag.id)) { + this.forumFilters.tags = this.forumFilters.tags.filter((id) => id !== tag.id); + } else { + this.forumFilters.tags.push(tag.id); + this.forumFilters.tags.sort(); + } + flipPage(0); + }, + { + icon: {css: this.forumFilters.tags.includes(tag.id) ? "svg-select" : "svg-noSelect"}, + }, + ); + } + + const allTags = document.createElement("button"); + allTags.classList.add("allTagButton"); + allTags.textContent = I18n.forum.allTags(); + + allTags.onclick = (e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopImmediatePropagation(); + const box = allTags.getBoundingClientRect(); + allMenu.makemenu(box.right - window.innerWidth, box.bottom + 6); + }; + + sortRow.append(sortOptionButton, tags, allTags); + + div.append(...(await Promise.all(threads.map((_) => _.renderThread())))); + + const buttonRow = document.createElement("div"); + buttonRow.classList.add("flexltr", "forumButtonRow"); + + const next = document.createElement("button"); + next.textContent = I18n.forum.next(); + next.disabled = !more; + if (more) { + next.onclick = () => { + flipPage(25); + }; + } + + const back = document.createElement("button"); + back.textContent = I18n.forum.back(); + back.disabled = !offset; + if (offset) { + back.onclick = () => { + flipPage(-25); + }; + } + + buttonRow.append(back, next); + div.append(buttonRow); + }; + await flipPage(0); } renderForum() { (document.getElementById("typediv") as HTMLElement).style.visibility = "hidden"; @@ -1951,6 +2242,14 @@ class Channel extends SnowFlake { const theadList = document.createElement("div"); theadList.classList.add("flexttb", "forumList"); container.append(theadList); + + let curidsearch = 0; + text.onkeyup = async () => { + const thisid = ++curidsearch; + await new Promise((res) => setTimeout(res, 120)); + if (thisid !== curidsearch) return; + this.forumSearch(text.value, theadList); + }; this.forumSearch("", theadList); } async getHTML(addstate = true, getMessages: boolean | void = undefined, aroundMessage?: string) { diff --git a/src/webpage/contextmenu.ts b/src/webpage/contextmenu.ts index e351d66..04589c6 100644 --- a/src/webpage/contextmenu.ts +++ b/src/webpage/contextmenu.ts @@ -166,6 +166,25 @@ class Seperator implements menuPart { } } } + +class ContextMenuText implements menuPart { + private visible?: (obj1: x, obj2: y) => boolean; + group?: string; + text: string; + constructor(text: string, visible?: (obj1: x, obj2: y) => boolean, group?: string) { + this.visible = visible; + this.group = group; + this.text = text; + } + makeContextHTML(obj1: x, obj2: y, menu: HTMLDivElement): void { + if (!this.visible || this.visible(obj1, obj2)) { + const span = document.createElement("span"); + span.textContent = this.text; + menu.append(span); + } + } +} + declare global { interface HTMLElementEventMap { layered: LayeredEvent; @@ -242,11 +261,16 @@ class Contextmenu { group?: string; } = {}, ) { - this.buttons.push(new ContextButton(text, onClick, addProps)); + const button = new ContextButton(text, onClick, addProps); + this.buttons.push(button); + return button; } addSeperator(visible?: (obj1: x, obj2: y) => boolean, group?: string) { this.buttons.push(new Seperator(visible, group)); } + addText(text: string, visible?: (obj1: x, obj2: y) => boolean, group?: string) { + this.buttons.push(new ContextMenuText(text, visible, group)); + } addGroup( group: string, addprops?: { @@ -287,7 +311,12 @@ class Contextmenu { } else { div.style.bottom = y * -1 + "px"; } - div.style.left = x + "px"; + if (x > 0) { + div.style.left = x + "px"; + } else { + div.style.right = x * -1 + "px"; + } + document.body.appendChild(div); Contextmenu.keepOnScreen(div); diff --git a/src/webpage/icons/noSelect.svg b/src/webpage/icons/noSelect.svg new file mode 100644 index 0000000..23a1fc4 --- /dev/null +++ b/src/webpage/icons/noSelect.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/webpage/icons/select.svg b/src/webpage/icons/select.svg new file mode 100644 index 0000000..43e23b5 --- /dev/null +++ b/src/webpage/icons/select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/webpage/style.css b/src/webpage/style.css index bea9285..4e3ee59 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -895,6 +895,16 @@ textarea { mask-size: contain !important; background: var(--primary-text); } +.svg-select { + mask: url(./icons/select.svg); + mask-size: contain !important; + background: var(--primary-text); +} +.svg-noSelect { + mask: url(./icons/noSelect.svg); + mask-size: contain !important; + background: var(--primary-text); +} .svg-stopstream { mask: url(./icons/stopstream.svg); mask-size: contain !important; @@ -1107,10 +1117,32 @@ textarea { flex: 1; overflow-y: scroll; } +.allTagButton { + margin-left: auto; +} +.forumSortRow { + flex-shrink: 0; + padding: 4px 12px; + align-items: center; +} +.forumTagSelect { + display: flex; + flex-direction: row; + flex-wrap: wrap; + overflow-y: clip; + height: 26px; + > * { + margin-left: 6px; + } + .selected { + background: #ffffff5e; + } +} .forumTag { padding: 3px; background: #ffffff21; border-radius: 12px; + cursor: pointer; } .forumPostBody { background: #00000075; @@ -1122,6 +1154,14 @@ textarea { } cursor: pointer; } +.forumButtonRow { + flex-shrink: 0; + mix-height: fit-content; + padding: 12px; + button { + margin-right: 10px; + } +} .forumMessageRow { .svg-frmessage { width: 16px; diff --git a/translations/en.json b/translations/en.json index a47bcb6..a6bd6c9 100644 --- a/translations/en.json +++ b/translations/en.json @@ -144,7 +144,28 @@ "name":"Name:" }, "forum":{ - "creorsear":"Create or search for post" + "creorsear":"Create or search for post", + "next":"Next", + "back":"Back", + "sortOptions":{ + "sortby":{ + "title":"Sort By", + "recent":"Recently Active", + "posted":"Posted Date" + }, + "sortOrder":{ + "title":"Sort Order", + "recent":"Recent First", + "old":"Old First" + }, + "tagMatch":{ + "title":"Tag Matching", + "some":"Match Some", + "all":"Match All" + }, + "name":"Sort Options" + }, + "allTags":"All Tags" }, "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",