From 45f92babcc5e4a341c4d446cacb92182ddba2909 Mon Sep 17 00:00:00 2001 From: Salastil Date: Sat, 5 Sep 2026 20:44:19 -0400 Subject: [PATCH] Somewhere to find a thread, and something to do about a member The header gets a thread button on Discord channels, listing what is going on in this one and, in a forum, what has been posted to it - saying which are archived, because an archived thread reads as a dead one until you open it and opening it brings it back. Picking one opens it as its own conversation. The member menu speaks Discord now: remove, ban and time out, which it had always drawn for other services and never offered here, plus the roles this account may hand out. What somebody already holds is in the member list, so taking a role away is offered beside giving one. Co-Authored-By: Claude Opus 5 --- nobilis | 2 +- .../src/components/ConversationTools.tsx | 88 ++++++++++++++++++ src/renderer/src/components/NickList.tsx | 90 ++++++++++++++++++- src/shared/wire.ts | 2 + 4 files changed, 178 insertions(+), 4 deletions(-) diff --git a/nobilis b/nobilis index fbf18b1..2e10190 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit fbf18b1efddd5ee3e6f52fc282384ee5433bf043 +Subproject commit 2e10190b358d89589ba12dfcf8547f6377e6a21d diff --git a/src/renderer/src/components/ConversationTools.tsx b/src/renderer/src/components/ConversationTools.tsx index 191ecc3..66c708e 100644 --- a/src/renderer/src/components/ConversationTools.tsx +++ b/src/renderer/src/components/ConversationTools.tsx @@ -9,6 +9,14 @@ import type { BufferEntry, LiveCard } from '../state/store' import type { Message } from '../../../shared/wire' /** What the homeserver answered, alongside this window's own results. */ +/** One thread or forum post, as the daemon lists it. */ +interface DiscordThread { + id: string + name: string + archived?: boolean + messageCount?: number +} + /** What Discord's own search hands back. */ interface DiscordSearch { results: Message[] @@ -256,6 +264,85 @@ interface ThreadSummary { lastTs?: number | null } +/** + * The threads a Discord channel is holding, and the posts in a forum. + * + * Asked for when the button is pressed rather than kept: a guild can be + * holding hundreds, and which of them somebody wants is a question they are + * about to answer by pressing this. + * + * Opening one joins it, because Discord will not send a thread's messages to + * somebody who is not in it - which is also how reading one works in its own + * client, and is why this is a call rather than a lookup. + */ +function DiscordThreads({ buffer }: { buffer: BufferEntry }): JSX.Element { + const store = useStore() + const [open, setOpen] = useState(false) + const [rows, setRows] = useState(null) + const button = useRef(null) + + const show = (): void => { + if (open) { + setOpen(false) + return + } + setOpen(true) + setRows(null) + void window.moho + .rpc<{ threads: DiscordThread[] }>('listDiscordThreads', { bufferId: buffer.id }) + .then((answer) => setRows(answer.threads)) + .catch((e: Error) => { + store.toast('error', e.message) + setRows([]) + }) + } + + return ( + <> + + + + {open && ( + setOpen(false)}> +
Threads and posts
+ {rows === null &&
Looking…
} + {rows?.length === 0 &&
Nothing here yet.
} + {rows?.map((thread) => ( + + ))} +
+ )} + + ) +} + /** * What a room has told everyone to read first. * @@ -651,6 +738,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem {buffer.accountId.startsWith('matrix:') && } {isMatrix && buffer.kind !== 'server' && } + {isDiscord && buffer.kind === 'channel' && } {(isMatrix || isDiscord) && buffer.kind !== 'server' && } {/* What this channel has asked before now. Two buttons rather than one diff --git a/src/renderer/src/components/NickList.tsx b/src/renderer/src/components/NickList.tsx index 94952c2..761e8c7 100644 --- a/src/renderer/src/components/NickList.tsx +++ b/src/renderer/src/components/NickList.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Icon, IconButton } from './Icon' import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu' import { useActiveBuffer, useChat, useIdSetPref, useStore } from '../state/hooks' @@ -96,6 +96,15 @@ function ircPermissions(members: Member[], currentNick: string): Record(null) + useEffect(() => { + setDiscordPowers(null) + if (account?.service !== 'discord' || !buffer) return + void window.moho + .rpc('getDiscordPowers', { bufferId: buffer.id }) + .then(setDiscordPowers) + .catch(() => { + /* advisory only: no answer means no moderation entries */ + }) + }, [account?.service, account?.id, buffer?.id]) + const perms = account?.service === 'irc' ? ircPermissions(members, account.currentNick) : account?.service === 'kick' ? kickPermissions(members, account.currentNick) - : (buffer && permissions[buffer.id]) || {} + : account?.service === 'discord' + ? discordPowers ?? {} + : (buffer && permissions[buffer.id]) || {} const groups = useMemo(() => { const filtered = query @@ -264,6 +290,20 @@ export function NickList(): JSX.Element { .catch((e: Error) => store.toast('error', e.message)) return } + if (account.service === 'discord') { + // Discord's own three, and "mute" is a timeout there - + // the same word this menu uses everywhere, meaning what + // the service means by it. + void window.moho + .rpc('moderateDiscordMember', { + bufferId: buffer.id, + userId: member.userId || member.nick, + action, + minutes: 10 + }) + .catch((e: Error) => store.toast('error', e.message)) + return + } if (action === 'op' || action === 'deop' || action === 'voice') { store.toast('error', 'Only IRC has channel ranks') return @@ -282,6 +322,17 @@ export function NickList(): JSX.Element { }) .catch((e: Error) => store.toast('error', e.message)) }} + onSetRole={(roleId, give) => { + if (!buffer) return + void window.moho + .rpc('setDiscordMemberRole', { + bufferId: buffer.id, + userId: member.userId || member.nick, + roleId, + give + }) + .catch((e: Error) => store.toast('error', e.message)) + }} onCall={() => { if (!account) return store.toast('info', `Calling ${member.nick}…`) @@ -328,12 +379,21 @@ interface MemberRowProps { canWhisper: boolean /** IRC carries a file directly between two people; nothing else here does. */ canSendFile: boolean - perms: { canKick?: boolean; canBan?: boolean; canMute?: boolean; canOp?: boolean } + perms: { + canKick?: boolean + canBan?: boolean + canMute?: boolean + canOp?: boolean + canAssignRoles?: boolean + roles?: { id: string; name: string; colour?: string | null }[] + } /** Which service this row belongs to, so an action is named as it acts. */ service: string | undefined onToggleBlock: (key: string) => void onMention: () => void onModerate: (action: ModerationAction) => void + /** Gives this member a role, or takes it away. Discord only. */ + onSetRole: (roleId: string, give: boolean) => void /** Matrix only: give this member a power level outright. */ onSetPower: (level: number) => void onOpenDm: () => void @@ -359,6 +419,7 @@ function MemberRow({ onToggleBlock, onMention, onModerate, + onSetRole, onOpenDm, onWhisper, onSendFile, @@ -419,6 +480,29 @@ function MemberRow({ // Channel ranks, which only IRC has. Both directions offered rather than // one toggle: the member's own prefix says which way round it should be, // and a menu that guessed wrong would take op from somebody by accident. + // Discord's roles, offered as entries rather than a submenu: a guild has + // a handful worth giving by hand, and the rest are managed by bots and + // were filtered out before they got here. + ...(service === 'discord' && perms.canAssignRoles && (perms.roles?.length ?? 0) > 0 + ? ([ + { separator: true }, + ...(perms.roles ?? []).slice(0, 12).map((role) => ({ + label: `Give ${role.name}`, + icon: 'shield', + onClick: () => onSetRole(role.id, true) + })), + ...(member.roles ?? []) + .filter((held) => (perms.roles ?? []).some((r) => r.name === held)) + .map((held) => ({ + label: `Take ${held}`, + icon: 'remove_moderator', + onClick: () => { + const role = (perms.roles ?? []).find((r) => r.name === held) + if (role) onSetRole(role.id, false) + } + })) + ] as MenuEntry[]) + : []), ...(perms.canOp ? ([ { separator: true }, diff --git a/src/shared/wire.ts b/src/shared/wire.ts index 7626044..ce0e4c6 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -452,6 +452,8 @@ export interface Member { prefix?: string away?: boolean userId?: string + /** The roles this person holds here, by name. Discord only. */ + roles?: string[] powerLevel?: number /** * The protocol's own presence word - "online", "idle", "dnd", "offline".