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',