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:
+1
-1
Submodule nobilis updated: ffa406a2ad...a59b355664
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user