Profile, threads and key files in the account panel

Your homeserver name and picture are editable where the local rename
already sits, with the difference between them said rather than left to be
guessed: one is what moho calls this account, the other is what every room
you are in shows.

A threads button lists what a room has going on beside the main
conversation, newest first, with the reply count and whether you are in it;
pressing one opens the thread panel that already existed.

Room keys export to and import from the encrypted file Element writes,
behind one passphrase field, with a save dialog added for it - the app had
only ever needed to open files before now.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-04 15:40:39 -04:00
co-authored by Claude Opus 5
parent 9eb3d33b5e
commit da09eed541
6 changed files with 251 additions and 1 deletions
+1 -1
Submodule nobilis updated: ffa406a2ad...a59b355664
+11
View File
@@ -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
+2
View File
@@ -105,6 +105,8 @@ const api = {
pathForFile: (file: File): string => webUtils.getPathForFile(file),
pickFile: (): Promise<string | null> => ipcRenderer.invoke(IPC.pickFile),
pickSavePath: (suggested?: string): Promise<string | null> =>
ipcRenderer.invoke(IPC.pickSavePath, suggested),
pickDirectory: (): Promise<string | null> => ipcRenderer.invoke(IPC.pickDirectory),
importGroupIcon: (groupId: string): Promise<{ path?: string; error?: string }> =>
ipcRenderer.invoke(IPC.importGroupIcon, groupId),
@@ -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<ThreadSummary[] | null>(null)
const button = useRef<HTMLSpanElement>(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 (
<>
<span ref={button} className="header-anchor">
<IconButton
name="forum"
title="Threads in this room"
className={open ? 'active' : undefined}
onClick={show}
/>
</span>
{open && (
<HeaderPopover anchor={button.current} width={400} onClose={() => setOpen(false)}>
<div className="small muted">Threads, most recent first</div>
{rows === null && <div className="small muted">Looking…</div>}
{rows?.length === 0 && <div className="small muted">No threads in this room yet.</div>}
{rows?.map((thread) => (
<button
key={thread.rootId}
type="button"
className="history-row"
onClick={() => {
void store.openThreadPanel(buffer.id, thread.rootId)
setOpen(false)
}}
>
<span className="small">
<span className="search-result-from">{thread.from}</span>{' '}
<span className="muted">
{thread.replies} {thread.replies === 1 ? 'reply' : 'replies'}
{/* Said because it is the difference between a thread that
happened and one you are in. */}
{thread.joined ? ' · you replied' : ''}
</span>
</span>
<span className="small ellipsis">{thread.body}</span>
{thread.lastBody && (
<span className="small muted ellipsis">
{thread.lastFrom}: {thread.lastBody}
</span>
)}
</button>
))}
</HeaderPopover>
)}
</>
)
}
/** 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:') && <InviteToRoom buffer={buffer} />}
{isMatrix && buffer.kind !== 'server' && <ThreadList buffer={buffer} />}
{isMatrix && buffer.kind !== 'server' && <PinnedMessages buffer={buffer} />}
{/* What this channel has asked before now. Two buttons rather than one
@@ -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<string | null>(null)
const [deletePassword, setDeletePassword] = useState('')
const [crossSigning, setCrossSigning] = useState<CrossSigningStatus | null>(null)
const [profile, setProfile] = useState<OwnProfile | null>(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<OwnProfile>('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
</button>
</div>
{/* 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. */}
<div className="setting-row">
<div className="setting-text">
<div>Your profile on {account.id.split(':').slice(1).join(':')}</div>
<div className="small muted">Changing these changes them for everyone.</div>
</div>
</div>
<div className="device-row">
{profile?.avatarUrl ? (
<img className="account-avatar" src={resolveMediaUrl(profile.avatarUrl)} alt="" />
) : (
<Icon name="account_circle" size={18} />
)}
<div className="field-row setting-text">
<input
className="text-field"
type="text"
placeholder={profile?.userId || 'Display name'}
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
/>
</div>
<button
type="button"
className="button subtle"
disabled={!profileName.trim() || profileName.trim() === profile?.displayName}
onClick={() =>
void rpc('setMatrixProfileName', { accountId: account.id, name: profileName.trim() })
.then(() => store.toast('info', 'Name changed'))
.then(refresh)
}
>
Save name
</button>
<button
type="button"
className="button subtle"
onClick={() =>
void window.moho.pickFile().then((path) => {
if (!path) return
return rpc('setMatrixProfileAvatar', { accountId: account.id, path })
.then(() => store.toast('info', 'Picture changed'))
.then(refresh)
})
}
>
Change picture
</button>
</div>
{/* 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
</p>
)}
{/* 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. */}
<div className="setting-row">
<div className="setting-text">
<div>Key file</div>
<div className="small muted">
The encrypted export Element writes, for moving history to a client that cannot reach
the backup - or for keeping a copy of your own.
</div>
</div>
</div>
<div className="device-row">
<Icon name="vpn_key" size={18} />
<div className="field-row setting-text">
<input
className="text-field"
type="password"
placeholder="Passphrase for the file"
value={keyPassphrase}
onChange={(e) => setKeyPassphrase(e.target.value)}
/>
</div>
<button
type="button"
className="button subtle"
disabled={!keyPassphrase || keyBusy}
onClick={() =>
void window.moho.pickSavePath('element-keys.txt').then((path) => {
if (!path) return
setKeyBusy(true)
return rpc('exportMatrixKeys', {
accountId: account.id,
path,
passphrase: keyPassphrase
})
.then((answer: { keys: number }) =>
store.toast('info', `Wrote ${answer.keys} room keys`)
)
.finally(() => setKeyBusy(false))
})
}
>
Export
</button>
<button
type="button"
className="button subtle"
disabled={!keyPassphrase || keyBusy}
onClick={() =>
void window.moho.pickFile().then((path) => {
if (!path) return
setKeyBusy(true)
return rpc('importMatrixKeys', {
accountId: account.id,
path,
passphrase: keyPassphrase
})
.then((answer: { imported: number; total: number }) =>
// Both numbers, because they differ and only the first
// answers "did that do anything".
store.toast('info', `Imported ${answer.imported} of ${answer.total} keys`)
)
.finally(() => setKeyBusy(false))
})
}
>
Import
</button>
</div>
{/* 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
+1
View File
@@ -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',