Files
dms-chat/views/ChatView.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

2302 lines
118 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Modals.FileBrowser
import qs.Services
import qs.Widgets
// Shared chat content, hosted by both the DankSlideout and the
// FloatingWindow (ChatPopoutWindow.qml) - see ChatDaemon.qml. Owns its own
// DankSocket connection to chatd, independent of ChatDaemon.qml's
// status-only socket (the same multi-connection-per-backend pattern DMS's
// own Services/DMSService.qml uses).
Item {
id: root
property bool inPopout: false
// Conditional, not unconditional: DankSlideout.qml's `content` Loader
// (confirmed by reading its source) is itself anchors.fill'd to its
// own container, but does NOT propagate that fill down to whatever
// item it loads - an unanchored loaded item just sits at its own
// implicit size instead of stretching, which is what was leaving
// ChatView far short of the slideout's actual content area. So this
// root needs anchors.fill: parent for the slideout case. But
// ChatPopoutWindow.qml instantiates this directly with its own
// explicit anchors (top/left/right/bottom) for the floating window,
// and an unconditional anchors.fill: parent here fights those (both
// bindings target the same anchor lines) - confirmed earlier as what
// was pulling header content up into the popout's title bar. Hence:
// fill only when not hosted in the popout, where inPopout: true is set
// and the instantiation site's own anchors take over instead.
anchors.fill: root.inPopout ? null : parent
implicitWidth: 480
implicitHeight: 640
signal hideRequested
signal popoutRequested
signal dockRequested
readonly property string runtimeDir: Quickshell.env("XDG_RUNTIME_DIR") || (Quickshell.env("HOME") + "/.local/state")
readonly property string socketPath: runtimeDir + "/dms-chat/chatd.sock"
property var accounts: []
property var buffers: []
// Persisted for the same reason sidebarFolded below is: closing the
// slideout/popout and reopening it later is a fresh ChatView instance,
// not just a hidden one, so without this it would always come back to
// no buffer selected. Restoring a stale id (the buffer no longer
// exists - account removed, channel left) is harmless: activeBuffer
// below just resolves to null and the existing empty-state view
// ("Select or join a channel") shows exactly as it would for "" today.
property string activeBufferId: PluginService.loadPluginData("dankChat", "ui.activeBufferId", "")
property var messagesByBuffer: ({})
property bool connected: false
property var pendingRequests: []
// Discord-style optimistic local echo: a locally-built message is
// appended to messagesByBuffer the instant the user hits send (dimmed,
// see MessageList.qml's contentRow.opacity), keyed by a client-
// generated id rather than any real server-assigned one (chatd hasn't
// seen it yet). Stays here, keyed by that same id, until either a real
// "message" event echoes it back (see the onMessage handler's
// reconcileOwnEcho) or sendTimeoutSweep gives up on it - removed only
// on the former, so a late echo can still resolve an already-timed-out
// entry instead of leaving a permanent phantom failure.
property var pendingSends: ({})
property int _sendSeq: 0
readonly property int sendTimeoutMs: 10000
// Folds away the account/buffer sidebar so only the message buffer
// itself is visible - separate from DankSlideout's own width toggle
// (ChatDaemon.qml), which only changes how WIDE the whole panel is.
// Persisted (not just in-memory) so closing the whole popout/slideout
// and reopening it later - a fresh ChatView instance, not just a
// hidden one - remembers whatever fold state was last left behind,
// same as every other durable display preference in this file.
property bool sidebarFolded: PluginService.loadPluginData("dankChat", "ui.sidebarFolded", false)
property bool userListFolded: PluginService.loadPluginData("dankChat", "ui.userListFolded", false)
onSidebarFoldedChanged: PluginService.savePluginData("dankChat", "ui.sidebarFolded", sidebarFolded)
onUserListFoldedChanged: PluginService.savePluginData("dankChat", "ui.userListFolded", userListFolded)
onActiveBufferIdChanged: {
PluginService.savePluginData("dankChat", "ui.activeBufferId", activeBufferId);
root.matrixRefreshRoomPermissionsIfNeeded();
}
// {bufferId: {canRedactOthers, canKick, canBan, canMute}} - Matrix
// rooms only, fetched once per buffer (permissions rarely change
// mid-session; a stale cache here is only ever advisory anyway, see
// backend/matrix/moderation.rs's own doc comment - the actual action
// still gets validated server-side regardless of what's cached).
property var matrixRoomPermissionsByBuffer: ({})
readonly property var activeBufferPermissions: root.matrixRoomPermissionsByBuffer[root.activeBufferId] || {}
function matrixRefreshRoomPermissionsIfNeeded() {
const buf = root.activeBuffer;
if (!buf || !!root.matrixRoomPermissionsByBuffer[buf.id])
return;
const acct = root.accounts.find(a => a.id === buf.accountId);
if (!acct || acct.service !== "matrix")
return;
send("getMatrixRoomPermissions", { "accountId": buf.accountId, "bufferId": buf.id }, "getMatrixRoomPermissions:" + buf.id);
}
function matrixKick(userId) {
if (root.activeBuffer)
send("kickMatrixMember", { "accountId": root.activeBuffer.accountId, "bufferId": root.activeBufferId, "userId": userId });
}
function matrixBan(userId) {
if (root.activeBuffer)
send("banMatrixMember", { "accountId": root.activeBuffer.accountId, "bufferId": root.activeBufferId, "userId": userId });
}
function matrixMute(userId) {
if (root.activeBuffer)
send("muteMatrixMember", { "accountId": root.activeBuffer.accountId, "bufferId": root.activeBufferId, "userId": userId });
}
// NickList.qml's "Open DM" action (presence/Matrix mode only) -
// creates or reuses a real 1:1 DM room with the clicked member (see
// backend::matrix::open_dm) and switches straight to it once the RPC
// resolves (see the "openMatrixDm" response handler below).
function matrixOpenDm(accountId, userId, displayName) {
send("openMatrixDm", { "accountId": accountId, "userId": userId, "displayName": displayName }, "openMatrixDm");
}
// The per-protocol join pages' (IrcJoinPanel/MatrixJoinPanel/
// DiscordJoinPanel) own actions - see each one's doc comment for why
// every service gets a different mechanism rather than one generic
// "join by name" field.
function matrixJoinRoom(accountId, roomIdOrAlias) {
send("joinMatrixRoom", { "accountId": accountId, "roomIdOrAlias": roomIdOrAlias }, "joinMatrixRoom");
}
function discordJoinGuild(accountId, invite) {
send("joinDiscordGuild", { "accountId": accountId, "invite": invite }, "joinDiscordGuild");
}
function discordOpenDm(accountId, userId) {
send("openDiscordDm", { "accountId": accountId, "userId": userId }, "openDiscordDm");
}
// Bubbled up from a MediaEmbed whose Discord attachment link has
// expired (see MediaEmbed.qml's own doc comment on why this backend
// can't just silently re-sign one) - opens the real message instead,
// in whatever client/browser handles discord.com links.
function openDiscordMessageLink(bufferId, messageId) {
send("getDiscordMessageLink", { "bufferId": bufferId, "messageId": messageId }, "getDiscordMessageLink");
}
// Presence (channel member list) per buffer - keyed by bufferId, same
// shape the wire contract's presenceChange event carries:
// [{nick, prefix, away}]. IRC-only for now (no other backend emits
// this event yet), but the shape itself has nothing IRC-specific in
// it, so NickList.qml doesn't need to know that.
property var presenceByBuffer: ({})
// Discord channel buffer names are "GuildName/#channel" internally
// (see backend/discord.rs and BufferList.qml's own copy of this same
// helper) - display just the "#channel" part everywhere a buffer name
// shows up outside the sidebar's own guild-grouped rows. Safe no-op
// for every other buffer kind, since only Discord guild channels ever
// contain "/".
function bufferDisplayName(name) {
const idx = name.lastIndexOf("/");
return idx === -1 ? name : name.slice(idx + 1);
}
readonly property var activeBuffer: root.buffers.find(b => b.id === root.activeBufferId) || null
readonly property string activeBufferKind: root.activeBuffer ? root.activeBuffer.kind : ""
readonly property var activeMembers: root.presenceByBuffer[root.activeBufferId] || []
// The account's own nick, derived from its id ("nick@host" - chatd's
// own IRC convention, see model.c's chatd_account_id/chatd_account_
// service) - used to find "my own" entry in activeMembers so the
// userlist context menu knows what rank I actually hold before
// offering moderator actions.
readonly property string activeOwnNick: {
if (!root.activeBuffer)
return "";
const acct = root.accounts.find(a => a.id === root.activeBuffer.accountId);
if (!acct || acct.service !== "irc")
return "";
return acct.id.split("@")[0];
}
// Matrix's own identity for the userlist - its full mxid, derived the
// same way every other Matrix account id is unwound elsewhere in this
// file (accounts.rs's account_id() is "matrix:" + user_id, see
// matrix_account_to_json) - matched against presenceChange's userId
// field (see backend/matrix/roomstate.rs's emit_matrix_presence),
// never nick/display name (which can collide or be renamed).
readonly property string activeOwnUserId: {
if (!root.activeBufferIsMatrix)
return "";
return root.activeBuffer.accountId.slice("matrix:".length);
}
readonly property string activeBufferGroupMode: root.activeBufferIsMatrix ? "presence" : "tier"
// Client-side ignore list ("<accountId>|<nick>") - IRC has no
// protocol-level block/ignore, so this is purely a local display
// filter (see visibleMessages below), not something chatd/the wire
// protocol needs to know about.
property var blockedNicks: PluginService.loadPluginData("dankChat", "blockedNicks", [])
// Discord-style "New" divider: per-buffer, persisted timestamp of the
// most recent message the user has actually seen - survives restarts,
// unlike `unread` (a purely in-memory session badge, reset to 0 on
// open and never itself a source of truth for where new content
// starts). `dividerTsByBuffer` is the actual line-placement value used
// by MessageList for whatever buffer is currently open: a snapshot of
// lastReadTs taken at the *moment of switching in*, deliberately not
// re-read afterward, so the line stays put while the buffer is open
// even as markBufferRead keeps lastReadTs itself advancing underneath -
// same as Discord's own line not chasing every new message while
// you're actively looking at the channel.
property var lastReadTs: PluginService.loadPluginData("dankChat", "lastReadTs", ({}))
property var dividerTsByBuffer: ({})
function markBufferRead(bufferId) {
const r = Object.assign({}, root.lastReadTs);
r[bufferId] = Math.floor(Date.now() / 1000) + 1;
root.lastReadTs = r;
PluginService.savePluginData("dankChat", "lastReadTs", r);
}
function isBlocked(accountId, nick) {
return root.blockedNicks.indexOf(accountId + "|" + nick) !== -1;
}
function toggleBlocked(accountId, nick) {
const key = accountId + "|" + nick;
const current = root.blockedNicks.slice();
const idx = current.indexOf(key);
if (idx === -1)
current.push(key);
else
current.splice(idx, 1);
root.blockedNicks = current;
PluginService.savePluginData("dankChat", "blockedNicks", current);
}
// Deliberately NOT registered as plugin.json's "settings" key, so it
// doesn't show up in DMS's global Settings -> Plugins accordion -
// reachable only from in here, behind the header's overflow menu.
// ChatSettings.qml isn't loaded through PluginsTab's usual injection
// path this way, so pluginService is wired up manually below.
// "" = normal chat view, "accounts" = account list/add panel,
// "settings" = ChatSettings, "join" = one of the per-protocol join
// pages below (see joinPanelAccount).
property string activePanel: ""
// Which account BufferList.qml's "+" button was clicked for - each
// protocol gets its own dedicated join page (IrcJoinPanel/
// MatrixJoinPanel/DiscordJoinPanel) rather than one page listing every
// account, so the Loader below needs to know which single account
// (and therefore which service) this "join" panel is for. Same
// .find()-over-a-property-read shape as BufferList.qml's own
// currentAccount, which this mirrors.
readonly property var joinPanelAccount: root.accounts.find(a => a.id === root.joinPanelAccountId) || null
property string joinPanelAccountId: ""
// Message-kind filters from ChatSettings.qml. chatd always emits and
// persists every message regardless of kind (see uiops_conv.c's
// classify_message_kind) - which of them to actually render is purely
// a frontend decision, so toggling a filter here takes effect
// immediately without needing chatd to reconnect or replay anything.
property var messageFilters: ({
"join": true,
"part": true,
"nick": true,
"topic": true,
"mode": true,
"matrixJoin": true,
"matrixInvite": true,
"matrixKick": true,
"matrixQuit": true
})
function loadMessageFilters() {
root.messageFilters = {
"join": PluginService.loadPluginData("dankChat", "irc.showJoinMessages", true),
"part": PluginService.loadPluginData("dankChat", "irc.showPartMessages", true),
"nick": PluginService.loadPluginData("dankChat", "irc.showNickChanges", true),
"topic": PluginService.loadPluginData("dankChat", "irc.showTopicChanges", true),
"mode": PluginService.loadPluginData("dankChat", "irc.showModeChanges", true),
"matrixJoin": PluginService.loadPluginData("dankChat", "matrix.showJoinMessages", true),
"matrixInvite": PluginService.loadPluginData("dankChat", "matrix.showInviteMessages", true),
"matrixKick": PluginService.loadPluginData("dankChat", "matrix.showKickMessages", true),
"matrixQuit": PluginService.loadPluginData("dankChat", "matrix.showQuitMessages", true)
};
}
// Cross-protocol display prefs from DisplaySettingsPanel.qml - unlike
// messageFilters above (IRC-only), these apply to every buffer
// regardless of backend, so they live directly on ChatView rather than
// behind a per-protocol loader.
property string messageDisplayMode: PluginService.loadPluginData("dankChat", "display.messageMode", "classic")
property bool mediaAutoplay: PluginService.loadPluginData("dankChat", "media.autoplay", true)
property bool mediaLoop: PluginService.loadPluginData("dankChat", "media.loop", true)
property bool relativeTimestamps: PluginService.loadPluginData("dankChat", "display.relativeTimestamps", false)
property bool contentSniffingEnabled: PluginService.loadPluginData("dankChat", "media.contentSniffing", true)
function loadDisplaySettings() {
root.messageDisplayMode = PluginService.loadPluginData("dankChat", "display.messageMode", "classic");
root.mediaAutoplay = PluginService.loadPluginData("dankChat", "media.autoplay", true);
root.mediaLoop = PluginService.loadPluginData("dankChat", "media.loop", true);
root.relativeTimestamps = PluginService.loadPluginData("dankChat", "display.relativeTimestamps", false);
root.contentSniffingEnabled = PluginService.loadPluginData("dankChat", "media.contentSniffing", true);
}
function visibleMessages(bufferId) {
const all = root.messagesByBuffer[bufferId] || [];
const buf = root.buffers.find(b => b.id === bufferId);
const accountId = buf ? buf.accountId : "";
return all.filter(m => {
if (accountId && root.isBlocked(accountId, m.from))
return false;
const kind = m.kind || "chat";
if (kind === "chat" || kind === "system")
return true;
return root.messageFilters[kind] !== false;
});
}
Component.onCompleted: root.loadMessageFilters()
Connections {
target: PluginService
function onPluginDataChanged(changedPluginId) {
if (changedPluginId === "dankChat") {
root.blockedNicks = PluginService.loadPluginData("dankChat", "blockedNicks", []);
root.loadMessageFilters();
root.loadDisplaySettings();
root.sidebarFolded = PluginService.loadPluginData("dankChat", "ui.sidebarFolded", false);
root.userListFolded = PluginService.loadPluginData("dankChat", "ui.userListFolded", false);
root.activeBufferId = PluginService.loadPluginData("dankChat", "ui.activeBufferId", "");
}
}
}
function send(method, params, id) {
const payload = {
"id": id || method,
"method": method,
"params": params || {}
};
// chatSocket.send() writes straight to the transport with no
// connected-check of its own - if the link isn't up yet (e.g. the
// panel just opened and the socket hasn't finished connecting),
// that write silently fails (Qt logs "QIODevice::write: device
// not open" but nothing surfaces to the UI) and the request is
// just gone. Queue instead and flush once linkUp actually fires.
if (chatSocket.linkUp) {
console.log("[dankChat/ChatView] send ->", method, JSON.stringify(params || {}));
chatSocket.send(payload);
} else {
console.log("[dankChat/ChatView] queued (socket not linked up yet) ->", method, JSON.stringify(params || {}));
root.pendingRequests = root.pendingRequests.concat([payload]);
}
}
function refreshAccounts() { send("listAccounts"); }
function refreshBuffers() { send("listBuffers"); }
function selectBuffer(bufferId) {
// Picking a buffer from the sidebar always means "show me that
// chat" - if Settings or Accounts was open, clicking a buffer
// should back out of it rather than leave the panel open behind
// the scenes with the sidebar now pointing at a buffer nothing is
// displaying.
root.activePanel = "";
// Guards against re-subscribing/re-fetching backlog for a buffer
// that's already the active one - BufferList.qml's click handler
// always fires bufferSelected regardless of current state, and
// without this, clicking an already-open buffer (or any other path
// that re-invokes this for the same id) re-fetches the same 200
// messages and appends them again, duplicating history. Combined
// with the id-based de-dup in the getBacklog handler below, this
// covers both "shouldn't have re-fetched at all" and "re-fetched
// anyway but the result didn't duplicate anything".
const alreadyActive = bufferId === root.activeBufferId;
if (!alreadyActive) {
// Mark whatever buffer is being left as read up to now (covers
// messages that arrived while it sat open this session), then
// snapshot the *incoming* buffer's prior read boundary before
// touching it - that snapshot is what actually places the line.
if (root.activeBufferId)
root.markBufferRead(root.activeBufferId);
const d = Object.assign({}, root.dividerTsByBuffer);
d[bufferId] = root.lastReadTs[bufferId] || 0;
root.dividerTsByBuffer = d;
}
root.activeBufferId = bufferId;
if (!root.messagesByBuffer[bufferId]) {
const m = Object.assign({}, root.messagesByBuffer);
m[bufferId] = [];
root.messagesByBuffer = m;
}
const bufs = root.buffers.map(b => b.id === bufferId ? Object.assign({}, b, { "unread": 0 }) : b);
root.buffers = bufs;
root.markBufferRead(bufferId);
root.fetchBufferEmoji(bufferId);
if (root.activeBufferIsSockchat)
root.fetchSockchatSmilies();
if (alreadyActive)
return;
send("subscribe", { "bufferId": bufferId });
send("getBacklog", { "bufferId": bufferId, "limit": 200 }, "getBacklog:" + bufferId);
}
// Per-buffer, not a single flag - more than one buffer's pagination
// could plausibly be mid-flight (unlikely in practice since only one
// is ever visible at a time, but MessageList.qml only reads whichever
// entry matches its own current bufferId, so this costs nothing to
// keep general). Cleared unconditionally once the response for that
// fetch comes back (see the getBacklog: handler below), whether or
// not it actually returned anything - a "we've reached the real
// beginning" empty response must not leave the flag stuck on forever.
property var loadingMoreBuffers: ({})
function loadMoreHistory(bufferId) {
if (root.loadingMoreBuffers[bufferId])
return;
const existing = root.messagesByBuffer[bufferId] || [];
if (existing.length === 0)
return; // nothing loaded yet to page "before"
const m = Object.assign({}, root.loadingMoreBuffers);
m[bufferId] = true;
root.loadingMoreBuffers = m;
send("getBacklog", { "bufferId": bufferId, "before": existing[0].ts, "limit": 100 }, "getBacklog:" + bufferId);
}
// Bounded rather than left to grow for as long as a buffer stays open
// and active - without this, a busy channel left running for days
// keeps every single message it's ever seen in memory forever, once
// per open ChatView instance (main screen + any popout). Trimming the
// oldest end is safe: getBacklog's own pagination (loadMoreHistory)
// re-fetches further history on demand if the user scrolls back up
// past what's still held here - it just means scrolling all the way
// up in a long-lived busy buffer re-fetches a bit of what was
// evicted, the same page-on-demand trade-off any bounded scrollback
// window makes.
readonly property int maxMessagesPerBuffer: 500
function appendMessage(bufferId, msg) {
const existing = root.messagesByBuffer[bufferId] || [];
// Defensive against duplicate delivery (e.g. a buffer subscribed
// to more than once) - every message carries a unique id, so a
// repeat is always safe to drop rather than show twice.
if (existing.some(m => m.id === msg.id))
return;
let list = existing.concat([msg]);
if (list.length > root.maxMessagesPerBuffer)
list = list.slice(list.length - root.maxMessagesPerBuffer);
const m = Object.assign({}, root.messagesByBuffer);
m[bufferId] = list;
root.messagesByBuffer = m;
if (bufferId !== root.activeBufferId) {
const bufs = root.buffers.map(b => b.id === bufferId ? Object.assign({}, b, { "unread": (b.unread || 0) + 1 }) : b);
root.buffers = bufs;
} else {
// Keep the persisted read boundary current while this buffer
// sits open and live traffic keeps arriving - without this, a
// long-open buffer's lastReadTs would go stale at whatever it
// was when the buffer was first selected, and everything seen
// live during this session would wrongly show as "New" again
// the next time the buffer is (re)opened. Doesn't touch
// dividerTsByBuffer, so the line itself stays put meanwhile.
root.markBufferRead(bufferId);
}
}
// Discord-native reply (message_reference) - set via right-click Reply
// on a Discord message, shown as a "Replying to X" bar above the
// input with a cancel affordance, cleared once the reply is actually
// sent (or cancelled). {id, from, body} or null.
property var replyingTo: null
function startReply(id, from, body) {
root.replyingTo = { "id": id, "from": from, "body": body };
inputField.forceActiveFocus();
}
function cancelReply() {
root.replyingTo = null;
}
// Best-effort "who am I in this buffer" for the optimistic echo below -
// same convention activeOwnNick already uses for IRC (nick parsed out
// of the account id), extended to Discord/Sneedchat's own displayName.
function ownDisplayNameFor(accountId) {
const acct = root.accounts.find(a => a.id === accountId);
if (!acct)
return "";
return acct.service === "irc" ? acct.id.split("@")[0] : (acct.displayName || acct.id);
}
function sendCurrentMessage(body) {
if (!body || !root.activeBufferId)
return;
const bufferId = root.activeBufferId;
const replyTo = root.replyingTo;
root.replyingTo = null;
root.queueOptimisticSend(bufferId, body, replyTo);
}
// Discord-style optimistic send: echoes the message into the buffer
// immediately (dimmed - see MessageList.qml's contentRow.opacity),
// then reconciles it against whatever really comes back - a genuine
// "message" event (onMessage's reconcileOwnEcho) replaces it in place,
// an RPC error or a 10s silence (sendTimeoutSweep) marks it failed
// with a Retry option instead.
function queueOptimisticSend(bufferId, body, replyTo) {
const buffer = root.buffers.find(b => b.id === bufferId);
const acct = buffer ? root.accounts.find(a => a.id === buffer.accountId) : null;
const clientId = "local-" + Date.now() + "-" + (root._sendSeq++);
const echo = {
"id": clientId,
"bufferId": bufferId,
"from": root.ownDisplayNameFor(buffer ? buffer.accountId : ""),
"body": body,
"ts": Math.floor(Date.now() / 1000),
"isOwn": true,
"isAction": false,
"isHighlight": false,
"kind": "chat",
"edited": false,
"avatarUrl": acct ? (acct.avatarUrl || "") : "",
"replyTo": replyTo,
"reactions": [],
"embeds": [],
"sendStatus": "pending"
};
appendMessage(bufferId, echo);
const pending = Object.assign({}, root.pendingSends);
pending[clientId] = { "bufferId": bufferId, "body": body, "replyToId": replyTo ? replyTo.id : undefined, "startedAt": Date.now() };
root.pendingSends = pending;
const params = { "bufferId": bufferId, "body": body };
if (replyTo)
params.replyToId = replyTo.id;
send("sendMessage", params, clientId);
}
// Re-sends a failed optimistic echo in place - same message row, same
// clientId, just a reset pending state and a fresh 10s window, rather
// than appending a second line for what the user sees as "the same
// message, try again".
function retrySend(clientId) {
const pending = root.pendingSends[clientId];
if (!pending)
return;
const existing = root.messagesByBuffer[pending.bufferId] || [];
const idx = existing.findIndex(m => m.id === clientId);
if (idx === -1)
return;
const list = existing.slice();
list[idx] = Object.assign({}, list[idx], { "sendStatus": "pending", "sendError": "" });
const m = Object.assign({}, root.messagesByBuffer);
m[pending.bufferId] = list;
root.messagesByBuffer = m;
const refreshed = Object.assign({}, root.pendingSends);
refreshed[clientId] = Object.assign({}, pending, { "startedAt": Date.now() });
root.pendingSends = refreshed;
const params = { "bufferId": pending.bufferId, "body": pending.body };
if (pending.replyToId)
params.replyToId = pending.replyToId;
send("sendMessage", params, clientId);
}
function markSendFailed(clientId, errorText) {
const pending = root.pendingSends[clientId];
if (!pending)
return;
const existing = root.messagesByBuffer[pending.bufferId] || [];
const list = existing.map(m => m.id === clientId ? Object.assign({}, m, { "sendStatus": "failed", "sendError": errorText || "" }) : m);
const m = Object.assign({}, root.messagesByBuffer);
m[pending.bufferId] = list;
root.messagesByBuffer = m;
}
// Called once per second while any send is still unresolved (see
// sendTimeoutSweep below) - anything that's been "pending" for 10s
// with no real echo or RPC error yet is assumed lost and shown failed,
// but stays in pendingSends so a late echo can still resolve it.
function sweepPendingSends() {
const now = Date.now();
for (const clientId in root.pendingSends) {
const pending = root.pendingSends[clientId];
if (now - pending.startedAt < root.sendTimeoutMs)
continue;
const existing = root.messagesByBuffer[pending.bufferId] || [];
const current = existing.find(m => m.id === clientId);
if (current && current.sendStatus === "pending")
root.markSendFailed(clientId, "timed out");
}
}
// Matches a real "message" event carrying our own text against the
// oldest still-unresolved optimistic echo in that same buffer, and
// replaces it in place rather than appending a duplicate line. FIFO
// order is the only correlation the wire gives us - a single client
// sends one message at a time, so the oldest pending entry is always
// the right match. Returns false (nothing replaced) for an isOwn
// message that didn't originate from a pending local echo at all -
// e.g. this account's own message sent from another client/session -
// which the caller then falls through to appending normally.
function reconcileOwnEcho(realMsg) {
const existing = root.messagesByBuffer[realMsg.bufferId] || [];
const idx = existing.findIndex(m => !!root.pendingSends[m.id]);
if (idx === -1)
return false;
const clientId = existing[idx].id;
const list = existing.slice();
list[idx] = realMsg;
const m = Object.assign({}, root.messagesByBuffer);
m[realMsg.bufferId] = list;
root.messagesByBuffer = m;
const pending = Object.assign({}, root.pendingSends);
delete pending[clientId];
root.pendingSends = pending;
return true;
}
Timer {
id: sendTimeoutSweep
interval: 1000
repeat: true
running: Object.keys(root.pendingSends).length > 0
onTriggered: root.sweepPendingSends()
}
readonly property bool activeBufferIsDiscord: {
if (!root.activeBuffer)
return false;
const acct = root.accounts.find(a => a.id === root.activeBuffer.accountId);
return !!acct && acct.service === "discord";
}
readonly property bool activeBufferIsMatrix: {
if (!root.activeBuffer)
return false;
const acct = root.accounts.find(a => a.id === root.activeBuffer.accountId);
return !!acct && acct.service === "matrix";
}
// Tri-state on the wire (true/false/absent - see model.rs's Buffer::
// encrypted) but the input field only ever needs the boolean once
// activeBufferIsMatrix already gates it - undefined/false both mean
// "not (yet) confirmed encrypted", which is exactly the unlocked
// state until proven otherwise.
readonly property bool activeBufferEncrypted: !!(root.activeBuffer && root.activeBuffer.encrypted)
// Drives the reply prefill format below - Sneedchat's own convention
// (and its site-wide search/notifications) is an "@username" mention,
// not IRC's "nick: " prefix.
readonly property bool activeBufferIsSockchat: {
if (!root.activeBuffer)
return false;
const acct = root.accounts.find(a => a.id === root.activeBuffer.accountId);
return !!acct && acct.service === "sockchat";
}
// Whether the active buffer's service supports editing/deleting your
// own messages at all - broader than activeBufferIsDiscord above,
// which stays Discord-specific since it also drives native-reply
// behavior that Sneedchat doesn't have.
readonly property bool activeBufferSupportsEditDelete: {
if (!root.activeBuffer)
return false;
const acct = root.accounts.find(a => a.id === root.activeBuffer.accountId);
// Matrix's own edit/delete (editMessage/deleteMessage RPCs) has
// been fully built backend-side since Phase 5 - this frontend
// gate just never actually included it, so the option silently
// never showed up for a Matrix account's own messages.
return !!acct && (acct.service === "discord" || acct.service === "sockchat" || acct.service === "matrix");
}
// Discord attaches a file directly to a message via its own API (see
// backend/discord.rs's send_attachment). Sneedchat has no such concept
// in its chat protocol at all, but backend/sockchat's own
// send_attachment covers it a different way - uploading to qu.ax
// (a ShareX-compatible anonymous host) through the account's Tor
// transport, then posting the resulting URL as a normal [img]-wrapped
// message. Matrix has its own real upload endpoint too (backend/
// matrix/mod.rs's send_message, "attachmentPath" already threaded all
// the way through the sendMessage RPC), covering both unencrypted
// rooms (a plain content.url) and E2EE ones (AttachmentEncryptor +
// content.file, decrypted symmetrically on receive) - the backend
// picks the right one per-room on its own, nothing extra needed here.
// IRC/XMPP/Slack have neither, so this stays false for them.
readonly property bool activeBufferSupportsAttachments: root.activeBufferIsDiscord || root.activeBufferIsSockchat || root.activeBufferIsMatrix
// Files picked via the "+" browser or pasted from the clipboard sit
// here as thumbnails - matching Discord's own attach flow - rather
// than uploading the instant they're chosen, so a caption can still be
// typed (or a mis-attached file backed out of) before anything
// actually sends. `isTemp` marks a paste-scratch file (see
// pasteScratchDir) rather than a real file the user picked from their
// own filesystem - only those are safe to delete on removal.
property var stagedAttachments: []
property int _stagedAttachmentSeq: 0
function stageAttachment(filePath, isTemp) {
if (!filePath)
return;
const name = filePath.split("/").pop();
const isImage = /\.(png|jpe?g|gif|webp|bmp)$/i.test(filePath);
root.stagedAttachments = root.stagedAttachments.concat([{ "id": root._stagedAttachmentSeq++, "path": filePath, "name": name, "isImage": isImage, "isTemp": !!isTemp }]);
}
function removeStagedAttachment(id) {
const staged = root.stagedAttachments.find(a => a.id === id);
root.stagedAttachments = root.stagedAttachments.filter(a => a.id !== id);
if (staged && staged.isTemp)
Quickshell.execDetached(["rm", "-f", staged.path]);
}
// One real "sendMessage" RPC per staged file - every backend's own
// attachment upload (Discord/Sneedchat/Matrix) is fundamentally
// single-file-per-message (Matrix's m.room.message in particular has
// no multi-attachment concept at all - even Element sends one event
// per file), so "one message, several attachments" the way Discord's
// own client shows it isn't something any of these protocols can
// actually do. The typed caption goes on the first one only; the rest
// send caption-less, immediately after - visually near-identical once
// rendered (comfy mode already groups consecutive same-sender messages
// together), without needing a multi-attachment wire format this
// project doesn't have.
function sendStagedAttachments(caption) {
if (root.stagedAttachments.length === 0 || !root.activeBufferId)
return false;
const staged = root.stagedAttachments;
root.stagedAttachments = [];
staged.forEach((att, i) => {
const params = { "bufferId": root.activeBufferId, "body": i === 0 ? caption : "", "attachmentPath": att.path };
if (i === 0 && root.replyingTo)
params.replyToId = root.replyingTo.id;
send("sendMessage", params);
});
root.replyingTo = null;
return true;
}
// Paste-an-image-into-the-input-box support. Qt's own TextField paste
// only ever pulls a text/plain representation out of the clipboard, so
// an image-only clipboard entry (a browser's "Copy Image", a
// screenshot tool) silently pastes nothing on its own - this is what
// makes that case actually upload instead. Goes through `dms
// clipboard` (the same CLI already used for the right-click Copy
// actions elsewhere) rather than any direct Qt clipboard API, which
// isn't exposed to QML here; `history`+`get --json` is the only way
// that CLI exposes raw image bytes (`paste` is text-only).
property string pasteScratchDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/dms-chat-paste"
function tryPasteImage() {
if (!root.activeBufferSupportsAttachments || pasteImageProcess.running)
return;
pasteImageProcess.running = true;
}
Process {
id: pasteImageProcess
running: false
command: ["python3", "-c", `
import base64, json, mimetypes, os, subprocess, sys, time
out_dir = ${JSON.stringify(root.pasteScratchDir)}
hist = subprocess.run(["dms", "clipboard", "history", "--json"], capture_output=True, text=True)
try:
entries = json.loads(hist.stdout or "[]")
except ValueError:
sys.exit(0)
if not entries or not entries[0].get("isImage"):
sys.exit(0)
got = subprocess.run(["dms", "clipboard", "get", str(entries[0]["id"]), "--json"], capture_output=True, text=True)
try:
entry = json.loads(got.stdout or "{}")
except ValueError:
sys.exit(0)
data = entry.get("data")
if not data:
sys.exit(0)
os.makedirs(out_dir, exist_ok=True)
ext = mimetypes.guess_extension(entry.get("mimeType", "image/png")) or ".png"
path = os.path.join(out_dir, "paste-" + str(int(time.time() * 1000)) + ext)
with open(path, "wb") as f:
f.write(base64.b64decode(data))
print(path)
`]
stdout: StdioCollector {
onStreamFinished: {
const path = (text || "").trim();
if (path)
root.stageAttachment(path, true);
}
}
}
function editMessage(id, newBody) {
if (!root.activeBufferId)
return;
send("editMessage", { "bufferId": root.activeBufferId, "messageId": id, "body": newBody });
}
function deleteMessage(id) {
if (!root.activeBufferId)
return;
send("deleteMessage", { "bufferId": root.activeBufferId, "messageId": id });
}
// Userlist context menu actions (NickList.qml). Moderator actions
// reuse the exact same slash commands a user could type by hand
// (backend/irc.rs's send_message) rather than needing their own RPC
// methods - the server is the real authority on whether we actually
// have the rank, same as any other IRC client.
function nickMessageRequested(nick) {
// Reset unconditionally, not just when actually leaving a panel -
// harmless no-op from NickList.qml's own "Message" action (already
// "" there), but required from IrcJoinPanel.qml's own "Message
// someone" field, which is reached *from* the join page and needs
// to hand control back to the normal composer.
root.activePanel = "";
inputField.text = "/msg " + nick + " ";
inputField.forceActiveFocus();
inputField.cursorPosition = inputField.text.length;
}
function nickModAction(command, nick, arg) {
root.sendCurrentMessage("/" + command + " " + nick + (arg ? " " + arg : ""));
}
function closeBuffer(bufferId) {
// chatd's partBuffer already leaves the channel/destroys the IM
// conversation and pushes back a bufferListChange{removed:true}
// event (see the msg.event === "bufferListChange" handler below,
// which filters root.buffers on that) - this just also cleans up
// local-only state that event doesn't know about, and makes sure
// we don't leave the message view pointed at a buffer that no
// longer exists in the sidebar.
send("partBuffer", { "bufferId": bufferId });
root.buffers = root.buffers.filter(b => b.id !== bufferId);
const m = Object.assign({}, root.messagesByBuffer);
delete m[bufferId];
root.messagesByBuffer = m;
if (root.activeBufferId === bufferId)
root.activeBufferId = root.buffers.length > 0 ? root.buffers[0].id : "";
}
function addAccount(nick, host, port, ssl) {
console.log("[dankChat/ChatView] addAccount clicked:", nick, host, port, ssl);
// nick/host/port/ssl come from the form itself (the things someone
// reasonably wants to set per-connection); everything else comes
// from ChatSettings.qml's "Connection defaults for new accounts" -
// this is the one place those settings actually get applied. SASL
// isn't one of them - it's a per-account setting now, edited from
// that account's own entry in the Accounts pane after it exists.
send("addAccount", {
"nick": nick,
"host": host,
"port": port,
"ssl": ssl,
"realname": nick,
"encoding": PluginService.loadPluginData("dankChat", "irc.defaultEncoding", "UTF-8"),
"autodetectUtf8": PluginService.loadPluginData("dankChat", "irc.autodetectUtf8", false),
"quitMessage": PluginService.loadPluginData("dankChat", "irc.quitMessage", "Leaving."),
"ratelimitInterval": parseInt(PluginService.loadPluginData("dankChat", "irc.ratelimitInterval", "2"), 10),
"ratelimitBurst": parseInt(PluginService.loadPluginData("dankChat", "irc.ratelimitBurst", "5"), 10)
});
}
function addXmppAccount(jid, password) {
console.log("[dankChat/ChatView] addXmppAccount clicked:", jid);
send("addXmppAccount", { "jid": jid, "password": password });
}
// Discord's QR login is multi-step and asynchronous (see backend/
// discord.rs) - progress arrives as discordLoginQr/discordLoginScanned/
// discordLoginResult events. Deliberately NOT scoped to "the loginId
// this instance's own addDiscordAccount call got back": ChatDaemon.qml
// instantiates one ChatView per screen (Variants over Quickshell.
// screens) plus one more for the popout window, each with its own
// socket connection - a per-instance loginId correlation would only
// ever resolve on whichever instance happened to issue the request,
// leaving every other screen's copy showing "Generating..." forever
// (confirmed live: multi-monitor setup, exactly this symptom). Every
// other piece of state here (accounts, buffers, connectionState) is
// already broadcast-and-applied-everywhere rather than request-scoped;
// this just follows that same convention.
property string discordQrPath: ""
property string discordLoginStatus: ""
function startDiscordLogin() {
console.log("[dankChat/ChatView] startDiscordLogin clicked");
root.discordQrPath = "";
root.discordLoginStatus = "";
send("addDiscordAccount", {});
}
// Same async-kickoff shape as Discord's QR login (Tor bootstrap + login
// + a possible proof-of-work solve can take anywhere from instant to
// over a minute) - progress/result arrive as sockChatLoginStatus/
// sockChatLoginResult events, broadcast-and-applied-everywhere rather
// than scoped to this instance's own request, same reasoning as
// discordLoginQr/Result above.
property string sockChatLoginStatus: ""
function addSockChatAccount(username, password, totpSecret) {
console.log("[dankChat/ChatView] addSockChatAccount clicked:", username);
root.sockChatLoginStatus = "";
const params = { "username": username, "password": password };
if (totpSecret)
params.totpSecret = totpSecret;
send("addSockChatAccount", params);
}
// Same async-kickoff shape as Discord/Sneedchat above - login (homeserver
// reachability + m.login.password + this device's E2EE identity setup)
// can take a moment, progress/result arrive as matrixLoginStatus/
// matrixLoginResult events, broadcast-and-applied-everywhere.
property string matrixLoginStatus: ""
function addMatrixAccount(homeserverUrl, username, password) {
console.log("[dankChat/ChatView] addMatrixAccount clicked:", username);
root.matrixLoginStatus = "";
send("addMatrixAccount", { "homeserverUrl": homeserverUrl, "userId": username, "password": password });
}
// Session verification (SAS) + recovery key state, shown inline under
// each Matrix account in AccountsPanel.qml (not a global settings
// category - this is all inherently per-account) - see backend/matrix/
// verification.rs's/backup.rs's own doc comments for what's driving
// each of these on the other end. Devices are keyed by accountId since
// more than one Matrix account can exist; matrixActiveVerification is
// a single object (not a list, and not even keyed - it carries its own
// accountId) since the backend only tracks one active verification per
// account at a time (v1 limit), and this frontend doesn't attempt to
// run two at once either.
property var matrixDevicesByAccount: ({})
property var matrixDevicesLoadingByAccount: ({})
property var matrixActiveVerification: null
property string matrixRecoveryKey: ""
property bool matrixRecoveryKeyPending: false
property string matrixRestoreStatusText: ""
property bool matrixRestorePending: false
property string matrixDeviceDeleteError: ""
// The account/device id a just-sent startMatrixVerification targeted -
// the RPC response only carries back a verificationId, not what it was
// for, so this bridges the gap between the click and that response.
property string matrixPendingVerifyAccountId: ""
property string matrixPendingVerifyDeviceId: ""
// Same reasoning, for deleteMatrixDevice - deliberately not encoded
// into the request id the way listMatrixDevices's accountId is: a
// Matrix account id (matrix:@user:server) contains colons itself, so
// a naive id.split(":") to recover it back out (rather than slicing
// off a fixed known prefix) would silently grab only the first
// fragment ("matrix") instead of the whole id.
property string matrixPendingDeleteAccountId: ""
// Discord friends list - accountId -> array of {userId, username,
// globalName, avatarUrl, status}. Same "fetch on expand, keyed by
// accountId" shape as matrixDevicesByAccount above, but with no
// separate loading map: listDiscordFriends is a synchronous in-memory
// read on chatd's end (see rpc/methods.rs), not a real network round
// trip, so there's no meaningful loading state to show.
property var discordFriendsByAccount: ({})
function discordRefreshFriends(accountId) {
send("listDiscordFriends", { "accountId": accountId }, "listDiscordFriends:" + accountId);
}
function discordAddFriend(accountId, username) {
send("addDiscordFriend", { "accountId": accountId, "username": username }, "addDiscordFriend");
}
function discordCreateGuild(accountId, name) {
send("createDiscordGuild", { "accountId": accountId, "name": name }, "createDiscordGuild");
}
function matrixRefreshDevices(accountId) {
const loading = Object.assign({}, root.matrixDevicesLoadingByAccount);
loading[accountId] = true;
root.matrixDevicesLoadingByAccount = loading;
send("listMatrixDevices", { "accountId": accountId }, "listMatrixDevices:" + accountId);
}
function matrixVerifyDevice(accountId, deviceId) {
root.matrixPendingVerifyAccountId = accountId;
root.matrixPendingVerifyDeviceId = deviceId;
send("startMatrixVerification", { "accountId": accountId, "deviceId": deviceId }, "startMatrixVerification");
}
function matrixRespondVerification(accountId, verificationId, accept) {
send("respondMatrixVerification", { "accountId": accountId, "verificationId": verificationId, "accept": accept });
}
function matrixConfirmVerification(accountId, verificationId, matches) {
send("confirmMatrixVerification", { "accountId": accountId, "verificationId": verificationId, "matches": matches });
}
function matrixCancelVerification(accountId, verificationId) {
send("cancelMatrixVerification", { "accountId": accountId, "verificationId": verificationId });
}
function matrixDeleteDevice(accountId, deviceId, password) {
root.matrixDeviceDeleteError = "";
root.matrixPendingDeleteAccountId = accountId;
send("deleteMatrixDevice", { "accountId": accountId, "deviceId": deviceId, "password": password }, "deleteMatrixDevice");
}
function matrixSetupRecoveryKey(accountId) {
root.matrixRecoveryKeyPending = true;
root.matrixRecoveryKey = "";
send("setupMatrixRecoveryKey", { "accountId": accountId }, "setupMatrixRecoveryKey");
}
function matrixRestoreRecoveryKey(accountId, recoveryKey) {
root.matrixRestorePending = true;
root.matrixRestoreStatusText = "";
send("restoreMatrixRecoveryKey", { "accountId": accountId, "recoveryKey": recoveryKey }, "restoreMatrixRecoveryKey");
}
DankSocket {
id: chatSocket
path: root.socketPath
// `connected: true` as a static declared value is the object's
// INITIAL value, not a change - DankSocket's own onConnectedChanged
// (which is what actually tells the real underlying socket to
// connect) only fires on a genuine change after construction, so a
// declarative `connected: true` here never triggers a connection
// attempt at all. Component.onCompleted below does the assignment
// imperatively instead, which does fire the change notification.
connected: false
Component.onCompleted: connected = true
onConnectionStateChanged: {
console.log("[dankChat/ChatView] chatSocket linkUp =", linkUp, "path:", root.socketPath, "pending:", root.pendingRequests.length);
root.connected = linkUp;
if (linkUp) {
const queued = root.pendingRequests;
root.pendingRequests = [];
for (const req of queued)
chatSocket.send(req);
root.refreshAccounts();
root.refreshBuffers();
}
}
parser: SplitParser {
onRead: line => {
if (!line)
return;
console.log("[dankChat/ChatView] recv <-", line.substring(0, 300));
let msg;
try {
msg = JSON.parse(line);
} catch (e) {
console.log("[dankChat/ChatView] JSON parse failed:", e.message);
return;
}
if (msg.id === "sendMessage" && msg.error) {
// Attachments still go through the plain fire-and-
// forget "sendMessage" id (see sendStagedAttachments) -
// no per-message pending/failed row for those yet, so
// a toast is still the only feedback path there.
ToastService.showError("Failed to send: " + msg.error);
} else if (root.pendingSends[msg.id]) {
// A tracked optimistic echo's own send (see
// queueOptimisticSend/retrySend) - a definite RPC
// error marks it failed right away rather than
// waiting out the full timeout; success alone doesn't
// resolve it yet, that's reconcileOwnEcho's job once
// the real "message" event actually arrives below.
if (msg.error)
root.markSendFailed(msg.id, msg.error);
} else if (msg.event === "message") {
if (!msg.data.isOwn || !root.reconcileOwnEcho(msg.data))
appendMessage(msg.data.bufferId, msg.data);
} else if (msg.event === "messageUpdated") {
// `edited` from the event itself, not hardcoded true -
// a backend-internal body rewrite (e.g. Sneedchat's
// attachment-link resolution swapping in a Tor-fetched
// local copy) reuses this same event with edited:false,
// since it isn't a real user edit and shouldn't show
// the "(edited)" label. OR'd with the message's current
// state so a genuine prior edit's true doesn't get
// clobbered by a later silent rewrite.
const existing = root.messagesByBuffer[msg.data.bufferId] || [];
const list = existing.map(m => m.id === msg.data.id ? Object.assign({}, m, { "body": msg.data.body, "edited": m.edited || !!msg.data.edited, "embeds": msg.data.embeds || [] }) : m);
const m = Object.assign({}, root.messagesByBuffer);
m[msg.data.bufferId] = list;
root.messagesByBuffer = m;
} else if (msg.event === "messageDeleted") {
const existing = root.messagesByBuffer[msg.data.bufferId] || [];
const m = Object.assign({}, root.messagesByBuffer);
m[msg.data.bufferId] = existing.filter(msgItem => msgItem.id !== msg.data.id);
root.messagesByBuffer = m;
} else if (msg.event === "reactionsChanged") {
const existing = root.messagesByBuffer[msg.data.bufferId] || [];
const list = existing.map(m => m.id === msg.data.id ? Object.assign({}, m, { "reactions": msg.data.reactions }) : m);
const m = Object.assign({}, root.messagesByBuffer);
m[msg.data.bufferId] = list;
root.messagesByBuffer = m;
} else if (msg.event === "bufferListChange") {
if (msg.data.removed) {
root.buffers = root.buffers.filter(b => b.id !== msg.data.id);
} else if (root.buffers.some(b => b.id === msg.data.id)) {
// Re-broadcast of an already-known buffer (e.g. its
// lastActivityTs just bumped from a new message) -
// merge the fresh server fields in, keeping this
// instance's own local-only fields (unread,
// highlight) intact rather than resetting them.
root.buffers = root.buffers.map(b => b.id === msg.data.id ? Object.assign({}, b, msg.data) : b);
} else {
root.buffers = root.buffers.concat([Object.assign({ "unread": 0, "highlight": false }, msg.data)]);
// Without this, a buffer that's never been opened
// this session never gets its unread count bumped
// at all - "message" (unlike bufferListChange) is a
// scoped event, only delivered for buffers this
// connection has explicitly subscribed to (see the
// wire contract's subscribe/unsubscribe methods).
send("subscribe", { "bufferId": msg.data.id });
}
} else if (msg.event === "presenceChange") {
const p = Object.assign({}, root.presenceByBuffer);
p[msg.data.bufferId] = msg.data.members;
root.presenceByBuffer = p;
} else if (msg.event === "connectionState") {
// .map() only updates an existing entry - it can never
// ADD one. An account created by this same session (see
// the addAccount handler below) is added immediately,
// so this event's job is normally just updating state.
// But upsert anyway: this daemon-owned socket can also
// observe accounts added from *other* connections
// (another screen's slideout, chatd's own reconnect of
// previously-saved accounts on startup) that this
// instance's local `accounts` never had to begin with.
if (root.accounts.some(a => a.id === msg.data.accountId)) {
// `error` used to get silently dropped here - the
// real reason a connection failed or got stuck
// (DNS/TLS/registration timeout, "cancelled" from
// an explicit disconnect-while-connecting, etc.)
// never reached anywhere the user could see it.
// `detail` is report_progress's live "what's it
// doing right now" string - kept while still
// connecting even across an update that didn't
// carry one, cleared the moment it isn't.
root.accounts = root.accounts.map(a => {
if (a.id !== msg.data.accountId)
return a;
const detail = msg.data.state === "connecting" ? (msg.data.detail || a.detail || "") : "";
return Object.assign({}, a, { "state": msg.data.state, "error": msg.data.error || "", "detail": detail });
});
} else {
root.refreshAccounts();
}
} else if (msg.event === "accountAvatarChanged") {
root.accounts = root.accounts.map(a => a.id === msg.data.accountId ? Object.assign({}, a, { "avatarUrl": msg.data.avatarUrl }) : a);
} else if (msg.event === "discordPresenceUpdate") {
// Only ever fires for a user_id already in that
// account's friends list (see runtime.rs's
// update_discord_presence) - a plain no-op map lookup
// miss below is impossible in practice, but harmless
// either way since .map() just leaves that entry alone.
const existing = root.discordFriendsByAccount[msg.data.accountId];
if (existing) {
const byAccount = Object.assign({}, root.discordFriendsByAccount);
byAccount[msg.data.accountId] = existing.map(f => f.userId === msg.data.userId ? Object.assign({}, f, { "status": msg.data.status }) : f);
root.discordFriendsByAccount = byAccount;
}
} else if (msg.event === "discordLoginQr") {
root.discordQrPath = msg.data.qrCodePath;
root.discordLoginStatus = "Scan with the Discord mobile app";
} else if (msg.event === "discordLoginScanned") {
root.discordLoginStatus = "Scanned as " + msg.data.username + " - confirm on your phone";
} else if (msg.event === "discordLoginResult") {
if (msg.data.success) {
root.discordQrPath = "";
root.discordLoginStatus = "";
if (!root.accounts.some(a => a.id === msg.data.account.id))
root.accounts = root.accounts.concat([msg.data.account]);
} else {
root.discordQrPath = "";
root.discordLoginStatus = "Login failed: " + msg.data.error;
}
} else if (msg.event === "sockChatLoginStatus") {
root.sockChatLoginStatus = msg.data.detail;
} else if (msg.event === "sockChatLoginResult") {
if (msg.data.success) {
root.sockChatLoginStatus = "";
if (!root.accounts.some(a => a.id === msg.data.account.id))
root.accounts = root.accounts.concat([msg.data.account]);
} else {
root.sockChatLoginStatus = "Login failed: " + msg.data.error;
}
} else if (msg.event === "matrixLoginStatus") {
root.matrixLoginStatus = msg.data.detail;
} else if (msg.event === "matrixLoginResult") {
if (msg.data.success) {
root.matrixLoginStatus = "";
if (!root.accounts.some(a => a.id === msg.data.account.id))
root.accounts = root.accounts.concat([msg.data.account]);
} else {
root.matrixLoginStatus = "Login failed: " + msg.data.error;
}
} else if (msg.event === "matrixVerificationIncoming") {
// The other session started this - see
// backend/matrix/verification.rs's tick, which only
// surfaces one we didn't already know about. Only one
// active verification tracked at a time (matches the
// backend's own v1 limit), so this replaces whatever
// was there before.
root.matrixActiveVerification = {
"accountId": msg.data.accountId,
"verificationId": msg.data.verificationId,
"state": "requested",
"weStarted": false,
"otherDevice": msg.data.fromDevice || "",
"emoji": [],
"deviceId": msg.data.fromDevice || ""
};
} else if (msg.event === "matrixVerificationStatus") {
if (root.matrixActiveVerification && root.matrixActiveVerification.verificationId === msg.data.verificationId)
root.matrixActiveVerification = Object.assign({}, root.matrixActiveVerification, { "state": msg.data.state });
} else if (msg.event === "matrixVerificationEmoji") {
if (root.matrixActiveVerification && root.matrixActiveVerification.verificationId === msg.data.verificationId)
root.matrixActiveVerification = Object.assign({}, root.matrixActiveVerification, { "emoji": msg.data.emoji || [] });
} else if (msg.event === "matrixVerificationResult") {
if (root.matrixActiveVerification && root.matrixActiveVerification.verificationId === msg.data.verificationId) {
if (msg.data.success)
ToastService.showInfo("Session verified.");
else if (msg.data.error !== "cancelled")
ToastService.showError("Verification failed: " + (msg.data.error || "unknown error"));
root.matrixActiveVerification = null;
root.matrixRefreshDevices(msg.data.accountId);
}
} else if (typeof msg.id === "string" && msg.id.startsWith("listMatrixDevices:")) {
const accountId = msg.id.slice("listMatrixDevices:".length);
const loading = Object.assign({}, root.matrixDevicesLoadingByAccount);
loading[accountId] = false;
root.matrixDevicesLoadingByAccount = loading;
if (msg.result) {
const byAccount = Object.assign({}, root.matrixDevicesByAccount);
byAccount[accountId] = msg.result;
root.matrixDevicesByAccount = byAccount;
} else {
ToastService.showError("Failed to load Matrix sessions: " + msg.error);
}
} else if (msg.id === "startMatrixVerification") {
if (msg.result) {
root.matrixActiveVerification = {
"accountId": root.matrixPendingVerifyAccountId,
"verificationId": msg.result.verificationId,
"state": "requested",
"weStarted": true,
"otherDevice": "",
"emoji": [],
"deviceId": root.matrixPendingVerifyDeviceId
};
} else {
ToastService.showError("Failed to start verification: " + msg.error);
}
} else if ((msg.id === "respondMatrixVerification" || msg.id === "confirmMatrixVerification" || msg.id === "cancelMatrixVerification") && msg.error) {
ToastService.showError("Verification action failed: " + msg.error);
} else if (msg.id === "deleteMatrixDevice") {
if (msg.result) {
ToastService.showInfo("Session removed.");
root.matrixRefreshDevices(root.matrixPendingDeleteAccountId);
} else {
root.matrixDeviceDeleteError = msg.error || "unknown error";
}
} else if (typeof msg.id === "string" && msg.id.startsWith("getMatrixRoomPermissions:")) {
if (msg.result) {
const bufferId = msg.id.slice("getMatrixRoomPermissions:".length);
const byBuffer = Object.assign({}, root.matrixRoomPermissionsByBuffer);
byBuffer[bufferId] = msg.result;
root.matrixRoomPermissionsByBuffer = byBuffer;
}
} else if (msg.id === "openMatrixDm") {
if (msg.result && msg.result.bufferId)
root.selectBuffer(msg.result.bufferId);
else
ToastService.showError("Failed to open DM: " + msg.error);
} else if (msg.id === "openDiscordDm") {
if (msg.result && msg.result.bufferId)
root.selectBuffer(msg.result.bufferId);
else
ToastService.showError("Failed to open DM: " + msg.error);
} else if (msg.id === "getDiscordMessageLink") {
if (msg.result && msg.result.url)
Qt.openUrlExternally(msg.result.url);
else
ToastService.showError("Couldn't resolve that message: " + msg.error);
} else if (msg.id === "joinBuffer") {
if (!msg.result)
ToastService.showError("Failed to join: " + msg.error);
} else if (msg.id === "joinMatrixRoom") {
if (msg.result)
ToastService.showInfo("Joined.");
else
ToastService.showError("Failed to join: " + msg.error);
} else if (msg.id === "joinDiscordGuild") {
if (msg.result)
ToastService.showInfo("Joined.");
else
ToastService.showError("Failed to join: " + msg.error);
} else if (msg.id === "createDiscordGuild") {
if (msg.result)
ToastService.showInfo("Server created.");
else
ToastService.showError("Failed to create server: " + msg.error);
} else if (msg.id === "addDiscordFriend") {
if (msg.result)
ToastService.showInfo("Friend request sent.");
else
ToastService.showError("Failed to send friend request: " + msg.error);
} else if (typeof msg.id === "string" && msg.id.startsWith("listDiscordFriends:")) {
const accountId = msg.id.slice("listDiscordFriends:".length);
if (msg.result) {
const byAccount = Object.assign({}, root.discordFriendsByAccount);
byAccount[accountId] = msg.result;
root.discordFriendsByAccount = byAccount;
} else {
ToastService.showError("Failed to load friends: " + msg.error);
}
} else if (msg.id === "kickMatrixMember") {
if (msg.result)
ToastService.showInfo("Member kicked.");
else
ToastService.showError("Failed to kick member: " + msg.error);
} else if (msg.id === "banMatrixMember") {
if (msg.result)
ToastService.showInfo("Member banned.");
else
ToastService.showError("Failed to ban member: " + msg.error);
} else if (msg.id === "muteMatrixMember") {
if (msg.result)
ToastService.showInfo("Member muted.");
else
ToastService.showError("Failed to mute member: " + msg.error);
} else if (msg.id === "setupMatrixRecoveryKey") {
root.matrixRecoveryKeyPending = false;
if (msg.result) {
root.matrixRecoveryKey = msg.result.recoveryKey;
root.refreshAccounts();
} else {
ToastService.showError("Failed to set up recovery key: " + msg.error);
}
} else if (msg.id === "restoreMatrixRecoveryKey") {
root.matrixRestorePending = false;
if (msg.result) {
root.matrixRestoreStatusText = "Restored " + msg.result.restoredKeys + " of " + msg.result.totalKeys + " keys.";
root.refreshAccounts();
} else {
root.matrixRestoreStatusText = "Restore failed: " + msg.error;
}
} else if (msg.id === "regenerateTorCircuit") {
if (msg.result)
ToastService.showInfo("Tor circuit regenerated.");
else
ToastService.showError("Failed to regenerate Tor circuit: " + msg.error);
} else if (msg.id === "restartTorFromScratch") {
if (msg.result)
ToastService.showInfo("Tor restarted from scratch.");
else
ToastService.showError("Failed to restart Tor: " + msg.error);
} else if (msg.id === "addAccount" && msg.result) {
// The account this session just created - without this,
// `accounts` never learns about it at all (connectionState
// above only updates existing entries), AccountsPanel's
// "no accounts yet" gate never clears, and the rest of
// the UI stays hidden behind it even once fully connected.
if (!root.accounts.some(a => a.id === msg.result.id))
root.accounts = root.accounts.concat([msg.result]);
} else if (msg.id === "listAccounts" && msg.result) {
root.accounts = msg.result;
} else if (msg.id === "listBuffers" && msg.result) {
// listBuffers is the full authoritative list, refetched
// on every (re)connect via refreshBuffers() - not just
// once at startup. A buffer already known from a prior
// fetch must keep its accumulated local-only state
// (unread, highlight); only a genuinely new buffer gets
// the zeroed defaults. Previously this unconditionally
// reset every buffer's unread count to 0 on every
// refresh, silently wiping the badge on any reconnect.
const existingById = {};
for (const b of root.buffers)
existingById[b.id] = b;
root.buffers = msg.result.map(b => Object.assign({ "unread": 0, "highlight": false }, existingById[b.id] || {}, b));
// Subscribe to every buffer, not just whichever one
// ends up active - "message" is a scoped event (see
// the bufferListChange handler's own note above), so
// without this, unread counts could only ever
// increment for the one buffer currently open.
for (const b of root.buffers)
send("subscribe", { "bufferId": b.id });
if (!root.activeBufferId && root.buffers.length > 0) {
root.selectBuffer(root.buffers[0].id);
} else if (root.activeBufferId && !root.messagesByBuffer[root.activeBufferId] && root.buffers.some(b => b.id === root.activeBufferId)) {
// activeBufferId was restored from persisted state
// (see its own doc comment) rather than picked via
// selectBuffer just above - which is the only other
// place that fetches backlog, and would itself just
// skip the fetch here since this buffer already
// "is" the active one (see its alreadyActive
// guard). Without this, a restored buffer opens
// selected but permanently empty until a live
// message happens to arrive for it.
root.fetchBufferEmoji(root.activeBufferId);
send("getBacklog", { "bufferId": root.activeBufferId, "limit": 200 }, "getBacklog:" + root.activeBufferId);
}
} else if (typeof msg.id === "string" && msg.id.startsWith("getBacklog:") && msg.result) {
const bufferId = msg.id.slice("getBacklog:".length);
const existing = root.messagesByBuffer[bufferId] || [];
// De-dupe by message id before prepending - a repeat
// getBacklog fetch for a buffer that already has some
// (or all) of that history loaded (e.g. a reconnect
// race) must not re-insert messages that are already
// showing.
const existingIds = new Set(existing.map(m => m.id));
const fresh = msg.result.filter(m => !existingIds.has(m.id));
const m = Object.assign({}, root.messagesByBuffer);
m[bufferId] = fresh.concat(existing);
root.messagesByBuffer = m;
if (root.loadingMoreBuffers[bufferId]) {
const lm = Object.assign({}, root.loadingMoreBuffers);
lm[bufferId] = false;
root.loadingMoreBuffers = lm;
}
} else if (typeof msg.id === "string" && msg.id.startsWith("bufferEmoji:") && msg.result) {
const bufferId = msg.id.slice("bufferEmoji:".length);
const e = Object.assign({}, root.bufferEmojiByBuffer);
e[bufferId] = msg.result;
root.bufferEmojiByBuffer = e;
} else if (msg.id === "sockchatSmilies" && msg.result) {
// chatd only knows the bundled filename (see
// smilies.rs's own doc comment on why - the images
// ship with the plugin, chatd doesn't resolve paths
// outside its own data dir) - the actual file:// URL
// is built here instead, from this side's own
// knowledge of where the plugin is installed.
root.sockchatSmilies = msg.result.map(s => Object.assign({}, s, { "url": root.sockchatSmilieAssetDir ? "file://" + root.sockchatSmilieAssetDir + s.file : "" }));
}
}
}
}
// Discord custom emoji available in the active buffer's guild (empty
// for IRC/DMs/anything else) - see backend/discord.rs's
// register_guild_channels, which is also what already filters out an
// unusable ("available": false) emoji before it ever reaches here, so
// there's nothing this side needs to re-check.
property var bufferEmojiByBuffer: ({})
function fetchBufferEmoji(bufferId) {
if (!bufferId || root.bufferEmojiByBuffer[bufferId] !== undefined)
return;
send("listBufferEmoji", { "bufferId": bufferId }, "bufferEmoji:" + bufferId);
}
// Sneedchat's own site-wide smiley table (see backend/sockchat/
// smilies.rs) - unlike bufferEmojiByBuffer above this isn't per-buffer
// (the same ~175 shortcodes apply to every room on the site) and, since
// the images ship bundled with the plugin (assets/sockchat-smilies/)
// rather than being fetched from kiwifarms.st at runtime, the table
// never changes at runtime either - fetched once and memoized, same
// as fetchBufferEmoji's own per-key guard.
property var sockchatSmilies: []
property bool sockchatSmiliesFetched: false
// Where the plugin's own bundled assets live - same getPluginPath
// resolution ChatDaemon.qml/DaemonStatusSettings.qml already use for
// their own on-disk paths, just for this one asset folder rather than
// the chatd binary or a settings-panel git check.
readonly property string sockchatSmilieAssetDir: {
const p = PluginService.getPluginPath("dankChat");
if (!p)
return "";
const dir = p.endsWith("/") ? p : p + "/";
return dir + "assets/sockchat-smilies/";
}
function fetchSockchatSmilies() {
if (root.sockchatSmiliesFetched)
return;
root.sockchatSmiliesFetched = true;
send("listSockchatSmilies", {}, "sockchatSmilies");
}
RowLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.preferredWidth: 220
Layout.fillHeight: true
color: Theme.surfaceContainer
visible: !root.sidebarFolded
BufferList {
anchors.fill: parent
buffers: root.buffers
activeBufferId: root.activeBufferId
onBufferSelected: id => root.selectBuffer(id)
onBufferCloseRequested: id => root.closeBuffer(id)
onAccountsRequested: root.activePanel = "accounts"
onSettingsRequested: root.activePanel = "settings"
onJoinPanelRequested: accountId => {
root.joinPanelAccountId = accountId;
root.activePanel = "join";
}
onConnectionToggleRequested: (accountId, connect) => root.send("setAccountConnected", { "accountId": accountId, "connected": connect })
accounts: root.accounts
}
}
Rectangle {
Layout.preferredWidth: 1
Layout.fillHeight: true
visible: !root.sidebarFolded
color: Theme.outlineStrong
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
RowLayout {
Layout.fillWidth: true
Layout.margins: Theme.spacingXS
spacing: Theme.spacingXS
DankActionButton {
circular: false
iconName: root.sidebarFolded ? "chevron_right" : "chevron_left"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceVariantText
// Folds the account/buffer sidebar away so only the
// message buffer is visible - independent of
// DankSlideout's own width toggle in ChatDaemon.qml,
// which just changes how wide the whole panel is.
onClicked: root.sidebarFolded = !root.sidebarFolded
}
StyledText {
Layout.fillWidth: true
visible: root.activePanel === "" && root.activeBufferId !== ""
text: root.bufferDisplayName(root.activeBufferId.split("|").pop())
font.weight: Font.Medium
color: Theme.surfaceText
wrapMode: Text.NoWrap
elide: Text.ElideRight
}
StyledText {
Layout.fillWidth: true
visible: root.activePanel === "accounts"
text: "Accounts"
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
Layout.fillWidth: true
visible: root.activePanel === "settings"
text: "Settings"
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
Layout.fillWidth: true
visible: root.activePanel === "join"
text: root.joinPanelAccount ? "Join · " + root.joinPanelAccount.displayName : "Join"
font.weight: Font.Medium
color: Theme.surfaceText
}
DankActionButton {
circular: false
iconName: root.userListFolded ? "group" : "group_off"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceVariantText
// Only meaningful for a channel - a DM is just the two
// participants, and there's no "member list" concept
// for the server buffer.
visible: root.activePanel === "" && root.activeBufferKind === "channel"
onClicked: root.userListFolded = !root.userListFolded
}
DankActionButton {
circular: false
iconName: "open_in_new"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceVariantText
// Floats the chat into its own resizable/movable window
// (ChatPopoutWindow.qml) - that window's own title bar
// already has a maximize button, so "float" and
// "maximize" are two clicks away, not one feature.
visible: !root.inPopout
onClicked: root.popoutRequested()
}
DankActionButton {
circular: false
iconName: "dock_to_right"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceVariantText
visible: root.inPopout
onClicked: root.dockRequested()
}
DankActionButton {
circular: false
iconName: "close"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceVariantText
visible: root.activePanel !== ""
onClicked: root.activePanel = ""
}
}
// A single Loader slot rather than four always-instantiated,
// visibility-toggled siblings - toggling Layout.fillHeight
// alongside visible: false was not enough to stop an inactive
// section from reserving its own natural/implicit height in
// this ColumnLayout (confirmed: AccountsPanel still rendered
// pushed down by the same amount after that fix). A Loader
// guarantees the inactive views don't exist as objects at
// all, so they truly cannot contribute any layout space.
Loader {
id: bodyLoader
Layout.fillWidth: true
Layout.fillHeight: true
// Belt-and-suspenders: Loader itself doesn't clip its
// loaded item by default (only MessageList's own ListView
// does, via its own `clip: true`) - explicit here too so
// the message area can never visually bleed past its own
// allocated rectangle into the composer bar below,
// regardless of any transient scroll/content-height state
// inside whatever's currently loaded.
clip: true
sourceComponent: {
if (root.activePanel === "settings")
return settingsComponent;
if (root.activePanel === "accounts" || (root.activePanel === "" && root.accounts.length === 0))
return accountsComponent;
if (root.activePanel === "join") {
const svc = root.joinPanelAccount ? root.joinPanelAccount.service : "";
if (svc === "irc")
return ircJoinComponent;
if (svc === "matrix")
return matrixJoinComponent;
if (svc === "discord")
return discordJoinComponent;
if (svc === "sockchat")
return sockchatJoinComponent;
return null;
}
if (root.activePanel === "" && root.activeBufferId !== "")
return messageListComponent;
if (root.activePanel === "" && root.activeBufferId === "" && root.accounts.length > 0)
return emptyStateComponent;
return null;
}
}
Component {
id: settingsComponent
Flickable {
anchors.fill: parent
anchors.margins: Theme.spacingS
clip: true
contentWidth: width
contentHeight: settingsView.implicitHeight
ChatSettings {
id: settingsView
width: parent.width
pluginService: PluginService
accounts: root.accounts
onSockchatRoomsChanged: (accountId, rooms) => {
root.send("setSockChatRooms", { "accountId": accountId, "rooms": rooms }, "setSockChatRooms");
root.refreshAccounts();
}
onTorConfigChanged: (torMode, proxy) => {
root.send("setTorConfig", { "torMode": torMode, "proxy": proxy }, "setTorConfig");
ToastService.showInfo("Connection settings saved - reconnecting...");
root.refreshAccounts();
}
onRegenerateTorCircuitRequested: {
root.send("regenerateTorCircuit", {}, "regenerateTorCircuit");
ToastService.showInfo("Regenerating Tor circuit...");
}
onRestartTorFromScratchRequested: {
root.send("restartTorFromScratch", {}, "restartTorFromScratch");
ToastService.showInfo("Restarting Tor from scratch - this can take up to a minute...");
}
}
}
}
Component {
id: accountsComponent
// Scrollable, same shape as settingsComponent's own
// Flickable wrapper below - without this, a long accounts
// list (or a Matrix account's expanded verify/recovery-key
// section) just overflows past the Loader's own bounds
// with no way to reach the rest of it, since AccountsPanel
// itself is sized to its full natural content height, not
// clipped to whatever's actually visible.
Flickable {
anchors.fill: parent
anchors.margins: Theme.spacingS
clip: true
contentWidth: width
contentHeight: accountsView.implicitHeight
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: DankScrollbar {}
AccountsPanel {
id: accountsView
width: parent.width
accounts: root.accounts
buffers: root.buffers
discordQrPath: root.discordQrPath
discordLoginStatus: root.discordLoginStatus
sockChatLoginStatus: root.sockChatLoginStatus
matrixLoginStatus: root.matrixLoginStatus
onConnectRequested: (nick, host, port, ssl) => root.addAccount(nick, host, port, ssl)
onConnectXmppRequested: (jid, password) => root.addXmppAccount(jid, password)
onConnectDiscordRequested: root.startDiscordLogin()
onConnectSockChatRequested: (username, password, totpSecret) => root.addSockChatAccount(username, password, totpSecret)
onConnectMatrixRequested: (homeserverUrl, username, password) => root.addMatrixAccount(homeserverUrl, username, password)
onConnectionToggleRequested: (accountId, connect) => root.send("setAccountConnected", { "accountId": accountId, "connected": connect })
onAutojoinChanged: (accountId, channelsCsv) => {
root.send("setAccountAutojoin", { "accountId": accountId, "channels": channelsCsv }, "setAccountAutojoin");
root.refreshAccounts();
}
onNickservPasswordChanged: (accountId, password) => root.send("setAccountNickservPassword", { "accountId": accountId, "password": password })
onDisplayNameChanged: (accountId, name) => {
root.send("setAccountDisplayName", { "accountId": accountId, "name": name }, "setAccountDisplayName");
root.refreshAccounts();
}
onSaslChanged: (accountId, enabled, saslUser, saslPassword, allowPlaintext) => {
root.send("setAccountSasl", { "accountId": accountId, "enabled": enabled, "saslUser": saslUser, "password": saslPassword, "allowPlaintextSasl": allowPlaintext }, "setAccountSasl");
root.refreshAccounts();
}
onUseTorChanged: (accountId, useTor, proxy) => {
root.send("setAccountUseTor", { "accountId": accountId, "useTor": useTor, "proxy": proxy }, "setAccountUseTor");
root.refreshAccounts();
}
onAccountRemoveRequested: accountId => {
// chatd emits bufferListChange{removed:true} for
// each of this account's buffers as part of
// removeAccount - root.buffers updates itself from
// that (see the bufferListChange handler below), no
// manual refetch needed. Accounts don't get their
// own removal event, so that one still needs an
// explicit refresh.
root.send("removeAccount", { "accountId": accountId }, "removeAccount");
if (root.activeBuffer && root.activeBuffer.accountId === accountId)
root.activeBufferId = "";
root.refreshAccounts();
}
matrixDevicesByAccount: root.matrixDevicesByAccount
matrixDevicesLoadingByAccount: root.matrixDevicesLoadingByAccount
matrixActiveVerification: root.matrixActiveVerification
matrixRecoveryKey: root.matrixRecoveryKey
matrixRecoveryKeyPending: root.matrixRecoveryKeyPending
matrixRestoreStatusText: root.matrixRestoreStatusText
matrixRestorePending: root.matrixRestorePending
matrixDeviceDeleteError: root.matrixDeviceDeleteError
onMatrixRefreshDevicesRequested: accountId => root.matrixRefreshDevices(accountId)
onMatrixVerifyDeviceRequested: (accountId, deviceId) => root.matrixVerifyDevice(accountId, deviceId)
onMatrixRespondVerificationRequested: (accountId, verificationId, accept) => root.matrixRespondVerification(accountId, verificationId, accept)
onMatrixConfirmVerificationRequested: (accountId, verificationId, matches) => root.matrixConfirmVerification(accountId, verificationId, matches)
onMatrixCancelVerificationRequested: (accountId, verificationId) => root.matrixCancelVerification(accountId, verificationId)
onMatrixDeleteDeviceRequested: (accountId, deviceId, password) => root.matrixDeleteDevice(accountId, deviceId, password)
onMatrixSetupRecoveryKeyRequested: accountId => root.matrixSetupRecoveryKey(accountId)
onMatrixRestoreRecoveryKeyRequested: (accountId, recoveryKey) => root.matrixRestoreRecoveryKey(accountId, recoveryKey)
}
}
}
// Each protocol gets its own dedicated join page (rather than
// one page listing every account, each expandable) - all four
// share the same scrollable-Flickable shape accountsComponent
// above uses, scoped to root.joinPanelAccount, the single
// account BufferList.qml's "+" was clicked for.
Component {
id: ircJoinComponent
Flickable {
anchors.fill: parent
anchors.margins: Theme.spacingS
clip: true
contentWidth: width
contentHeight: ircJoinView.implicitHeight
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: DankScrollbar {}
IrcJoinPanel {
id: ircJoinView
width: parent.width
account: root.joinPanelAccount
onJoinRequested: channel => root.send("joinBuffer", { "accountId": root.joinPanelAccountId, "name": channel }, "joinBuffer")
onMessageRequested: nick => root.nickMessageRequested(nick)
}
}
}
Component {
id: matrixJoinComponent
Flickable {
anchors.fill: parent
anchors.margins: Theme.spacingS
clip: true
contentWidth: width
contentHeight: matrixJoinView.implicitHeight
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: DankScrollbar {}
MatrixJoinPanel {
id: matrixJoinView
width: parent.width
account: root.joinPanelAccount
onJoinRequested: roomIdOrAlias => root.matrixJoinRoom(root.joinPanelAccountId, roomIdOrAlias)
onDmRequested: userId => root.matrixOpenDm(root.joinPanelAccountId, userId, "")
}
}
}
Component {
id: discordJoinComponent
Flickable {
anchors.fill: parent
anchors.margins: Theme.spacingS
clip: true
contentWidth: width
contentHeight: discordJoinView.implicitHeight
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: DankScrollbar {}
DiscordJoinPanel {
id: discordJoinView
width: parent.width
account: root.joinPanelAccount
friends: root.discordFriendsByAccount[root.joinPanelAccountId] || []
onJoinRequested: invite => root.discordJoinGuild(root.joinPanelAccountId, invite)
onCreateGuildRequested: name => root.discordCreateGuild(root.joinPanelAccountId, name)
onDmRequested: userId => root.discordOpenDm(root.joinPanelAccountId, userId)
onFriendsRequested: root.discordRefreshFriends(root.joinPanelAccountId)
onAddFriendRequested: username => root.discordAddFriend(root.joinPanelAccountId, username)
}
}
}
Component {
id: sockchatJoinComponent
// No ad hoc join mechanism to build here - Sneedchat's
// rooms are a small fixed set, edited from this account's
// own row in the Accounts pane (existing setSockChatRooms
// RPC, already wired there) rather than joined one at a
// time. A single explanatory note is genuinely all this
// page needs, so it's inlined here rather than as its own
// near-empty file.
Item {
anchors.fill: parent
StyledText {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingS
text: "Sneedchat's rooms are a fixed set - add or remove them from this account's own row in the Accounts pane rather than joining ad hoc here."
wrapMode: Text.WordWrap
color: Theme.surfaceVariantText
}
}
}
Component {
id: messageListComponent
MessageList {
anchors.fill: parent
messages: root.visibleMessages(root.activeBufferId)
bufferId: root.activeBufferId
isDiscord: root.activeBufferIsDiscord
isSockchat: root.activeBufferIsSockchat
sockchatSmilies: root.sockchatSmilies
// Only meaningful for a Discord buffer's own reaction
// picker (see MessageList.qml's reactionPicker) - same
// data the composer's own emoji button already uses.
customEmoji: root.bufferEmojiByBuffer[root.activeBufferId] || []
canEditDelete: root.activeBufferSupportsEditDelete
canRedactOthers: !!root.activeBufferPermissions.canRedactOthers
canKick: !!root.activeBufferPermissions.canKick
canBan: !!root.activeBufferPermissions.canBan
canMute: !!root.activeBufferPermissions.canMute
dividerTs: root.dividerTsByBuffer[root.activeBufferId] || 0
comfyMode: root.messageDisplayMode === "comfy"
mediaAutoplay: root.mediaAutoplay
mediaLoop: root.mediaLoop
relativeTimestamps: root.relativeTimestamps
contentSniffingEnabled: root.contentSniffingEnabled
loadingMore: !!root.loadingMoreBuffers[root.activeBufferId]
onReplyRequested: (id, from, body) => {
if (root.activeBufferIsDiscord || root.activeBufferIsMatrix) {
root.startReply(id, from, body);
} else if (root.activeBufferIsSockchat) {
// Sneedchat's own convention is an "@username,"
// mention (the comma is just visual style, not
// part of the username itself), not IRC's
// "nick: " prefix.
inputField.text = "@" + from + ", " + inputField.text;
inputField.forceActiveFocus();
inputField.cursorPosition = inputField.text.length;
} else {
inputField.text = from + ": " + inputField.text;
inputField.forceActiveFocus();
inputField.cursorPosition = inputField.text.length;
}
}
onEditSubmitted: (id, newBody) => root.editMessage(id, newBody)
onDeleteRequested: id => root.deleteMessage(id)
onKickRequested: userId => root.matrixKick(userId)
onBanRequested: userId => root.matrixBan(userId)
onMuteRequested: userId => root.matrixMute(userId)
onRetryRequested: id => root.retrySend(id)
onReactionToggled: (id, emoji, add) => root.send("toggleReaction", { "bufferId": root.activeBufferId, "messageId": id, "emoji": emoji, "add": add })
onLoadMoreRequested: root.loadMoreHistory(root.activeBufferId)
onOpenInDiscordRequested: (bufferId, messageId) => root.openDiscordMessageLink(bufferId, messageId)
}
}
Component {
id: emptyStateComponent
Item {
anchors.fill: parent
StyledText {
anchors.centerIn: parent
text: "Select or join a channel"
color: Theme.surfaceVariantText
}
}
}
// A fixed, always-there bar - deliberately NOT part of the
// fillHeight message area above (which sits in its own clipped
// Loader), and never itself toggled open/closed the way the
// settings/accounts/userlist panels are. Structured this way
// (its own background + top divider, exactly like the
// sidebar/userlist panes' own Rectangle+divider treatment)
// specifically so the message list can never visually intrude
// under it, regardless of any transient scroll/layout state
// while new messages are arriving.
ColumnLayout {
id: composerBar
Layout.fillWidth: true
Layout.fillHeight: false
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Theme.outlineStrong
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: false
implicitHeight: composerContent.implicitHeight
color: Theme.surfaceContainer
ColumnLayout {
id: composerContent
anchors.left: parent.left
anchors.right: parent.right
spacing: 0
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Theme.spacingS
Layout.rightMargin: Theme.spacingS
Layout.topMargin: Theme.spacingXS
visible: root.activePanel === "" && root.activeBufferId !== "" && !!root.replyingTo
spacing: Theme.spacingXS
DankIcon {
name: "reply"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: root.replyingTo ? ("Replying to " + root.replyingTo.from + (root.replyingTo.body ? ": " + root.replyingTo.body : "")) : ""
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.NoWrap
elide: Text.ElideRight
}
DankIcon {
name: "close"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.cancelReply()
}
}
}
Flow {
id: stagedAttachmentsFlow
Layout.fillWidth: true
Layout.leftMargin: Theme.spacingS
Layout.rightMargin: Theme.spacingS
Layout.topMargin: Theme.spacingXS
visible: root.activePanel === "" && root.stagedAttachments.length > 0
spacing: Theme.spacingXS
Repeater {
model: root.stagedAttachments
Rectangle {
id: stagedThumb
required property var modelData
width: 72
height: 72
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
clip: true
Image {
anchors.fill: parent
visible: stagedThumb.modelData.isImage
source: stagedThumb.modelData.isImage ? "file://" + stagedThumb.modelData.path : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.spacingXS
visible: !stagedThumb.modelData.isImage
spacing: 2
DankIcon {
Layout.alignment: Qt.AlignHCenter
name: "description"
size: Theme.iconSize
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: stagedThumb.modelData.name
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall - 2
wrapMode: Text.NoWrap
elide: Text.ElideMiddle
horizontalAlignment: Text.AlignHCenter
}
}
Rectangle {
width: 20
height: 20
radius: 10
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 2
color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.9)
DankIcon {
anchors.centerIn: parent
name: "close"
size: 14
color: Theme.error
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.removeStagedAttachment(stagedThumb.modelData.id)
}
}
}
}
}
RowLayout {
Layout.fillWidth: true
Layout.margins: Theme.spacingS
visible: root.activePanel === "" && root.activeBufferId !== ""
DankIcon {
id: encryptionLockIcon
visible: root.activeBufferIsMatrix
name: root.activeBufferEncrypted ? "lock" : "lock_open"
size: Theme.iconSizeSmall
color: root.activeBufferEncrypted ? Theme.primary : Theme.surfaceVariantText
HoverHandler {
id: encryptionLockHover
onHoveredChanged: {
if (encryptionLockHover.hovered) {
encryptionLockTooltipDelay.restart();
} else {
encryptionLockTooltipDelay.stop();
encryptionLockTooltip.hide();
}
}
}
Timer {
id: encryptionLockTooltipDelay
interval: 400
onTriggered: encryptionLockTooltip.show(
root.activeBufferEncrypted ? "This room is end-to-end encrypted" : "This room is not encrypted",
encryptionLockIcon, 0, 0, "top")
}
onVisibleChanged: if (!visible) encryptionLockTooltipDelay.stop()
DankTooltipV2 {
id: encryptionLockTooltip
}
}
DankTextField {
id: inputField
Layout.fillWidth: true
placeholderText: "Message " + root.bufferDisplayName(root.activeBufferId.split("|").pop())
onAccepted: {
if (!root.sendStagedAttachments(text))
root.sendCurrentMessage(text);
text = "";
forceActiveFocus();
}
// Not accepted/consumed - Qt's own paste
// (text/plain only) still runs alongside
// this, and is a no-op for an image-only
// clipboard entry anyway (see
// tryPasteImage's own comment).
Keys.onPressed: event => {
if (event.matches(StandardKey.Paste))
root.tryPasteImage();
}
}
DankActionButton {
id: emojiButton
circular: false
iconName: "mood"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceVariantText
onClicked: emojiPicker.visible = !emojiPicker.visible
}
EmojiPicker {
id: emojiPicker
parent: emojiButton
y: -height - Theme.spacingXS
x: parent.width - width
// Only the active buffer's own guild's
// custom emoji are offered - see
// fetchBufferEmoji/bufferEmojiByBuffer and
// backend/discord.rs's per-buffer emoji
// list, which is already filtered to
// what's actually usable there.
customEmoji: root.bufferEmojiByBuffer[root.activeBufferId] || []
// Only offered for a Sneedchat buffer - see
// fetchSockchatSmilies, fetched once and
// memoized the first time one becomes active.
sockchatSmilies: root.activeBufferIsSockchat ? root.sockchatSmilies : []
onEmojiSelected: text => {
inputField.text += text;
inputField.forceActiveFocus();
inputField.cursorPosition = inputField.text.length;
}
}
// Attachments: Discord, Sneedchat, and Matrix
// today - see activeBufferSupportsAttachments's
// own doc comment for how each backend actually
// implements the upload. IRC has no such
// concept, and XMPP's HTTP upload / Slack's own
// attachment API aren't implemented yet (see
// the project plan's per-protocol milestones),
// so this still falls back to the placeholder
// toast for anything else.
DankActionButton {
circular: false
iconName: "add"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceVariantText
onClicked: {
if (root.activeBufferSupportsAttachments) {
attachBrowserLoader.active = true;
if (attachBrowserLoader.item)
attachBrowserLoader.item.open();
} else {
ToastService.showInfo("Attachments aren't supported yet for this service");
}
}
}
}
}
}
}
}
LazyLoader {
id: attachBrowserLoader
active: false
FileBrowserSurfaceModal {
browserTitle: "Upload to " + root.bufferDisplayName(root.activeBufferId.split("|").pop())
browserIcon: "attach_file"
browserType: "dankchat_attach"
fileExtensions: ["*"]
allowStacking: true
onFileSelected: path => {
const cleanPath = decodeURI(path.toString().replace(/^file:\/\//, ""));
root.stageAttachment(cleanPath, false);
close();
}
}
}
Rectangle {
Layout.preferredWidth: 1
Layout.fillHeight: true
visible: !root.userListFolded && root.activePanel === "" && root.activeBufferKind === "channel"
color: Theme.outlineStrong
}
Rectangle {
Layout.preferredWidth: 180
Layout.fillHeight: true
visible: !root.userListFolded && root.activePanel === "" && root.activeBufferKind === "channel"
color: Theme.surfaceContainer
NickList {
anchors.fill: parent
members: root.activeMembers
ownNick: root.activeOwnNick
ownUserId: root.activeOwnUserId
accountId: root.activeBuffer ? root.activeBuffer.accountId : ""
blockedNicks: root.blockedNicks
groupMode: root.activeBufferGroupMode
canKick: !!root.activeBufferPermissions.canKick
canBan: !!root.activeBufferPermissions.canBan
canMute: !!root.activeBufferPermissions.canMute
onMessageRequested: nick => root.nickMessageRequested(nick)
onBlockRequested: nick => root.toggleBlocked(root.activeBuffer.accountId, nick)
onModActionRequested: (command, nick) => root.nickModAction(command, nick)
onKickRequested: userId => root.matrixKick(userId)
onBanRequested: userId => root.matrixBan(userId)
onMuteRequested: userId => root.matrixMute(userId)
onOpenDmRequested: (userId, displayName) => root.matrixOpenDm(root.activeBuffer.accountId, userId, displayName)
}
}
}
}