diff --git a/nobilis b/nobilis index ffa406a..a59b355 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit ffa406a2adda9be67dad734f6f54fe862eccf197 +Subproject commit a59b355664a11a23192b8944e947c5820ac37eef diff --git a/src/main/index.ts b/src/main/index.ts index a0b1207..3cb902b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -546,6 +546,17 @@ function wireIpc(): void { if (/^(https?|mailto):/i.test(url)) shell.openExternal(url) }) + // Where to write something the app is about to produce - a key export, so + // far. Separate from pickFile because the two dialogs ask opposite + // questions, and a save dialog that cannot name a default file is a save + // dialog people cancel. + ipcMain.handle(IPC.pickSavePath, async (e, suggested?: string) => { + const parent = callerWindow(e) ?? mainWindow + if (!parent) return null + const res = await dialog.showSaveDialog(parent, { defaultPath: suggested }) + return res.canceled || !res.filePath ? null : res.filePath + }) + ipcMain.handle(IPC.pickFile, async (e) => { const parent = callerWindow(e) ?? mainWindow if (!parent) return null diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a9e305..602b280 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -105,6 +105,8 @@ const api = { pathForFile: (file: File): string => webUtils.getPathForFile(file), pickFile: (): Promise => ipcRenderer.invoke(IPC.pickFile), + pickSavePath: (suggested?: string): Promise => + ipcRenderer.invoke(IPC.pickSavePath, suggested), pickDirectory: (): Promise => ipcRenderer.invoke(IPC.pickDirectory), importGroupIcon: (groupId: string): Promise<{ path?: string; error?: string }> => ipcRenderer.invoke(IPC.importGroupIcon, groupId), diff --git a/src/renderer/src/components/ConversationTools.tsx b/src/renderer/src/components/ConversationTools.tsx index 279e3d8..9332ca7 100644 --- a/src/renderer/src/components/ConversationTools.tsx +++ b/src/renderer/src/components/ConversationTools.tsx @@ -156,6 +156,96 @@ function winning(card: LiveCard): string { return best ? `${best.label} (${best.votes})` : 'no answers' } +/** + * Which conversations a room has going on beside the main one. + * + * Asked of the server rather than assembled from scrollback: a thread whose + * root has scrolled past is unreachable otherwise, and that is exactly the + * thread somebody is looking for - the one they were part of yesterday. + */ +function ThreadList({ 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: ThreadSummary[] }>('listMatrixThreads', { bufferId: buffer.id, limit: 25 }) + .then((answer) => setRows(answer.threads)) + .catch((e: Error) => { + store.toast('error', e.message) + setRows([]) + }) + } + + return ( + <> + + + + {open && ( + setOpen(false)}> +
Threads, most recent first
+ {rows === null &&
Looking…
} + {rows?.length === 0 &&
No threads in this room yet.
} + {rows?.map((thread) => ( + + ))} +
+ )} + + ) +} + +/** One thread as the room's own list describes it. */ +interface ThreadSummary { + rootId: string + from: string + body: string + ts: number + replies: number + joined: boolean + lastFrom?: string | null + lastBody?: string | null + lastTs?: number | null +} + /** * What a room has told everyone to read first. * @@ -492,6 +582,7 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem there is no friend list to choose from and nothing to look up. */} {buffer.accountId.startsWith('matrix:') && } + {isMatrix && buffer.kind !== 'server' && } {isMatrix && buffer.kind !== 'server' && } {/* What this channel has asked before now. Two buttons rather than one diff --git a/src/renderer/src/components/MatrixAccountTools.tsx b/src/renderer/src/components/MatrixAccountTools.tsx index ff278d2..5e75d13 100644 --- a/src/renderer/src/components/MatrixAccountTools.tsx +++ b/src/renderer/src/components/MatrixAccountTools.tsx @@ -1,8 +1,17 @@ import { useEffect, useState } from 'react' import { Icon } from './Icon' import { useChat, useStore } from '../state/hooks' +import { resolveMediaUrl } from '../lib/util' import type { Account, MatrixDevice } from '../../../shared/wire' +/** What the homeserver says this account is called and looks like. */ +interface OwnProfile { + accountId: string + userId: string + displayName: string + avatarUrl?: string | null +} + /** Where an account stands on cross-signing, as the daemon reports it. */ interface CrossSigningStatus { accountId: string @@ -41,6 +50,10 @@ export function MatrixAccountTools({ account }: { account: Account }): JSX.Eleme const [deleting, setDeleting] = useState(null) const [deletePassword, setDeletePassword] = useState('') const [crossSigning, setCrossSigning] = useState(null) + const [profile, setProfile] = useState(null) + const [profileName, setProfileName] = useState('') + const [keyPassphrase, setKeyPassphrase] = useState('') + const [keyBusy, setKeyBusy] = useState(false) const [bootstrapPassword, setBootstrapPassword] = useState('') const [bootstrapping, setBootstrapping] = useState(false) @@ -53,6 +66,13 @@ export function MatrixAccountTools({ account }: { account: Account }): JSX.Eleme .then(setDevices) .catch((e: Error) => store.toast('error', e.message)) .finally(() => setLoading(false)) + void window.moho + .rpc('matrixOwnProfile', { accountId: account.id }) + .then((answer) => { + setProfile(answer) + setProfileName(answer.displayName || '') + }) + .catch(() => setProfile(null)) void window.moho .rpc<{ users: string[] }>('listMatrixIgnored', { accountId: account.id }) .then((answer) => store.noteIgnored(account.id, answer.users)) @@ -106,6 +126,59 @@ export function MatrixAccountTools({ account }: { account: Account }): JSX.Eleme + {/* Who everybody else sees. Separate from the display name in the + account card above it, and the difference is worth stating: that + one is what moho calls this account, this one is what every room + you are in shows. */} +
+
+
Your profile on {account.id.split(':').slice(1).join(':')}
+
Changing these changes them for everyone.
+
+
+
+ {profile?.avatarUrl ? ( + + ) : ( + + )} +
+ setProfileName(e.target.value)} + /> +
+ + +
+ {/* Setting it up, where there is nothing to set up against. Never offered as a reset: replacing an existing identity un-verifies every device you have, everywhere, for everyone. */} @@ -178,6 +251,78 @@ export function MatrixAccountTools({ account }: { account: Account }): JSX.Eleme

)} + {/* A copy of the room keys that does not depend on the homeserver. + Beside the server-side backup rather than instead of it: one + survives losing every device, the other survives losing the + server. */} +
+
+
Key file
+
+ The encrypted export Element writes, for moving history to a client that cannot reach + the backup - or for keeping a copy of your own. +
+
+
+
+ +
+ setKeyPassphrase(e.target.value)} + /> +
+ + +
+ {/* Who this account has asked never to hear from, and the way back. Listed here rather than only offered from a message, because the person you want to un-ignore is by definition somebody whose diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 733eba1..504bba3 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -10,6 +10,7 @@ export const IPC = { windowIsMaximized: 'moho:window:isMaximized', openExternal: 'moho:openExternal', pickFile: 'moho:pickFile', + pickSavePath: 'moho:pickSavePath', pickDirectory: 'moho:pickDirectory', downloadMedia: 'moho:downloadMedia', importGroupIcon: 'moho:importGroupIcon',