Files
dms-chat/views/AccountsPanel.qml
SalastilandClaude Sonnet 5 20389e7396 Add Matrix verification/backup, per-protocol join pages, Discord friends, and expired-media/auth-failure recovery
- Matrix: interactive device verification (SAS emoji flow), recovery-key
  backup/restore, membership announcement moderation, member/room avatars,
  power-level-aware moderation menu, and an encryption lock indicator on
  the composer - verified live with two independent devices.
- Replace the single combined join panel with a dedicated page per
  protocol (IrcJoinPanel/MatrixJoinPanel/DiscordJoinPanel), routed by
  whichever account the "+" button was clicked for rather than listing
  every account in one expandable page.
- Discord: friends list (Online/All, click-to-DM, add-friend, matches
  READY's relationships + live PRESENCE_UPDATE), create-a-server, and
  join-by-invite alongside the existing DM-by-ID flow.
- Discord: an expired attachment-link fallback ("Open in Discord") for
  scrollback media whose signed cdn.discordapp.com URL has lapsed -
  Discord's own /attachments/refresh-urls re-sign endpoint hard-rejects
  this backend's user-token requests, confirmed live, so this opens the
  real message instead of showing a broken embed.
- Discord: detect gateway close code 4004 (revoked token) as a distinct
  ConnState::AuthFailed instead of retrying a dead credential forever;
  the Accounts pane shows a clear "needs login" state that routes
  straight to a fresh QR login, which upserts the same account id in
  place (no lost buffers/scrollback).
- Fix the unread-DM pinned-buffer flash never reliably triggering: an
  implicit property binding computed via nested closures over two
  separate properties wasn't consistently re-evaluated by QML's
  dependency tracker; replaced with an explicit recompute function called
  at every actual mutation point.
- Fix ChatBarWidget.qml's pill double-padding against BasePill's own
  padding.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-21 12:30:46 -04:00

1063 lines
51 KiB
QML

import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
import "../forms"
// Shown as the buffer/message view's zero-accounts empty state, and also
// reachable any time as its own view via the header's overflow menu ->
// Accounts - hence the existing-accounts list below, not just the
// service picker. This is the frontend's actual first-run identity: Dank
// Chat is a unified frontend to multiple services (see the project
// README/plan), not an IRC client with other services bolted on - so the
// "add an account" section leads with a service picker, not an IRC
// connection form. IRC is the only service actually wired up in chatd
// today; the other entries are shown disabled rather than omitted so the
// "one frontend, many services" framing is true from the first screen a
// new user sees, not just an aspiration in project docs.
ColumnLayout {
id: root
signal connectRequested(string nick, string host, int port, bool ssl)
signal connectXmppRequested(string jid, string password)
signal connectDiscordRequested()
signal connectSockChatRequested(string username, string password, string totpSecret)
signal connectMatrixRequested(string homeserverUrl, string username, string password)
signal connectionToggleRequested(string accountId, bool connect)
signal autojoinChanged(string accountId, string channelsCsv)
signal nickservPasswordChanged(string accountId, string password)
signal displayNameChanged(string accountId, string name)
signal saslChanged(string accountId, bool enabled, string saslUser, string saslPassword, bool allowPlaintext)
signal useTorChanged(string accountId, bool useTor, string proxy)
signal accountRemoveRequested(string accountId)
// Matrix session verification (SAS) + recovery key + device removal -
// all inherently per-account, so this lives here rather than a global
// settings category. See backend/matrix/verification.rs's/backup.rs's
// own doc comments for what's driving each of these on the other end.
signal matrixRefreshDevicesRequested(string accountId)
signal matrixVerifyDeviceRequested(string accountId, string deviceId)
signal matrixRespondVerificationRequested(string accountId, string verificationId, bool accept)
signal matrixConfirmVerificationRequested(string accountId, string verificationId, bool matches)
signal matrixCancelVerificationRequested(string accountId, string verificationId)
signal matrixDeleteDeviceRequested(string accountId, string deviceId, string password)
signal matrixSetupRecoveryKeyRequested(string accountId)
signal matrixRestoreRecoveryKeyRequested(string accountId, string recoveryKey)
property var accounts: []
property var buffers: []
property string selectedService: "irc"
property var expandedAccounts: ({})
property string discordQrPath: ""
property string discordLoginStatus: ""
property string sockChatLoginStatus: ""
property string matrixLoginStatus: ""
// {accountId: [{deviceId, displayName, verified}]}/{accountId: bool} -
// fetched on-demand when a Matrix account's row is expanded, not
// eagerly for every account (see toggleAccountExpanded below).
property var matrixDevicesByAccount: ({})
property var matrixDevicesLoadingByAccount: ({})
// {accountId, verificationId, state, otherDevice, weStarted, emoji,
// deviceId} or null - one at a time, matching the backend's own v1
// limit (see verification.rs's start_verification doc comment).
property var matrixActiveVerification: null
// The just-generated key - shown exactly once, see backup.rs's
// setup_recovery_key doc comment on why it's never re-shown.
property string matrixRecoveryKey: ""
property bool matrixRecoveryKeyPending: false
property string matrixRestoreStatusText: ""
property bool matrixRestorePending: false
property string matrixDeviceDeleteError: ""
function toggleAccountExpanded(accountId) {
const e = Object.assign({}, root.expandedAccounts);
const expanding = !e[accountId];
e[accountId] = expanding;
root.expandedAccounts = e;
if (expanding) {
const acct = root.accounts.find(a => a.id === accountId);
if (acct && acct.service === "matrix")
root.matrixRefreshDevicesRequested(accountId);
}
}
// Same persisted key BufferList.qml's right-click "Hide" writes to -
// this is the only UI that reads it back and lets you undo it. Both
// components stay in sync purely through PluginService's own
// change-notification convention (see the Connections block below),
// not any signal threaded through ChatView.qml.
property var hiddenBuffers: PluginService.loadPluginData("dankChat", "hiddenBuffers", [])
function unhideBuffer(bufferId) {
const current = root.hiddenBuffers.slice();
const idx = current.indexOf(bufferId);
if (idx === -1)
return;
current.splice(idx, 1);
root.hiddenBuffers = current;
PluginService.savePluginData("dankChat", "hiddenBuffers", current);
}
function hiddenChannelsFor(accountId) {
return root.buffers.filter(b => b.accountId === accountId && (b.kind === "channel" || b.kind === "dm") && root.hiddenBuffers.indexOf(b.id) !== -1);
}
// Same "GuildName/#channel" -> "#channel" strip as BufferList.qml's own
// displayName() - a hidden Discord channel should read the same way
// here as it did in the sidebar before it was hidden.
function channelDisplayName(name) {
const idx = name.lastIndexOf("/");
return idx === -1 ? name : name.slice(idx + 1);
}
Connections {
target: PluginService
function onPluginDataChanged(changedPluginId) {
if (changedPluginId === "dankChat")
root.hiddenBuffers = PluginService.loadPluginData("dankChat", "hiddenBuffers", []);
}
}
// One entry per service actually planned - not just whatever
// happened to come bundled in the vendored libpurple tree. Bonjour,
// Gadu-Gadu, GroupWise/Novell, IBM Sametime, SILC (both variants),
// SIMPLE/SIP, and Zephyr were listed here briefly but are explicitly
// out of scope: no plans to support them. A flat list of large
// buttons stopped making sense once this stretched past a handful of
// entries, hence the searchable dropdown below instead of a button
// row - this array is the only thing that needs to grow as more
// backends come online, nothing about the picker itself changes.
// "available" flips to true once a service's account-creation flow
// actually exists in chatd (see actions.c).
readonly property var services: [
{ "id": "irc", "label": "IRC", "available": true },
{ "id": "jabber", "label": "XMPP / Jabber", "available": true },
{ "id": "discord", "label": "Discord", "available": true },
{ "id": "sockchat", "label": "Sneedchat", "available": true },
{ "id": "matrix", "label": "Matrix", "available": true },
{ "id": "slack", "label": "Slack", "available": false }
]
function serviceLabel(service) {
return service.available ? service.label : service.label + " (soon)";
}
function serviceById(id) {
for (const s of root.services) {
if (s.id === id)
return s;
}
return null;
}
spacing: Theme.spacingM
// Add-account comes first, right under the panel's own title bar - a
// fixed-size section regardless of how many accounts exist, so it
// can't get pushed further and further down the panel as the "Your
// accounts" list below it grows.
// Layout.fillHeight explicitly false on every top-level child below -
// this environment's ColumnLayout apparently treats children as
// fill-eligible by default (the same surprising behavior already hit
// twice elsewhere: invisible siblings still reserving fillHeight-
// shared space, and still reserving their natural height even with
// fillHeight off). Left implicit, AccountsPanel's own (stretched-tall,
// via the popout's much taller window) height was being spread out
// evenly across all these otherwise-fixed-size children instead of
// leaving the leftover space alone below the last one.
StyledText {
Layout.fillWidth: true
Layout.fillHeight: false
text: "Add an account"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
Layout.fillWidth: true
Layout.fillHeight: false
text: "Dank Chat is one frontend for multiple chat services. IRC works today; more services connect the same way once their backends land."
wrapMode: Text.WordWrap
color: Theme.surfaceVariantText
}
DankDropdown {
id: serviceDropdown
Layout.fillWidth: true
Layout.fillHeight: false
text: "Service"
enableFuzzySearch: root.services.length > 6
options: root.services.map(s => root.serviceLabel(s))
currentValue: {
const svc = root.serviceById(root.selectedService);
return svc ? root.serviceLabel(svc) : "";
}
onValueChanged: newValue => {
const match = root.services.find(s => root.serviceLabel(s) === newValue);
if (match)
root.selectedService = match.id;
}
}
StyledText {
Layout.fillWidth: true
Layout.fillHeight: false
visible: {
const svc = root.serviceById(root.selectedService);
return svc !== null && !svc.available;
}
text: "Not connected yet - this backend isn't wired up here, same as the other services listed above."
wrapMode: Text.WordWrap
color: Theme.surfaceVariantText
}
QuickConnectForm {
Layout.fillWidth: true
Layout.fillHeight: false
visible: root.selectedService === "irc"
onConnectRequested: (nick, host, port, ssl) => root.connectRequested(nick, host, port, ssl)
}
XmppConnectForm {
Layout.fillWidth: true
Layout.fillHeight: false
visible: root.selectedService === "jabber"
onConnectRequested: (jid, password) => root.connectXmppRequested(jid, password)
}
DiscordConnectForm {
Layout.fillWidth: true
Layout.fillHeight: false
visible: root.selectedService === "discord"
qrPath: root.discordQrPath
statusText: root.discordLoginStatus
onConnectRequested: root.connectDiscordRequested()
}
SockChatConnectForm {
Layout.fillWidth: true
Layout.fillHeight: false
visible: root.selectedService === "sockchat"
statusText: root.sockChatLoginStatus
onConnectRequested: (username, password, totpSecret) => root.connectSockChatRequested(username, password, totpSecret)
}
MatrixConnectForm {
Layout.fillWidth: true
Layout.fillHeight: false
visible: root.selectedService === "matrix"
statusText: root.matrixLoginStatus
onConnectRequested: (homeserverUrl, username, password) => root.connectMatrixRequested(homeserverUrl, username, password)
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: false
height: 1
color: Theme.outlineMedium
visible: root.accounts.length > 0
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: false
spacing: Theme.spacingXS
visible: root.accounts.length > 0
StyledText {
Layout.fillWidth: true
text: "Your accounts"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
}
Repeater {
model: root.accounts
ColumnLayout {
id: acctBlock
required property var modelData
// Local edit state for the two SASL toggles - DankToggle
// doesn't self-mutate `checked` on click (see handleClick()
// in DankToggle.qml, which only emits signals), so a plain
// `checked: modelData.saslEnabled` binding would never
// visibly move without something to actually update on
// toggle. Resets from modelData whenever the accounts list
// is refreshed wholesale, same as the plain text fields
// below already do.
property bool saslEnabledEdit: modelData.saslEnabled || false
property bool allowPlaintextEdit: modelData.allowPlaintextSasl || false
property bool useTorEdit: modelData.useTor || false
// Deleting an account is permanent (removes stored
// credentials, chatd has no undo) - a plain inline
// "click again to confirm" rather than a full modal
// dialog, since that's enough friction to stop an
// accidental click without the complexity of getting a
// DankModal's layering right from inside a composite
// plugin's own panel.
property bool confirmingDelete: false
Layout.fillWidth: true
spacing: Theme.spacingXXS
Rectangle {
id: acctRow
Layout.fillWidth: true
Layout.preferredHeight: 40
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingXS
Rectangle {
width: 8
height: 8
radius: 4
color: {
if (acctBlock.modelData.state === "connected")
return Theme.primary;
if (acctBlock.modelData.state === "auth_failed")
return Theme.error;
return Theme.outlineMedium;
}
}
StyledText {
Layout.fillWidth: true
text: acctBlock.modelData.displayName + " (" + acctBlock.modelData.service.toUpperCase() + ")"
color: Theme.surfaceText
wrapMode: Text.NoWrap
elide: Text.ElideRight
}
// Click toggles connect/disconnect - connected or
// mid-connect requests a disconnect, disconnected
// requests a (re)connect. A stuck "connecting" is
// exactly this same click (see Runtime::disconnect
// in runtime.rs - it aborts the in-flight
// connection task directly when there's no Sender
// yet to gracefully QUIT with), it just wasn't
// obvious this was the way to break out of it.
Rectangle {
Layout.preferredWidth: stateLabel.implicitWidth + Theme.spacingM * 2
Layout.preferredHeight: 22
radius: Theme.cornerRadius
color: toggleHover.hovered ? Theme.surfaceHover : "transparent"
border.width: 1
border.color: {
if (acctBlock.modelData.state === "connecting")
return Theme.warning;
if (acctBlock.modelData.state === "auth_failed")
return Theme.error;
return Theme.outlineMedium;
}
HoverHandler {
id: toggleHover
onHoveredChanged: {
if (toggleHover.hovered) {
stateTooltipDelay.restart();
} else {
stateTooltipDelay.stop();
stateTooltip.hide();
}
}
}
Timer {
id: stateTooltipDelay
interval: 400
onTriggered: {
let text;
if (acctBlock.modelData.state === "connecting")
text = (acctBlock.modelData.detail ? acctBlock.modelData.detail + "\n" : "") + "Stuck? Click to cancel and try again";
else if (acctBlock.modelData.state === "connected")
text = "Click to disconnect";
else if (acctBlock.modelData.state === "auth_failed")
text = (acctBlock.modelData.error || "Login revoked") + "\nClick to log in again";
else
text = acctBlock.modelData.error ? ("Last error: " + acctBlock.modelData.error + "\nClick to reconnect") : "Click to reconnect";
stateTooltip.show(text, stateLabel, 0, 0, "top");
}
}
DankTooltipV2 {
id: stateTooltip
}
StyledText {
id: stateLabel
anchors.centerIn: parent
text: acctBlock.modelData.state === "auth_failed" ? "needs login" : acctBlock.modelData.state
color: {
if (acctBlock.modelData.state === "connecting")
return Theme.warning;
if (acctBlock.modelData.state === "auth_failed")
return Theme.error;
return Theme.surfaceVariantText;
}
font.pixelSize: Theme.fontSizeSmall
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
// Retrying with the exact same stored
// token can never succeed once Discord
// has revoked it (see runtime.rs's
// ConnState::AuthFailed doc comment) -
// route straight to a fresh QR login
// instead of the normal reconnect
// toggle. Scanning with the same real
// account upserts this exact entry in
// place (accounts.rs's add_discord),
// not a new/duplicate one.
if (acctBlock.modelData.state === "auth_failed" && acctBlock.modelData.service === "discord")
root.connectDiscordRequested();
else
root.connectionToggleRequested(acctBlock.modelData.id, acctBlock.modelData.state === "disconnected");
}
}
}
DankIcon {
name: root.expandedAccounts[acctBlock.modelData.id] ? "expand_less" : "tune"
size: Theme.iconSizeSmall
color: tuneHover.hovered ? Theme.primary : Theme.surfaceVariantText
HoverHandler {
id: tuneHover
}
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleAccountExpanded(acctBlock.modelData.id)
}
}
}
}
// Persistent, no-hover-needed error line - the tooltip
// above covers "why" while hovering, this covers it at a
// glance for anyone who wasn't already hovering when it
// failed (including "cancelled", from breaking out of a
// stuck connect via the toggle above).
StyledText {
Layout.fillWidth: true
Layout.leftMargin: Theme.spacingM
visible: acctBlock.modelData.state === "disconnected" && !!acctBlock.modelData.error
text: "Error: " + (acctBlock.modelData.error || "")
color: Theme.error
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// Distinct from the plain-error line above: this is a
// specific, actionable state (a revoked credential, not a
// network hiccup), so it gets its own clear explanation
// rather than a generic "Error: ..." - notably reassuring
// that reconnecting won't lose anything, since that's the
// first thing anyone seeing "needs login" would worry about.
StyledText {
Layout.fillWidth: true
Layout.leftMargin: Theme.spacingM
visible: acctBlock.modelData.state === "auth_failed"
text: "This login was revoked. Click \"needs login\" above (or use \"Add Discord Account\" below with the same account) to sign back in - your messages, buffers, and settings stay as they are."
color: Theme.error
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// Live "what's it actually doing right now" while
// connecting (see backend/irc.rs's report_progress calls -
// DNS/TCP/TLS, SASL, waiting for welcome, NickServ
// identify) - this is what actually answers "why/how is
// this stuck", updating in real time instead of only
// reporting a result once the whole attempt finally
// resolves one way or another.
StyledText {
Layout.fillWidth: true
Layout.leftMargin: Theme.spacingM
visible: acctBlock.modelData.state === "connecting" && !!acctBlock.modelData.detail
text: acctBlock.modelData.detail || ""
color: Theme.warning
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// Plain visibility toggle rather than a Popup - keeps this
// in the same input/rendering model as everything else in
// this panel instead of depending on a QtQuick.Controls
// Overlay, which this Quickshell window type may not
// reliably provide.
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Theme.spacingM
Layout.rightMargin: Theme.spacingM
visible: !!root.expandedAccounts[acctBlock.modelData.id]
spacing: Theme.spacingXS
StyledText {
Layout.fillWidth: true
Layout.topMargin: Theme.spacingXS
text: "Display name"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankTextField {
id: displayNameField
Layout.fillWidth: true
placeholderText: acctBlock.modelData.id
text: acctBlock.modelData.displayName !== acctBlock.modelData.id ? acctBlock.modelData.displayName : ""
}
DankToggle {
id: saslToggle
Layout.fillWidth: true
// DankToggle computes its own `height` correctly
// for two-line label+description text, but never
// sets `implicitHeight` - ColumnLayout sizing goes
// by Layout.preferredHeight/implicitHeight, not
// `height`, so without this it always allocated
// too little space here, letting the description
// text spill into whatever came next.
Layout.preferredHeight: height
visible: acctBlock.modelData.service === "irc"
text: "Authenticate with SASL"
description: "Takes effect on the next connect, not immediately if already connected"
checked: acctBlock.saslEnabledEdit
onToggled: isChecked => acctBlock.saslEnabledEdit = isChecked
}
StyledText {
Layout.fillWidth: true
visible: saslToggle.checked
text: "SASL username"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankTextField {
id: saslUserField
Layout.fillWidth: true
visible: saslToggle.checked
placeholderText: "blank = account nick"
text: acctBlock.modelData.saslUsername || ""
}
StyledText {
Layout.fillWidth: true
visible: saslToggle.checked
text: "SASL password"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankTextField {
id: saslPasswordField
Layout.fillWidth: true
visible: saslToggle.checked
echoMode: TextInput.Password
placeholderText: acctBlock.modelData.hasPassword ? "(saved - leave blank to keep)" : "required for SASL PLAIN"
}
DankToggle {
id: allowPlaintextToggle
Layout.fillWidth: true
Layout.preferredHeight: height
visible: saslToggle.checked
text: "Allow plaintext SASL over unencrypted connection"
description: "Off by default - sending SASL credentials without TLS is insecure"
checked: acctBlock.allowPlaintextEdit
onToggled: isChecked => acctBlock.allowPlaintextEdit = isChecked
}
StyledText {
Layout.fillWidth: true
visible: acctBlock.modelData.service === "irc"
text: "Autojoin channels"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankTextField {
id: autojoinField
Layout.fillWidth: true
visible: acctBlock.modelData.service === "irc"
placeholderText: "#channel1, #channel2"
text: acctBlock.modelData.autojoin || ""
}
DankToggle {
id: useTorToggle
Layout.fillWidth: true
Layout.preferredHeight: height
visible: acctBlock.modelData.service === "irc"
text: "Tunnel through Tor"
description: "Disabled by default. Uses an external SOCKS5 proxy (a system Tor daemon or Tor Browser you already have running) - see the Tor settings category. Takes effect on the next connect."
checked: acctBlock.useTorEdit
onToggled: isChecked => acctBlock.useTorEdit = isChecked
}
StyledText {
Layout.fillWidth: true
visible: acctBlock.modelData.service === "irc" && useTorToggle.checked
text: "SOCKS5 proxy"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankTextField {
id: torProxyField
Layout.fillWidth: true
visible: acctBlock.modelData.service === "irc" && useTorToggle.checked
placeholderText: acctBlock.modelData.torProxy || "127.0.0.1:9050"
text: ""
}
StyledText {
Layout.fillWidth: true
visible: acctBlock.modelData.service === "irc" && !saslToggle.checked
text: "NickServ auto-identify"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
// SASL already authenticates you with services once
// it succeeds - offering a manual NickServ IDENTIFY
// alongside it is redundant at best, confusing at
// worst, so the two are mutually exclusive here.
DankTextField {
id: nickservField
Layout.fillWidth: true
visible: acctBlock.modelData.service === "irc" && !saslToggle.checked
echoMode: TextInput.Password
placeholderText: acctBlock.modelData.hasNickservPassword ? "(saved - leave blank to keep)" : "password sent to NickServ on connect"
}
// Session verification (SAS) + recovery key + device
// removal - Matrix-only, and inherently per-account
// (this is why it lives here rather than in a global
// settings category - see verification.rs's/backup.rs's
// own doc comments for what's driving it).
ColumnLayout {
id: matrixSection
Layout.fillWidth: true
visible: acctBlock.modelData.service === "matrix"
spacing: Theme.spacingXS
readonly property var thisAccountVerification: (root.matrixActiveVerification && root.matrixActiveVerification.accountId === acctBlock.modelData.id) ? root.matrixActiveVerification : null
readonly property var devices: root.matrixDevicesByAccount[acctBlock.modelData.id] || []
readonly property bool devicesLoading: !!root.matrixDevicesLoadingByAccount[acctBlock.modelData.id]
Rectangle {
Layout.fillWidth: true
Layout.topMargin: Theme.spacingXS
height: 1
color: Theme.outlineMedium
}
StyledText {
Layout.fillWidth: true
text: "Sessions"
font.weight: Font.Medium
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: "Compare a short emoji sequence with another of your own logged-in sessions to verify it, or remove one you don't recognize."
wrapMode: Text.WordWrap
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
Rectangle {
id: incomingBanner
readonly property var v: matrixSection.thisAccountVerification
Layout.fillWidth: true
Layout.preferredHeight: incomingCol.implicitHeight + Theme.spacingM
visible: !!incomingBanner.v && incomingBanner.v.state === "requested" && !incomingBanner.v.weStarted
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
ColumnLayout {
id: incomingCol
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: Theme.spacingXS
StyledText {
Layout.fillWidth: true
text: "Incoming verification request from " + (incomingBanner.v ? incomingBanner.v.otherDevice : "")
wrapMode: Text.WordWrap
font.pixelSize: Theme.fontSizeSmall
}
RowLayout {
spacing: Theme.spacingS
DankButton {
text: "Decline"
onClicked: root.matrixRespondVerificationRequested(acctBlock.modelData.id, incomingBanner.v.verificationId, false)
}
DankButton {
text: "Accept"
onClicked: root.matrixRespondVerificationRequested(acctBlock.modelData.id, incomingBanner.v.verificationId, true)
}
}
}
}
StyledText {
Layout.fillWidth: true
visible: matrixSection.devicesLoading
text: "Loading sessions..."
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
visible: !matrixSection.devicesLoading && matrixSection.devices.length === 0
text: "No other sessions found."
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
visible: root.matrixDeviceDeleteError !== ""
text: "Failed to remove session: " + root.matrixDeviceDeleteError
wrapMode: Text.WordWrap
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
}
Repeater {
model: matrixSection.devices
ColumnLayout {
id: deviceRow
required property var modelData
property bool confirmingRemove: false
Layout.fillWidth: true
spacing: Theme.spacingXXS
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacingS
StyledText {
Layout.fillWidth: true
text: (deviceRow.modelData.displayName || deviceRow.modelData.deviceId) + (deviceRow.modelData.verified ? " ✓ verified" : "")
color: deviceRow.modelData.verified ? Theme.primary : Theme.surfaceText
elide: Text.ElideRight
}
DankButton {
readonly property bool isThisDevice: !!matrixSection.thisAccountVerification && matrixSection.thisAccountVerification.deviceId === deviceRow.modelData.deviceId
text: isThisDevice ? "Cancel" : "Verify"
enabled: !deviceRow.modelData.verified && (isThisDevice || !matrixSection.thisAccountVerification)
onClicked: {
if (isThisDevice)
root.matrixCancelVerificationRequested(acctBlock.modelData.id, matrixSection.thisAccountVerification.verificationId);
else
root.matrixVerifyDeviceRequested(acctBlock.modelData.id, deviceRow.modelData.deviceId);
}
}
DankButton {
text: deviceRow.confirmingRemove ? "Cancel" : "Remove"
onClicked: deviceRow.confirmingRemove = !deviceRow.confirmingRemove
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Theme.spacingM
visible: deviceRow.confirmingRemove
spacing: Theme.spacingXXS
StyledText {
Layout.fillWidth: true
text: "Enter your account password to remove this session:"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacingXS
DankTextField {
id: removePasswordField
Layout.fillWidth: true
echoMode: TextInput.Password
placeholderText: "Account password"
}
DankButton {
text: "Confirm"
enabled: removePasswordField.text.length > 0
onClicked: {
root.matrixDeleteDeviceRequested(acctBlock.modelData.id, deviceRow.modelData.deviceId, removePasswordField.text);
removePasswordField.text = "";
deviceRow.confirmingRemove = false;
}
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.topMargin: Theme.spacingS
height: 1
color: Theme.outlineMedium
}
StyledText {
Layout.fillWidth: true
text: "Recovery key"
font.weight: Font.Medium
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: "Backs up your room keys so a new session doesn't show \"unable to decrypt\" for history from before it joined."
wrapMode: Text.WordWrap
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
ColumnLayout {
Layout.fillWidth: true
visible: root.matrixRecoveryKey !== ""
spacing: Theme.spacingXXS
StyledText {
Layout.fillWidth: true
text: "Save this now - it won't be shown again:"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: keyText.implicitHeight + Theme.spacingM
color: Theme.surfaceContainerHigh
radius: Theme.cornerRadius
StyledText {
id: keyText
anchors.fill: parent
anchors.margins: Theme.spacingS
text: root.matrixRecoveryKey
wrapMode: Text.WrapAnywhere
font.family: "monospace"
}
}
DankButton {
text: "Copy"
onClicked: Quickshell.execDetached(["dms", "clipboard", "copy", root.matrixRecoveryKey])
}
}
DankButton {
visible: root.matrixRecoveryKey === ""
text: root.matrixRecoveryKeyPending ? "Setting up..." : (acctBlock.modelData.hasKeyBackup ? "Set up a new recovery key" : "Set up recovery key")
enabled: !root.matrixRecoveryKeyPending
onClicked: root.matrixSetupRecoveryKeyRequested(acctBlock.modelData.id)
}
StyledText {
Layout.fillWidth: true
Layout.topMargin: Theme.spacingS
text: "Restore from an existing recovery key"
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceVariantText
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacingXS
DankTextField {
id: restoreField
Layout.fillWidth: true
placeholderText: "Recovery key"
}
DankButton {
text: root.matrixRestorePending ? "Restoring..." : "Restore"
enabled: !root.matrixRestorePending && restoreField.text.length > 0
onClicked: {
root.matrixRestoreRecoveryKeyRequested(acctBlock.modelData.id, restoreField.text);
restoreField.text = "";
}
}
}
StyledText {
Layout.fillWidth: true
visible: root.matrixRestoreStatusText !== ""
text: root.matrixRestoreStatusText
wrapMode: Text.WordWrap
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
// Only the channels this account actually has hidden -
// populated via BufferList.qml's right-click "Hide"
// (see hiddenChannelsFor() above). Absent entirely when
// there's nothing hidden, rather than an always-shown
// "no hidden channels" placeholder.
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacingXXS
visible: root.hiddenChannelsFor(acctBlock.modelData.id).length > 0
StyledText {
Layout.fillWidth: true
Layout.topMargin: Theme.spacingXS
text: "Hidden"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
Repeater {
model: root.hiddenChannelsFor(acctBlock.modelData.id)
RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: Theme.spacingXS
StyledText {
Layout.fillWidth: true
text: root.channelDisplayName(parent.modelData.name)
color: Theme.surfaceText
wrapMode: Text.NoWrap
elide: Text.ElideRight
}
DankButton {
text: "Unhide"
onClicked: root.unhideBuffer(parent.modelData.id)
}
}
}
}
RowLayout {
Layout.alignment: Qt.AlignRight
Layout.bottomMargin: Theme.spacingXS
spacing: Theme.spacingS
DankButton {
text: acctBlock.confirmingDelete ? "Confirm delete?" : "Delete"
backgroundColor: acctBlock.confirmingDelete ? Theme.error : Theme.withAlpha(Theme.error, 0.15)
textColor: acctBlock.confirmingDelete ? "white" : Theme.error
onClicked: {
if (acctBlock.confirmingDelete) {
root.accountRemoveRequested(acctBlock.modelData.id);
acctBlock.confirmingDelete = false;
} else {
acctBlock.confirmingDelete = true;
confirmDeleteResetTimer.restart();
}
}
Timer {
id: confirmDeleteResetTimer
interval: 4000
onTriggered: acctBlock.confirmingDelete = false
}
}
DankButton {
text: "Save"
onClicked: {
root.displayNameChanged(acctBlock.modelData.id, displayNameField.text);
root.saslChanged(acctBlock.modelData.id, acctBlock.saslEnabledEdit, saslUserField.text, saslPasswordField.text, acctBlock.allowPlaintextEdit);
root.autojoinChanged(acctBlock.modelData.id, autojoinField.text);
if (acctBlock.modelData.service === "irc")
root.useTorChanged(acctBlock.modelData.id, acctBlock.useTorEdit, torProxyField.text);
if (!acctBlock.saslEnabledEdit && nickservField.text.length > 0)
root.nickservPasswordChanged(acctBlock.modelData.id, nickservField.text);
saslPasswordField.text = "";
nickservField.text = "";
torProxyField.text = "";
}
}
}
}
}
}
}
// Absorbs whatever leftover vertical space this panel ends up with
// (e.g. hosted in a much taller window than its own content needs) so
// every section above stays packed at natural size instead of that
// leftover space getting spread out between them - Layout.fillHeight:
// false on each section above plus pinning this panel's own height to
// implicitHeight (see its instantiation in ChatView.qml) apparently
// wasn't sufficient on its own in this environment, so this makes it
// unambiguous: exactly one item claims the extra space, and it isn't
// any of the visible content.
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}
// The emoji-comparison step of an in-progress SAS verification -
// driven by matrixActiveVerification, set from the "Verify" buttons
// above (see MatrixVerificationPopup.qml). A single shared instance
// rather than one per account row, matching the backend's own "only
// one active verification at a time" limit.
MatrixVerificationPopup {
id: verificationPopup
parent: root
x: Math.max(0, (root.width - width) / 2)
y: 40
onConfirmed: matches => {
if (root.matrixActiveVerification)
root.matrixConfirmVerificationRequested(root.matrixActiveVerification.accountId, root.matrixActiveVerification.verificationId, matches);
}
}
onMatrixActiveVerificationChanged: {
const v = root.matrixActiveVerification;
if (v && v.emoji && v.emoji.length > 0) {
verificationPopup.emoji = v.emoji;
verificationPopup.otherDevice = v.otherDevice || "";
if (!verificationPopup.visible)
verificationPopup.open();
} else if ((!v || !v.emoji || v.emoji.length === 0) && verificationPopup.visible) {
verificationPopup.close();
}
}
}