- 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]>
1063 lines
51 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|