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:
+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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user