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

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

437 lines
19 KiB
QML

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: <expr>` 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 <id>` 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();
}
}