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 <[email protected]>
This commit is contained in:
2026-09-05 20:44:19 -04:00
co-authored by Claude Opus 5
parent f1dafe2c83
commit 45f92babcc
4 changed files with 178 additions and 4 deletions
+1 -1
Submodule nobilis updated: fbf18b1efd...2e10190b35
@@ -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<DiscordThread[] | null>(null)
const button = useRef<HTMLSpanElement>(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 (
<>
<span ref={button} className="header-anchor">
<IconButton
name="forum"
title="Threads here"
className={open ? 'active' : undefined}
onClick={show}
/>
</span>
{open && (
<HeaderPopover anchor={button.current} width={400} onClose={() => setOpen(false)}>
<div className="small muted">Threads and posts</div>
{rows === null && <div className="small muted">Looking…</div>}
{rows?.length === 0 && <div className="small muted">Nothing here yet.</div>}
{rows?.map((thread) => (
<button
key={thread.id}
type="button"
className="history-row"
onClick={() => {
setOpen(false)
void window.moho
.rpc<{ bufferId: string }>('openDiscordThread', {
bufferId: buffer.id,
threadId: thread.id
})
.then((answer) => store.selectBuffer(answer.bufferId))
.catch((e: Error) => store.toast('error', e.message))
}}
>
<span className="ellipsis">{thread.name}</span>
<span className="small muted">
{thread.messageCount ? `${thread.messageCount} messages` : 'no replies'}
{/* Said plainly, because an archived thread reads as a dead
one until you open it - and opening it brings it back. */}
{thread.archived ? ' · archived' : ''}
</span>
</button>
))}
</HeaderPopover>
)}
</>
)
}
/**
* 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:') && <InviteToRoom buffer={buffer} />}
{isMatrix && buffer.kind !== 'server' && <ThreadList buffer={buffer} />}
{isDiscord && buffer.kind === 'channel' && <DiscordThreads buffer={buffer} />}
{(isMatrix || isDiscord) && buffer.kind !== 'server' && <PinnedMessages buffer={buffer} />}
{/* What this channel has asked before now. Two buttons rather than one
+87 -3
View File
@@ -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<string,
}
}
/** What a Discord account may do in the guild a conversation belongs to. */
interface DiscordPowers {
canKick?: boolean
canBan?: boolean
canMute?: boolean
canAssignRoles?: boolean
roles?: { id: string; name: string; colour?: string | null }[]
}
export function NickList(): JSX.Element {
const store = useStore()
const buffer = useActiveBuffer()
@@ -123,12 +132,29 @@ export function NickList(): JSX.Element {
// moderator buttons. Same shape as IRC's,
// and advisory in the same way - Kick re-checks and refuses if we were
// wrong, which it explains far better than a status code would.
// Discord answers this from the roles it has already sent - it has no
// endpoint for "what may I do", so its own client works this out the same
// way. Advisory like the rest: the service re-checks and refuses.
const [discordPowers, setDiscordPowers] = useState<DiscordPowers | null>(null)
useEffect(() => {
setDiscordPowers(null)
if (account?.service !== 'discord' || !buffer) return
void window.moho
.rpc<DiscordPowers>('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 },
+2
View File
@@ -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".