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 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: af11ded923...89b180ebd6
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<div className="panel join-panel">
|
||||
@@ -703,6 +724,37 @@ function DiscordJoin({ account }: { account: Account }): JSX.Element {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{waiting.length > 0 && (
|
||||
<>
|
||||
<div className="join-friends-header">
|
||||
<span className="setting-text">Requests</span>
|
||||
</div>
|
||||
{waiting.map((f) => (
|
||||
<div key={f.userId} className="friend-row request">
|
||||
{f.avatarUrl ? (
|
||||
<img className="friend-avatar" src={resolveMediaUrl(f.avatarUrl)} alt="" />
|
||||
) : (
|
||||
<span className="friend-avatar placeholder">{(f.globalName || f.username).slice(0, 1)}</span>
|
||||
)}
|
||||
<span className="ellipsis">{f.globalName || f.username}</span>
|
||||
<span className="small muted">
|
||||
{f.kind === 'incoming' ? 'wants to be friends' : 'asked, waiting'}
|
||||
</span>
|
||||
{f.kind === 'incoming' && (
|
||||
<button type="button" className="button" onClick={() => answer(f.userId, true)}>
|
||||
Accept
|
||||
</button>
|
||||
)}
|
||||
{/* Refusing one and taking back your own are the same call, and
|
||||
read differently enough to be worth different words. */}
|
||||
<button type="button" className="button subtle" onClick={() => answer(f.userId, false)}>
|
||||
{f.kind === 'incoming' ? 'Decline' : 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="join-friends-header">
|
||||
<span className="setting-text">Friends</span>
|
||||
<button
|
||||
|
||||
@@ -17,15 +17,20 @@ import type { Account } from '../../../shared/wire'
|
||||
* the plaque.
|
||||
*/
|
||||
|
||||
export type Status = 'online' | 'idle' | 'offline'
|
||||
export type Status = 'online' | 'idle' | 'dnd' | 'invisible' | 'offline'
|
||||
|
||||
const STATUSES: { id: Status; label: string; glyph: string }[] = [
|
||||
{ id: 'online', label: 'Online', glyph: 'circle' },
|
||||
{ id: 'idle', label: 'Idle', glyph: 'dark_mode' },
|
||||
{ id: 'dnd', label: 'Do not disturb', glyph: 'do_not_disturb_on' },
|
||||
// The one people want from a client that is not the service's own: still
|
||||
// connected, still reading, counted as away by everybody looking. Not the
|
||||
// same thing as the entry below, which actually disconnects.
|
||||
{ id: 'invisible', label: 'Invisible', glyph: 'visibility_off' },
|
||||
// Not a mood but an action: it signs the account out. Named for the state it
|
||||
// leaves you in rather than for the mechanism, since that is how it reads
|
||||
// beside the other two.
|
||||
{ id: 'offline', label: 'Offline', glyph: 'logout' }
|
||||
{ id: 'offline', label: 'Sign out', glyph: 'logout' }
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CustomEmoji,
|
||||
IncomingCall,
|
||||
DccTransfer,
|
||||
DiscordFriend,
|
||||
MatrixInvite,
|
||||
MatrixVerification,
|
||||
Member,
|
||||
@@ -388,6 +389,8 @@ export interface ChatState {
|
||||
/** Which messages each Matrix room has pinned, newest last. */
|
||||
/** Which messages a conversation has pinned, where the service says so. */
|
||||
pinnedMessages: Record<string, string[]>
|
||||
/** Friends and pending requests per Discord account, as they change. */
|
||||
discordFriends: Record<string, DiscordFriend[]>
|
||||
/** Who each Matrix account has asked never to hear from. */
|
||||
matrixIgnored: Record<string, string[]>
|
||||
/** Rooms each Matrix account has been invited to and not answered. */
|
||||
@@ -469,6 +472,7 @@ const INITIAL: ChatState = {
|
||||
hiddenPolls: [],
|
||||
reviewCard: null,
|
||||
pinnedMessages: {},
|
||||
discordFriends: {},
|
||||
matrixIgnored: {},
|
||||
matrixInvites: {},
|
||||
profile: null,
|
||||
@@ -1287,7 +1291,10 @@ export class ChatStore {
|
||||
* the kind of switch people press twice and then distrust. The status is
|
||||
* recorded either way and applied when the connection comes up.
|
||||
*/
|
||||
async setPresence(accountId: string, status: 'online' | 'idle' | 'offline'): Promise<void> {
|
||||
async setPresence(
|
||||
accountId: string,
|
||||
status: 'online' | 'idle' | 'dnd' | 'invisible' | 'offline'
|
||||
): Promise<void> {
|
||||
if (status === 'offline') return this.setAccountConnected(accountId, false)
|
||||
|
||||
const state = this.state.accounts.find((a) => a.id === accountId)?.state
|
||||
@@ -1302,7 +1309,10 @@ export class ChatStore {
|
||||
await this.setAccountConnected(accountId, true)
|
||||
}
|
||||
|
||||
async setAccountStatus(accountId: string, status: 'online' | 'idle'): Promise<void> {
|
||||
async setAccountStatus(
|
||||
accountId: string,
|
||||
status: 'online' | 'idle' | 'dnd' | 'invisible'
|
||||
): Promise<void> {
|
||||
try {
|
||||
await window.moho.rpc('setAccountStatus', { accountId, status })
|
||||
await this.refreshAccounts()
|
||||
@@ -1592,6 +1602,18 @@ export class ChatStore {
|
||||
if (!data.isOwn || !this.reconcileOwnEcho(data)) this.appendMessage(data.bufferId, data)
|
||||
break
|
||||
|
||||
// Friends, and requests either way, as they change. Discord announces
|
||||
// these rather than expecting a client to poll - which is what makes a
|
||||
// request that arrives while the panel is open appear in it.
|
||||
case 'discordFriends':
|
||||
this.set({
|
||||
discordFriends: {
|
||||
...this.state.discordFriends,
|
||||
[data.accountId as string]: (data.friends as DiscordFriend[]) || []
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
// Buttons and menus, which arrive a moment after the message they are
|
||||
// on: the row has to exist before they can be written onto it.
|
||||
case 'messageComponents':
|
||||
|
||||
@@ -523,6 +523,12 @@ export interface DiscordFriend {
|
||||
globalName?: string | null
|
||||
avatarUrl?: string | null
|
||||
status?: string
|
||||
/**
|
||||
* Whether this is a friend, somebody asking to be one, or somebody being
|
||||
* asked. The three are answered differently, and an incoming request that
|
||||
* is not shown is one that cannot be answered.
|
||||
*/
|
||||
kind?: 'friend' | 'incoming' | 'outgoing'
|
||||
}
|
||||
|
||||
/** One emoji in a Matrix SAS comparison. */
|
||||
|
||||
Reference in New Issue
Block a user