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