import QtQuick import Quickshell import Quickshell.Io import qs.Common import qs.Services import qs.Widgets import "views" // Composite-plugin "daemon" surface: instantiated once. Supervises the // chatd process and maintains a lightweight status-only connection to its // control socket, republishing per-account connection state and unread/ // highlight counts via PluginService global vars for ChatBarWidget.qml to // read reactively (the documented cross-surface channel - see // PLUGINS/README.md's "Plugin Global Variables" section). The actual chat // UI (ChatView.qml, opened from the bar widget) talks to chatd directly // over its own separate socket connection instead of relaying everything // through here, the same multi-connection pattern DMS's own // Services/DMSService.qml uses against its Go backend. Item { id: root property var pluginService: null property string pluginId: "dankChat" property var popoutService: null readonly property string pluginDir: { if (pluginService) { const p = pluginService.getPluginPath(pluginId); if (p) return p.endsWith("/") ? p : p + "/"; } const dirUrl = Qt.resolvedUrl(".").toString(); return dirUrl.replace(/^file:\/\//, ""); } // Rust daemon (chatd-rs/) as of the libpurple->Rust rewrite - see the // project plan. The old C/libpurple binary at daemon/chatd/chatd is // retired but not yet deleted (kept until all 5 protocols reach parity // here, see plan's "Final cleanup" milestone). readonly property string chatdPath: pluginDir + "chatd-rs/target/release/chatd" readonly property string runtimeDir: Quickshell.env("XDG_RUNTIME_DIR") || (Quickshell.env("HOME") + "/.local/state") readonly property string socketPath: runtimeDir + "/dms-chat/chatd.sock" property bool chatdConnected: false property var accountStates: ({}) // Real unread tracking for the bar widget - every bufferId currently // holding an un-acknowledged "notification"-worthy event (chatd only // ever emits "notification" for an inbound DM or a highlighted // mention, never plain traffic - see runtime.rs's record_message). // Deliberately built from that same event stream rather than the full // per-buffer message/unread state ChatView.qml tracks: this surface // stays alive (and this list stays accurate) even while the chat // window itself is closed, which is the whole point of a tray-icon // alert. property var unreadNotifyBuffers: [] property var pinnedBuffers: [] // Snapshot of lastReadTs (see ChatView.qml's markBufferRead, the same // PluginService-persisted key) as of the last time it was checked - // reloadPersistedState() diffs against this to tell "the user just // read this buffer" apart from "nothing changed", clearing that // buffer's own unread entry when its value moves. property var _lastKnownReadTs: ({}) // Subset of unreadNotifyBuffers the user has actually pinned (see // BufferList.qml's Pin menu item - same "pinnedBuffers" plugin-data // key) - what actually drives the bar widget's red/flashing state. // Deliberately narrower than unreadNotifyBuffers itself: an unread DM/ // mention on some ordinary, unpinned buffer already has its own // desktop notification (see showChatNotification below); the pill // itself only escalates for buffers important enough to have been // pinned. // // Plain properties, recomputed explicitly by recomputePinnedAlert() // rather than declared as `readonly property var: ` bindings - // QML's automatic dependency tracking through a .filter() callback // closure (reading two other properties from inside the lambda body) // isn't reliably picked up in every case, which is exactly the kind // of "counts up fine, flash doesn't reliably follow" symptom this was // producing. Called explicitly at every point either input // (unreadNotifyBuffers/pinnedBuffers) actually changes instead, so // there's no dependency-tracking gap to hit. property var pinnedAlertBufferIds: [] property bool hasPinnedAlert: false function recomputePinnedAlert() { root.pinnedAlertBufferIds = root.unreadNotifyBuffers.filter(id => root.pinnedBuffers.indexOf(id) !== -1); root.hasPinnedAlert = root.pinnedAlertBufferIds.length > 0; root.publishState(); } function reloadPersistedState() { root.pinnedBuffers = PluginService.loadPluginData("dankChat", "pinnedBuffers", []); const currentReadTs = PluginService.loadPluginData("dankChat", "lastReadTs", {}); const kept = root.unreadNotifyBuffers.filter(id => { return !(currentReadTs[id] !== undefined && currentReadTs[id] !== root._lastKnownReadTs[id]); }); root._lastKnownReadTs = currentReadTs; root.unreadNotifyBuffers = kept; root.recomputePinnedAlert(); } function markBufferNotified(bufferId) { if (root.unreadNotifyBuffers.indexOf(bufferId) === -1) { root.unreadNotifyBuffers = root.unreadNotifyBuffers.concat([bufferId]); root.recomputePinnedAlert(); } } // Same mutedBuffers key BufferList.qml's Pin/Mute/Close menu writes to // (see its own comment for the hierarchical-mute design) - this // daemon surface is what actually fires the desktop notification, so // it's the one place that needs to check it, not the chat UI itself. // No buffer-list tracking lives here (this surface only cares about // status, not full buffer state), so the server-buffer cascade check // reconstructs the id from IRC's own "nick@host" account-id // convention rather than looking one up - a reasonable IRC-specific // shortcut given this is also the only backend that exists today. function isMuted(accountId, bufferId) { const muted = PluginService.loadPluginData("dankChat", "mutedBuffers", []); if (muted.indexOf(bufferId) !== -1) return true; const atIdx = accountId.indexOf("@"); if (atIdx === -1) return false; const serverBufferId = accountId + "|" + accountId.slice(atIdx + 1); return muted.indexOf(serverBufferId) !== -1; } function totalUnread() { // Count of buffers with a pending unread DM/mention, not a raw // message count (chatd's "notification" event - what // unreadNotifyBuffers is built from - only fires once per DM/ // highlight, not per message) - a real, meaningful number instead // of the permanent 0 this used to return before buffer-level // tracking existed here. return root.unreadNotifyBuffers.length; } function publishState() { if (!pluginService) return; pluginService.setGlobalVar(pluginId, "chatdConnected", root.chatdConnected); pluginService.setGlobalVar(pluginId, "accountStates", root.accountStates); pluginService.setGlobalVar(pluginId, "totalUnread", root.totalUnread()); pluginService.setGlobalVar(pluginId, "hasPinnedAlert", root.hasPinnedAlert); } // unreadNotifyBuffers/pinnedBuffers deliberately have no onXChanged // handler here - recomputePinnedAlert() already publishes explicitly // right after every point either one actually changes (markBuffer // Notified, reloadPersistedState), so a signal-handler-driven publish // here would just be a redundant, harmless-but-pointless second call. onChatdConnectedChanged: publishState() onAccountStatesChanged: publishState() property bool restartPending: false function restartChatd() { console.log("[dankChat] restart requested"); if (!chatdProcess.running) { chatdProcess.running = true; return; } // Wait for the real exit rather than immediately flipping running // back to true - respawning before the old OS process has // actually torn down risks racing its own flock()-based singleton // lock (see chatd.c) against the replacement. root.restartPending = true; chatdProcess.running = false; } Process { id: chatdProcess // The Rust daemon has no --pidgin-src equivalent (no libpurple) - // it takes no required arguments, defaulting to // ~/.config/dms-chat/ for its data dir. command: [root.chatdPath] running: false // StdioCollector only fires once the stream *closes* (i.e. when // chatd exits) - while it's running normally, none of its output // (including libpurple's own debug logging) would ever reach the // DMS log. SplitParser streams line-by-line as it's produced. stdout: SplitParser { splitMarker: "\n" onRead: line => console.log("[dankChat/chatd]", line) } stderr: SplitParser { splitMarker: "\n" onRead: line => console.log("[dankChat/chatd stderr]", line) } onExited: exitCode => { // A nonzero exit right at startup usually just means a chatd // from a previous shell session is already holding the // socket - not an error worth surfacing, statusSocket will // connect to whichever one is actually listening. console.log("[dankChat] chatd process exited, code:", exitCode); if (root.restartPending) { root.restartPending = false; chatdProcess.running = true; } } onRunningChanged: console.log("[dankChat] chatdProcess.running =", running, "command:", JSON.stringify(command)) } // Desktop notification with the sender's real avatar as its icon when // one is available (see model.rs's Message.avatarUrl / the // "notification" event's own avatarUrl field - Discord only, covers // our own sent messages too since Discord's gateway echo of those // carries the same real author object as anyone else's). `dms notify // --icon` takes a local file/icon-theme reference, not a URL, so a // remote avatar has to be fetched to a local temp file first - argv // arrays throughout (never a shell string), so nothing here is at risk // from a hostile display name/body even though those are fully // attacker-controlled remote content. property int notificationAvatarSeq: 0 function showChatNotification(title, body, avatarUrl) { // Falls back to no icon (rather than queuing) when a fetch is // already in flight for a previous notification - notifications // are infrequent enough that this only matters for two arriving // within the same couple hundred milliseconds, and a missing icon // on one of them is a fine trade for not building a real queue. if (!avatarUrl || notificationAvatarFetch.running) { Quickshell.execDetached(["dms", "notify", title, body, "--app", "Dank Chat"]); return; } root.notificationAvatarSeq += 1; const ext = avatarUrl.split("?")[0].split(".").pop().slice(0, 4) || "png"; const path = "/tmp/dankchat-notify-avatar-" + root.notificationAvatarSeq + "." + ext; notificationAvatarFetch.title = title; notificationAvatarFetch.body = body; notificationAvatarFetch.iconPath = path; notificationAvatarFetch.command = ["curl", "-sL", "--max-time", "3", avatarUrl, "-o", path]; notificationAvatarFetch.running = true; } Process { id: notificationAvatarFetch property string title: "" property string body: "" property string iconPath: "" running: false onExited: exitCode => { const args = ["dms", "notify", title, body, "--app", "Dank Chat"]; if (exitCode === 0) args.push("--icon", iconPath); Quickshell.execDetached(args); } } DankSocket { id: statusSocket path: root.socketPath // `connected: true` as a static declared value is this 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. Assigning it imperatively in onCompleted instead // does fire the change notification. This was the actual reason // nothing ever connected, not just a timing race. connected: false Component.onCompleted: connected = true onConnectionStateChanged: { // `connected` here would resolve to this socket's own static // `connected: true` above (always true, we never change it) - // not the real link state. linkUp is the actual live status. console.log("[dankChat] statusSocket linkUp =", linkUp, "path:", root.socketPath); root.chatdConnected = linkUp; if (linkUp) { send({ "id": "status-subscribe", "method": "listAccounts" }); } } parser: SplitParser { onRead: line => { if (!line) return; let msg; try { msg = JSON.parse(line); } catch (e) { console.warn("dankChat: bad line from chatd:", line.substring(0, 200)); return; } if (msg.event === "connectionState") { const states = Object.assign({}, root.accountStates); states[msg.data.accountId] = msg.data.state; root.accountStates = states; } else if (msg.event === "notification" && msg.data) { if (!root.isMuted(msg.data.accountId, msg.data.bufferId)) { root.showChatNotification(msg.data.title || "Dank Chat", msg.data.body || "", msg.data.avatarUrl || ""); root.markBufferNotified(msg.data.bufferId); } } else if (msg.id === "status-subscribe" && msg.result) { const states = {}; for (const acct of msg.result) states[acct.id] = acct.state; root.accountStates = states; } } } } function toggle() { if (popoutWindow.visible) { popoutWindow.toggle(); return; } if (slideoutVariants.instances.length > 0) slideoutVariants.instances[0].toggle(); } // Same swap the header's own pop-out button drives (see ChatView.qml's // popoutRequested -> the slideout's onPopoutRequested handler below) - // just reachable without the slideout having to be open first. Already // floating is a no-op rather than re-showing/refocusing, since // `FloatingWindow.visible = true` on an already-visible window has no // useful effect to add here. function openPopout() { if (popoutWindow.visible) return; if (slideoutVariants.instances.length > 0) slideoutVariants.instances[0].hide(); popoutWindow.show(); } // Best-effort extra entry point (`dms ipc call dankChat toggle`/ // `popout`) for scripting/keybinds - same pattern // ExampleCompositePlugin/CompositeDaemon.qml demonstrates. Not the // primary path for toggle: see the "visible" global-var watcher below // for why. IpcHandler { target: "dankChat" function toggle(): string { root.toggle(); return "OK"; } function popout(): string { root.openPopout(); return "OK"; } } // Primary show/hide path: PluginService.togglePlugin() (which // `dms ipc call plugins toggle ` and, in principle, dms-ai- // assistant's daemon-instance.toggle() convention both rely on) // didn't reliably find this composite plugin's daemon instance when // tested against this DMS version. Global vars are the one // cross-surface mechanism already confirmed working in this plugin // (chatdConnected/totalUnread above), so the bar widget bumps a // "toggleNonce" counter instead and this just reacts to it changing - // deliberately not tracking/comparing actual visibility here, since // the window/slideout can also be hidden via their own close buttons // and a "desired visible" var would drift out of sync with that. Connections { target: pluginService function onGlobalVarChanged(changedPluginId, varName) { if (changedPluginId === root.pluginId && varName === "toggleNonce") root.toggle(); // Same mechanism, for DaemonStatusSettings.qml's "Restart // daemon" button in DMS's global Settings -> Plugins pane. else if (changedPluginId === root.pluginId && varName === "restartNonce") root.restartChatd(); } } // Separate from the toggleNonce/restartNonce Connections above - this // is the *data* change signal (PluginService.savePluginData), not the // global-var one, same distinction ChatView.qml's own identical // Connections block already relies on. Reacts to BufferList.qml's Pin // menu item (pinnedBuffers) and ChatView.qml's markBufferRead // (lastReadTs) - both are how a pinned alert gets raised or cleared // while this surface has no chat window open to tell it directly. Connections { target: PluginService function onPluginDataChanged(changedPluginId) { if (changedPluginId === root.pluginId) root.reloadPersistedState(); } } Variants { id: slideoutVariants model: Quickshell.screens delegate: DankSlideout { id: slideout required property var modelData title: "Dank Chat" slideoutWidth: 420 expandable: true expandedWidthValue: 720 // Open already at the larger width - DankSlideout's own // unfold_more/unfold_less button (rendered because // expandable: true) still lets the user shrink it back down // manually, but the useful default for a chat client with an // account/buffer sidebar is the wide view, not something you // have to remember to expand every time you open it. expandedWidth: true content: ChatView { onPopoutRequested: { slideout.hide(); popoutWindow.show(); } } } } ChatPopoutWindow { id: popoutWindow onDockRequested: { popoutWindow.hide(); if (slideoutVariants.instances.length > 0) slideoutVariants.instances[0].show(); } } Component.onCompleted: { chatdProcess.running = true; root.reloadPersistedState(); } }