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 ("|") - 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) } } } }