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".