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