From 38458b61bf2c1777ecdffc283bc1cbf7c8598549 Mon Sep 17 00:00:00 2001 From: Salastil Date: Sat, 5 Sep 2026 18:58:42 -0400 Subject: [PATCH] Somewhere to be invisible, and somewhere to answer The status menu offers do-not-disturb and invisible beside the two it had, and the sign-out entry is named for what it does rather than sitting among them looking like a mood. Requests get their own short list above the friends, because they are not people you can message and they are what somebody opened the panel to deal with - filtering them by whether they happen to be online, which is what the toggle below does, would hide the ones that matter most. Refusing somebody's request and taking back your own are the same call and read differently enough to be given different words. The command lookup moved above the early return it was sitting behind. React counts hooks per render, and a composer with no conversation ran one fewer than a composer with one - which is the minified error #310 that appeared the moment a conversation opened. Co-Authored-By: Claude Opus 5 --- nobilis | 2 +- src/renderer/src/app.css | 17 +++++++ src/renderer/src/components/Composer.tsx | 59 +++++++++++----------- src/renderer/src/components/JoinPanel.tsx | 54 +++++++++++++++++++- src/renderer/src/components/UserFooter.tsx | 9 +++- src/renderer/src/state/store.ts | 26 +++++++++- src/shared/wire.ts | 6 +++ 7 files changed, 138 insertions(+), 35 deletions(-) diff --git a/nobilis b/nobilis index af11ded..89b180e 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit af11ded9236ce9923e4b72eefb072eaa0a132088 +Subproject commit 89b180ebd656b7425397b15a851dd1ef0c2dc7aa diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 0707182..c322a99 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -2893,6 +2893,23 @@ img.smilie { background: var(--surface-hover); } +/* A request is a row you answer rather than open, so its buttons sit in it. */ +.friend-row.request { + display: flex; + align-items: center; + gap: var(--spacing-s); + cursor: default; +} + +.friend-row.request .button { + flex: none; + margin-left: auto; +} + +.friend-row.request .button + .button { + margin-left: 0; +} + .friend-row .ellipsis:first-of-type { flex: 1; } diff --git a/src/renderer/src/components/Composer.tsx b/src/renderer/src/components/Composer.tsx index f76bdbe..f50a17a 100644 --- a/src/renderer/src/components/Composer.tsx +++ b/src/renderer/src/components/Composer.tsx @@ -181,6 +181,36 @@ export function Composer(): JSX.Element | null { [mention, mentionTargets] ) + // What has been typed as a command, if anything: a slash at the very start + // and the word after it. Not mid-message - "and/or" is not a command. + const typedCommand = service === 'discord' && /^\/\S*$/.test(text) ? text.slice(1) : null + + useEffect(() => { + if (typedCommand === null || !buffer) { + setCommands([]) + return + } + const timer = setTimeout(() => { + void window.moho + .rpc<{ commands: DiscordCommand[] }>('listDiscordCommands', { + bufferId: buffer.id, + query: typedCommand + }) + .then((answer) => { + setCommands(answer.commands || []) + setCommandIndex(0) + }) + .catch(() => { + // A channel with no bots answers nothing useful, and a list that + // could not be read is a list that stays closed rather than a + // complaint about typing a slash. + setCommands([]) + }) + }, 200) + return () => clearTimeout(timer) + }, [typedCommand, buffer?.id]) + + if (!buffer) return null /** @@ -292,35 +322,6 @@ export function Composer(): JSX.Element | null { }) } - // What has been typed as a command, if anything: a slash at the very start - // and the word after it. Not mid-message - "and/or" is not a command. - const typedCommand = service === 'discord' && /^\/\S*$/.test(text) ? text.slice(1) : null - - useEffect(() => { - if (typedCommand === null) { - setCommands([]) - return - } - const timer = setTimeout(() => { - void window.moho - .rpc<{ commands: DiscordCommand[] }>('listDiscordCommands', { - bufferId: buffer!.id, - query: typedCommand - }) - .then((answer) => { - setCommands(answer.commands || []) - setCommandIndex(0) - }) - .catch(() => { - // A channel with no bots answers nothing useful, and a list that - // could not be read is a list that stays closed rather than a - // complaint about typing a slash. - setCommands([]) - }) - }, 200) - return () => clearTimeout(timer) - }, [typedCommand, buffer?.id]) - /** * Runs the chosen command, with whatever was typed after it as its * arguments, in the order the command declares them. diff --git a/src/renderer/src/components/JoinPanel.tsx b/src/renderer/src/components/JoinPanel.tsx index ab8be6b..f55929c 100644 --- a/src/renderer/src/components/JoinPanel.tsx +++ b/src/renderer/src/components/JoinPanel.tsx @@ -664,7 +664,28 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element { useEffect(refreshFriends, [account.id]) - const shown = onlineOnly ? friends.filter((f) => f.status && f.status !== 'offline') : friends + // And whatever has changed since this was asked. A request that arrives + // while this panel is open is exactly the one somebody is waiting for, so + // it appears rather than waiting for the refresh button. + const live = useChat((s) => s.discordFriends)[account.id] + useEffect(() => { + if (live) setFriends(live) + }, [live]) + + // Requests are their own list above the friends. They are not people you + // can message and they are the thing somebody opened this panel to deal + // with, so filtering them by whether they happen to be online - which is + // what the toggle below does - would hide the ones that matter most. + const waiting = friends.filter((f) => f.kind === 'incoming' || f.kind === 'outgoing') + const settled = friends.filter((f) => !f.kind || f.kind === 'friend') + const shown = onlineOnly ? settled.filter((f) => f.status && f.status !== 'offline') : settled + + const answer = (userId: string, accept: boolean): void => { + void window.moho + .rpc('answerDiscordFriendRequest', { accountId: account.id, userId, accept }) + .then(refreshFriends) + .catch((e: Error) => store.toast('error', e.message)) + } return (
@@ -703,6 +724,37 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
+ {waiting.length > 0 && ( + <> +
+ Requests +
+ {waiting.map((f) => ( +
+ {f.avatarUrl ? ( + + ) : ( + {(f.globalName || f.username).slice(0, 1)} + )} + {f.globalName || f.username} + + {f.kind === 'incoming' ? 'wants to be friends' : 'asked, waiting'} + + {f.kind === 'incoming' && ( + + )} + {/* Refusing one and taking back your own are the same call, and + read differently enough to be worth different words. */} + +
+ ))} + + )} +
Friends