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

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

1212 lines
54 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
import qs.Common
import qs.Services
import qs.Widgets
// Groups buffers under a collapsible header per account ("Salastil@irc.
// libera.chat (IRC)" - richer per-network display names are a nice-to-have
// once accounts carry that metadata, not blocking) rather than one flat
// list - the frontend already treats "which service" as first-class (see
// AccountsPanel.qml), so the buffer list should read that way too once
// there's more than one account/service connected.
ColumnLayout {
id: root
property var buffers: []
property string activeBufferId: ""
property var accounts: []
signal bufferSelected(string bufferId)
signal bufferCloseRequested(string bufferId)
signal accountsRequested
signal settingsRequested
// Carries the account this "+" click was for (whichever one the
// avatar bar itself is currently showing - see currentAccount below),
// since each protocol now opens its own dedicated join page rather
// than one page listing every account.
signal joinPanelRequested(string accountId)
signal connectionToggleRequested(string accountId, bool connect)
// Persisted (not just in-memory) for the same reason ChatView.qml's
// sidebarFolded/activeBufferId are: this list is a fresh instance every
// time the slideout/popout is closed and reopened, not just a hidden
// one, so anything collapsed here would otherwise silently re-expand
// the next time it's shown.
property var collapsedAccounts: PluginService.loadPluginData("dankChat", "collapsedAccounts", ({}))
// Protocol-agnostic favorites: a plain array of bufferIds
// ("<accountId>|<name>"), persisted via PluginService's per-plugin data
// store (same convention ChatView.qml's messageFilters already use)
// rather than anything chatd/the daemon knows about - pinning is a
// pure display preference, not a protocol concept, so it has no
// business being part of the wire contract.
property var pinnedBuffers: PluginService.loadPluginData("dankChat", "pinnedBuffers", [])
property bool pinnedCollapsed: PluginService.loadPluginData("dankChat", "pinnedCollapsed", false)
onPinnedCollapsedChanged: PluginService.savePluginData("dankChat", "pinnedCollapsed", pinnedCollapsed)
function isPinned(bufferId) {
return root.pinnedBuffers.indexOf(bufferId) !== -1;
}
function togglePin(bufferId) {
const current = root.pinnedBuffers.slice();
const idx = current.indexOf(bufferId);
if (idx === -1)
current.push(bufferId);
else
current.splice(idx, 1);
root.pinnedBuffers = current;
PluginService.savePluginData("dankChat", "pinnedBuffers", current);
}
// Muting: a plain array of bufferIds, same persistence convention as
// pinnedBuffers - a pure display/notification preference, not
// anything chatd needs to know about (see ChatDaemon.qml, which reads
// this same key to decide whether to actually fire a desktop
// notification for an incoming "notification" event).
//
// Mute is hierarchical: muting a server buffer cascades to every
// channel/DM under that account for notification-suppression purposes
// (isEffectivelyMuted below), matching how muting an entire IRC
// network is normally an all-or-nothing thing. It's deliberately a
// simple two-level model, not a 3-state "inherit/muted/unmuted"
// override system - the context menu's Mute/Unmute always reflects
// and toggles a buffer's *own* flag, independent of whether it's also
// currently muted via cascade (same as muting a channel independently
// of an already-muted workspace in Slack). To restore notifications
// for everything under a muted server, unmute the server itself.
property var mutedBuffers: PluginService.loadPluginData("dankChat", "mutedBuffers", [])
// Discord-only: a guild can easily have 30+ text channels (confirmed
// live against a real account), which would otherwise flood this list
// for every other buffer. Channels are grouped per-guild (parsed from
// the buffer name's "Guild/#channel" convention - see backend/
// discord.rs, no wire-contract change needed for this) under a
// collapsible header with its own persisted display cap, independent
// of the account-level collapse above. Keyed by "accountId|guildName"
// rather than a real guild id since that's all the buffer name gives
// us - a guild-name collision within one account is the accepted edge
// case (same spirit as other documented judgment calls in this file).
property var discordGuildChannelLimits: PluginService.loadPluginData("dankChat", "discordGuildChannelLimits", ({}))
// Also covers the Direct Messages pseudo-guild group, which reuses this
// same map (see guildHeaderDelegate below) - persisted for the same
// reason collapsedAccounts above is.
property var collapsedGuilds: PluginService.loadPluginData("dankChat", "collapsedGuilds", ({}))
function guildLimitFor(guildKey) {
const v = root.discordGuildChannelLimits[guildKey];
return v === undefined ? 10 : v; // -1 means "show all"
}
function setGuildLimit(guildKey, limit) {
const current = Object.assign({}, root.discordGuildChannelLimits);
current[guildKey] = limit;
root.discordGuildChannelLimits = current;
PluginService.savePluginData("dankChat", "discordGuildChannelLimits", current);
}
function toggleGuildCollapsed(guildKey) {
const c = Object.assign({}, root.collapsedGuilds);
c[guildKey] = !c[guildKey];
root.collapsedGuilds = c;
PluginService.savePluginData("dankChat", "collapsedGuilds", c);
}
// Buffer *names* stay "GuildName/#channel" internally (still what
// groups channels by guild above, and still what keeps two different
// guilds' same-named channels from colliding into one buffer id) - this
// is purely a display-layer strip so the sidebar just reads "#channel",
// the guild header above it already says which guild. No other buffer
// kind's name ever contains "/", so this is a safe no-op for IRC/DM/etc.
function displayName(name) {
const idx = name.lastIndexOf("/");
return idx === -1 ? name : name.slice(idx + 1);
}
// Hiding is a one-way trip from this menu, unlike Pin/Mute's toggle -
// a hidden channel or DM just disappears from this list entirely, and
// the only way back is AccountsPanel.qml's own "Hidden" list per
// account (which reads/writes this exact same persisted key). Distinct
// from Close: Close (IRC) actually leaves the channel server-side, and
// for a DM specifically does nothing at all to stop the other side
// from just sending another message and recreating the buffer - Hide
// is a purely local, durable "get this out of my way" that survives
// reconnects and new incoming messages alike, which is exactly what a
// one-sided DM spammer needs (confirmed as the real motivating case).
property var hiddenBuffers: PluginService.loadPluginData("dankChat", "hiddenBuffers", [])
function isHidden(bufferId) {
return root.hiddenBuffers.indexOf(bufferId) !== -1;
}
function hideBuffer(bufferId) {
if (root.isHidden(bufferId))
return;
const current = root.hiddenBuffers.slice();
current.push(bufferId);
root.hiddenBuffers = current;
PluginService.savePluginData("dankChat", "hiddenBuffers", current);
}
function isMuted(bufferId) {
return root.mutedBuffers.indexOf(bufferId) !== -1;
}
function isEffectivelyMuted(buffer) {
if (root.isMuted(buffer.id))
return true;
if (buffer.kind === "server")
return false;
const serverBuf = root.buffers.find(b => b.accountId === buffer.accountId && b.kind === "server");
return serverBuf ? root.isMuted(serverBuf.id) : false;
}
function toggleMute(bufferId) {
const current = root.mutedBuffers.slice();
const idx = current.indexOf(bufferId);
if (idx === -1)
current.push(bufferId);
else
current.splice(idx, 1);
root.mutedBuffers = current;
PluginService.savePluginData("dankChat", "mutedBuffers", current);
}
Connections {
target: PluginService
function onPluginDataChanged(changedPluginId) {
if (changedPluginId === "dankChat") {
root.pinnedBuffers = PluginService.loadPluginData("dankChat", "pinnedBuffers", []);
root.pinnedCollapsed = PluginService.loadPluginData("dankChat", "pinnedCollapsed", false);
root.mutedBuffers = PluginService.loadPluginData("dankChat", "mutedBuffers", []);
root.discordGuildChannelLimits = PluginService.loadPluginData("dankChat", "discordGuildChannelLimits", ({}));
root.collapsedGuilds = PluginService.loadPluginData("dankChat", "collapsedGuilds", ({}));
root.collapsedAccounts = PluginService.loadPluginData("dankChat", "collapsedAccounts", ({}));
root.hiddenBuffers = PluginService.loadPluginData("dankChat", "hiddenBuffers", []);
}
}
}
function accountFor(accountId) {
return root.accounts.find(a => a.id === accountId) || null;
}
// Real brand marks where DMS already bundles one (Discord, Matrix) -
// returns "" for anything else so callers know to fall back to
// serviceIconGlyph() instead. IRC/XMPP/Slack have no bundled logo
// asset on this system to draw from.
function serviceIconSvg(service) {
if (service === "discord")
return "/usr/share/quickshell/dms/assets/discord.svg";
if (service === "matrix")
return "/usr/share/quickshell/dms/assets/matrix-logo-white.svg";
return "";
}
// Both bundled brand SVGs (Matrix's white logo, Discord's blurple one)
// get recolored to the exact same token used for the Material-Symbol
// fallback icons and the buffer/account name text next to them, via
// the alpha-mask technique below (serviceIconMask) - not just eyeballed
// close, so they actually line up.
function serviceIconColor(service) {
if (service === "matrix" || service === "discord")
return Theme.surfaceVariantText;
return "transparent";
}
function resolvedIconUrl(path) {
return path.startsWith("/") ? "file://" + path : path;
}
// Closest-fitting Material Symbol for a service with no bundled brand
// asset (see serviceIconSvg) - "tag" for IRC matches the literal "#"
// channel-prefix convention already used for the channel-kind icon
// elsewhere in this file; "workspaces" echoes Slack's own terminology
// for a team/server; "forum" is the closest generic-chat stand-in for
// XMPP, which has no single widely-recognized mark of its own.
function serviceIconGlyph(service) {
if (service === "irc")
return "tag";
if (service === "slack")
return "workspaces";
if (service === "xmpp")
return "forum";
// Sneedchat (SockChat) runs exclusively over Tor - no bundled brand
// asset exists for it, "vpn_lock" is the closest Material Symbol
// evocative of that transport.
if (service === "sockchat")
return "vpn_lock";
return "chat";
}
function toggleAccountCollapsed(accountId) {
const c = Object.assign({}, root.collapsedAccounts);
c[accountId] = !c[accountId];
root.collapsedAccounts = c;
PluginService.savePluginData("dankChat", "collapsedAccounts", c);
}
// The account tied to whatever buffer is currently open, falling back
// to the first known account - there's no single global identity across
// accounts the way Discord has one user across servers, so this is a
// best-effort "whose voice are you using right now" rather than a
// strict requirement.
readonly property var currentAccount: {
if (root.activeBufferId) {
const buf = root.buffers.find(b => b.id === root.activeBufferId);
if (buf) {
const acct = root.accounts.find(a => a.id === buf.accountId);
if (acct)
return acct;
}
}
return root.accounts.length > 0 ? root.accounts[0] : null;
}
// Most-recent-activity-first - see model.rs's Buffer.lastActivityTs
// (bumped live on every new message, seeded from persisted scrollback
// otherwise). Used for DMs, ungrouped channels, and the channels within
// each Discord guild group, so a channel/DM with a fresh reply floats
// to the top of whatever stack it's in - which is also what makes the
// guild "Show N channels" limit actually surface the active ones
// instead of an arbitrary/alphabetical slice.
function byActivity(list) {
return list.slice().sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
}
function avatarColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++)
hash = (hash << 5) - hash + name.charCodeAt(i);
const hue = Math.abs(hash) % 360;
return Qt.hsla(hue / 360, 0.5, 0.45, 1);
}
// Flattened into one list (account header rows + their buffer rows)
// rather than nested Repeaters, so the whole thing stays one
// virtualized, scrollable ListView.
readonly property var rows: {
const result = [];
// Hidden buffers are filtered out up front - nowhere in this list
// (pinned, guild-grouped, or flat) ever shows one. See hideBuffer()/
// isHidden() above and AccountsPanel.qml's "Hidden" list for the
// only way back.
const visibleBuffers = root.buffers.filter(b => !root.isHidden(b.id));
// Pinned section first, aggregating across every account/protocol -
// a pinned buffer also still appears in its own account's normal
// section below (same convention as Slack/Discord starred items).
const pinnedBufs = root.byActivity(visibleBuffers.filter(b => root.pinnedBuffers.includes(b.id)));
if (pinnedBufs.length > 0) {
result.push({ "rowType": "pinnedHeader" });
if (!root.pinnedCollapsed) {
for (const buf of pinnedBufs)
result.push({ "rowType": "buffer", "buffer": buf, "showAccountTag": true });
}
}
const seenAccountIds = [];
for (const account of root.accounts) {
seenAccountIds.push(account.id);
result.push({ "rowType": "account", "account": account });
if (root.collapsedAccounts[account.id])
continue;
const acctBuffers = visibleBuffers.filter(b => b.accountId === account.id);
if (account.service !== "discord") {
for (const buf of root.byActivity(acctBuffers))
result.push({ "rowType": "buffer", "buffer": buf, "showAccountTag": false });
continue;
}
// Discord: channel buffers are named "GuildName/#channel" (see
// backend/discord.rs) - split those into per-guild collapsible
// groups; DMs have no "/" and render flat like any other buffer.
const guildOrder = [];
const guildBuffers = {};
const flatBuffers = [];
for (const buf of acctBuffers) {
const slashIdx = buf.kind === "channel" ? buf.name.indexOf("/") : -1;
if (slashIdx === -1) {
flatBuffers.push(buf);
continue;
}
const guildName = buf.name.slice(0, slashIdx);
if (!guildBuffers[guildName]) {
guildBuffers[guildName] = [];
guildOrder.push(guildName);
}
guildBuffers[guildName].push(buf);
}
// Discord DMs are consolidated under their own collapsible
// "Direct Messages" group, the same collapse/limit mechanism
// as a real guild (reusing guildHeaderDelegate/collapsedGuilds/
// guildLimitFor via a synthetic "__dms__" guild key) - an
// account with many DMs shouldn't flood the sidebar any more
// than a big guild's channel list should.
const dmBuffers = root.byActivity(flatBuffers.filter(b => b.kind === "dm"));
const otherFlat = flatBuffers.filter(b => b.kind !== "dm");
for (const buf of root.byActivity(otherFlat))
result.push({ "rowType": "buffer", "buffer": buf, "showAccountTag": false });
if (dmBuffers.length > 0) {
const dmKey = account.id + "|__dms__";
result.push({ "rowType": "guildHeader", "guildName": "Direct Messages", "guildKey": dmKey, "count": dmBuffers.length });
if (!root.collapsedGuilds[dmKey]) {
const limit = root.guildLimitFor(dmKey);
const shown = limit < 0 ? dmBuffers : dmBuffers.slice(0, limit);
for (const buf of shown)
result.push({ "rowType": "buffer", "buffer": buf, "showAccountTag": false, "indent": true });
if (limit >= 0 && dmBuffers.length > limit)
result.push({ "rowType": "guildMore", "guildKey": dmKey, "remaining": dmBuffers.length - limit });
}
}
// Guilds themselves also float by their most-active channel, on
// top of the per-guild channel ordering below - an actively
// chatty guild rises in the guild list the same way an active
// channel rises within it.
const guildActivity = {};
for (const guildName of guildOrder)
guildActivity[guildName] = Math.max(...guildBuffers[guildName].map(b => b.lastActivityTs || 0));
guildOrder.sort((a, b) => guildActivity[b] - guildActivity[a]);
for (const guildName of guildOrder) {
const guildKey = account.id + "|" + guildName;
const channels = root.byActivity(guildBuffers[guildName]);
result.push({ "rowType": "guildHeader", "guildName": guildName, "guildKey": guildKey, "count": channels.length });
if (root.collapsedGuilds[guildKey])
continue;
const limit = root.guildLimitFor(guildKey);
const shown = limit < 0 ? channels : channels.slice(0, limit);
for (const buf of shown)
result.push({ "rowType": "buffer", "buffer": buf, "showAccountTag": false, "indent": true });
if (limit >= 0 && channels.length > limit)
result.push({ "rowType": "guildMore", "guildKey": guildKey, "remaining": channels.length - limit });
}
}
// Buffers whose account isn't in `accounts` for some reason
// shouldn't just silently disappear.
for (const buf of visibleBuffers.filter(b => !seenAccountIds.includes(b.accountId)))
result.push({ "rowType": "buffer", "buffer": buf, "showAccountTag": false });
return result;
}
spacing: 0
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: root.rows
delegate: Loader {
width: ListView.view.width
sourceComponent: {
if (modelData.rowType === "account")
return accountHeaderDelegate;
if (modelData.rowType === "pinnedHeader")
return pinnedHeaderDelegate;
if (modelData.rowType === "guildHeader")
return guildHeaderDelegate;
if (modelData.rowType === "guildMore")
return guildMoreDelegate;
return bufferRowDelegate;
}
Component {
id: pinnedHeaderDelegate
Rectangle {
height: 32
width: parent.width
color: Theme.surfaceContainerHigh
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingXS
DankIcon {
name: root.pinnedCollapsed ? "chevron_right" : "expand_more"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
DankIcon {
name: "push_pin"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: "Pinned"
font.weight: Font.Medium
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
}
}
MouseArea {
anchors.fill: parent
onClicked: root.pinnedCollapsed = !root.pinnedCollapsed
}
}
}
Component {
id: accountHeaderDelegate
Rectangle {
// Deliberately NOT a "required property var modelData"
// here - unlike a real Repeater/ListView delegate, a
// Component instantiated via Loader.sourceComponent
// gets no automatic property injection. This only
// works at all because `modelData` is an ambient
// context property from the outer Loader (itself the
// real ListView delegate), visible here via normal QML
// context lookup since this Component is declared
// textually inside that Loader. Declaring a same-named
// "required property" would shadow that context
// property with an object property nothing ever sets,
// leaving it uninitialized (confirmed - that's exactly
// what previously broke this delegate).
height: 32
width: parent.width
color: Theme.surfaceContainerHigh
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingXS
DankIcon {
name: root.collapsedAccounts[modelData.account.id] ? "chevron_right" : "expand_more"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
// A colored MultiEffect (see the pinned-buffer
// tag's identical block below for why) can't just
// tint these bundled brand SVGs to an arbitrary flat
// color - "colorization" blends toward the target
// hue while still preserving the source's own
// luminance, so a fairly dark source (either logo)
// stayed visibly dark no matter the target color.
// Using the source as a hard alpha mask over a flat
// Rectangle instead guarantees the exact target
// color wherever the logo has any opacity at all.
Item {
visible: root.serviceIconSvg(modelData.account.service) !== ""
implicitWidth: Theme.iconSizeSmall
implicitHeight: Theme.iconSizeSmall
Rectangle {
id: acctSvgFill
anchors.fill: parent
color: root.serviceIconColor(modelData.account.service)
visible: false
}
Image {
id: acctSvgMask
anchors.fill: parent
// Neither bundled logo is actually square
// (Discord's viewBox is ~126x96, Matrix's
// ~75x32) - stretching either to fill a
// square icon slot squashed it noticeably.
fillMode: Image.PreserveAspectFit
source: root.resolvedIconUrl(root.serviceIconSvg(modelData.account.service))
sourceSize.width: width * 2
sourceSize.height: height * 2
smooth: true
asynchronous: true
visible: false
}
MultiEffect {
anchors.fill: parent
source: acctSvgFill
maskEnabled: true
maskSource: acctSvgMask
maskThresholdMin: 0.3
maskSpreadAtMin: 0.5
}
}
DankIcon {
visible: root.serviceIconSvg(modelData.account.service) === ""
name: root.serviceIconGlyph(modelData.account.service)
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: modelData.account.displayName
wrapMode: Text.NoWrap
elide: Text.ElideRight
font.weight: Font.Medium
font.pixelSize: Theme.fontSizeSmall
color: modelData.account.state === "connected" ? Theme.surfaceText : Theme.surfaceVariantText
}
Rectangle {
width: 8
height: 8
radius: 4
color: modelData.account.state === "connected" ? Theme.primary : Theme.outlineMedium
}
}
MouseArea {
anchors.fill: parent
onClicked: root.toggleAccountCollapsed(modelData.account.id)
}
}
}
Component {
id: guildHeaderDelegate
Rectangle {
id: guildHeaderRow
// Captured into a real property (not left as the bare
// ambient `modelData`) because the limit menu below
// nests a Repeater whose own delegate re-declares
// `modelData` for its own {label,value} model item -
// that shadows the outer context property completely
// within the nested scope, same trap accountHeaderDelegate's
// own comment describes, just one level deeper here.
property var guildRowData: modelData
height: 28
width: parent.width
color: Theme.surfaceContainer
HoverHandler {
id: guildHover
}
MouseArea {
anchors.fill: parent
onClicked: root.toggleGuildCollapsed(guildHeaderRow.guildRowData.guildKey)
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: guildLimitMenu.popup()
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingL + Theme.spacingS
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingXS
DankIcon {
name: root.collapsedGuilds[guildHeaderRow.guildRowData.guildKey] ? "chevron_right" : "expand_more"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: guildHeaderRow.guildRowData.guildName
wrapMode: Text.NoWrap
elide: Text.ElideRight
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
// Right-click anywhere on the row opens the same
// menu (TapHandler above) - this icon just makes
// the control discoverable without needing to know
// that.
DankIcon {
name: "tune"
size: Theme.iconSizeSmall
visible: guildHover.hovered || tuneHover.hovered
color: tuneHover.hovered ? Theme.primary : Theme.surfaceVariantText
HoverHandler {
id: tuneHover
}
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: guildLimitMenu.popup()
}
}
}
Menu {
id: guildLimitMenu
width: 180
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: BlurService.enabled ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius
border.width: 1
border.color: Theme.outlineStrong
}
Repeater {
model: [
{ "label": "Show 5 channels", "value": 5 },
{ "label": "Show 10 channels", "value": 10 },
{ "label": "Show 15 channels", "value": 15 },
{ "label": "Show 25 channels", "value": 25 },
{ "label": "Show all channels", "value": -1 }
]
MenuItem {
required property var modelData
text: modelData.label
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: root.guildLimitFor(guildHeaderRow.guildRowData.guildKey) === parent.modelData.value ? Theme.primary : Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.setGuildLimit(guildHeaderRow.guildRowData.guildKey, modelData.value)
}
}
}
}
}
Component {
id: guildMoreDelegate
Rectangle {
height: 26
width: parent.width
color: "transparent"
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingL + Theme.spacingM + Theme.spacingS
anchors.rightMargin: Theme.spacingS
StyledText {
Layout.fillWidth: true
text: "+" + modelData.remaining + " more..."
font.pixelSize: Theme.fontSizeSmall
font.italic: true
color: Theme.surfaceVariantText
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.setGuildLimit(modelData.guildKey, -1)
}
}
}
Component {
id: bufferRowDelegate
Rectangle {
id: bufferRow
height: 32
width: parent.width
color: modelData.buffer.id === root.activeBufferId ? Theme.surfaceContainerHighest : "transparent"
readonly property string acctService: {
const acct = root.accountFor(modelData.buffer.accountId);
return acct ? acct.service : "";
}
HoverHandler {
id: rowHover
}
MouseArea {
anchors.fill: parent
onClicked: root.bufferSelected(modelData.buffer.id)
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: bufferContextMenu.popup()
}
RowLayout {
anchors.fill: parent
// Extra indent for channels nested under a guild
// header (see rows' "indent" flag) so the grouping
// reads visually, not just via the header above it.
anchors.leftMargin: modelData.indent ? Theme.spacingL + Theme.spacingM : Theme.spacingL
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingXS
// Protocol-agnostic by design (same reasoning as
// everywhere else - see NickList.qml): keys off the
// wire contract's buffer "kind" field, not IRC
// specifically, so this looks right once XMPP/
// Matrix/Discord/Slack buffers start showing up too.
// A room with its own avatar (Matrix only - see
// model.rs's Buffer.avatarUrl) shows that instead
// of the generic kind icon, same fallback shape as
// a message's own avatar column above.
Item {
implicitWidth: Theme.iconSizeSmall
implicitHeight: Theme.iconSizeSmall
DankIcon {
anchors.fill: parent
visible: !modelData.buffer.avatarUrl
name: {
if (modelData.buffer.kind === "channel")
return "tag";
if (modelData.buffer.kind === "dm")
return "chat_bubble";
return "dns";
}
size: Theme.iconSizeSmall
color: modelData.buffer.highlight ? Theme.primary : Theme.surfaceVariantText
}
Rectangle {
anchors.fill: parent
visible: !!modelData.buffer.avatarUrl
radius: width / 2
clip: true
Image {
anchors.fill: parent
source: modelData.buffer.avatarUrl || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
}
StyledText {
Layout.fillWidth: true
text: root.displayName(modelData.buffer.name)
color: modelData.buffer.highlight ? Theme.primary : Theme.surfaceText
wrapMode: Text.NoWrap
elide: Text.ElideRight
}
// Pinned buffers are pulled out of their account
// section and shown together at the top - a small
// service icon keeps that context from getting lost.
// Alpha-masked the same way as the account header's
// copy of this (see its comment) - MultiEffect's own
// colorization blend wasn't enough to fully flatten
// either bundled brand SVG to an arbitrary color.
Item {
visible: modelData.showAccountTag === true && root.serviceIconSvg(bufferRow.acctService) !== ""
implicitWidth: Theme.iconSizeSmall
implicitHeight: Theme.iconSizeSmall
Rectangle {
id: pinnedSvgFill
anchors.fill: parent
color: root.serviceIconColor(bufferRow.acctService)
visible: false
}
Image {
id: pinnedSvgMask
anchors.fill: parent
fillMode: Image.PreserveAspectFit
source: root.resolvedIconUrl(root.serviceIconSvg(bufferRow.acctService))
sourceSize.width: width * 2
sourceSize.height: height * 2
smooth: true
asynchronous: true
visible: false
}
MultiEffect {
anchors.fill: parent
source: pinnedSvgFill
maskEnabled: true
maskSource: pinnedSvgMask
maskThresholdMin: 0.3
maskSpreadAtMin: 0.5
}
}
DankIcon {
visible: modelData.showAccountTag === true && root.serviceIconSvg(bufferRow.acctService) === ""
name: root.serviceIconGlyph(bufferRow.acctService)
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
visible: (modelData.buffer.unread || 0) > 0
text: modelData.buffer.unread
color: Theme.primary
font.pixelSize: Theme.fontSizeSmall
}
// Shown whenever muted, whether by its own flag or
// inherited from a muted server (see
// isEffectivelyMuted) - purely informational, the
// actual toggle lives in the right-click menu.
DankIcon {
visible: root.isEffectivelyMuted(modelData.buffer)
name: "notifications_off"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
// Channels/DMs only - pinning the always-present,
// unclosable server buffer (kind "server") doesn't
// mean anything. Only reveals on hover, same
// convention as close below - including for an
// already-pinned item, so the Pinned section itself
// doesn't stay cluttered with pin icons at rest.
DankIcon {
id: pinIcon
name: "push_pin"
size: Theme.iconSizeSmall
color: root.isPinned(modelData.buffer.id) ? Theme.primary : (pinHover.hovered ? Theme.primary : Theme.surfaceVariantText)
visible: rowHover.hovered && modelData.buffer.kind !== "server"
rotation: root.isPinned(modelData.buffer.id) ? 0 : 45
HoverHandler {
id: pinHover
}
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.togglePin(modelData.buffer.id)
}
}
// The server buffer (kind "server" - see
// model.h's chatd_buffer_kind()) always exists
// once connected and chatd itself refuses to
// close it - no point offering a button for it.
DankIcon {
name: "close"
size: Theme.iconSizeSmall
color: closeHover.hovered ? Theme.error : Theme.surfaceVariantText
visible: rowHover.hovered && modelData.buffer.kind !== "server"
HoverHandler {
id: closeHover
}
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.bufferCloseRequested(modelData.buffer.id)
}
}
}
Menu {
id: bufferContextMenu
width: 180
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: BlurService.enabled ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius
border.width: 1
border.color: Theme.outlineStrong
}
MenuItem {
visible: modelData.buffer.kind !== "server"
height: visible ? implicitHeight : 0
text: root.isPinned(modelData.buffer.id) ? "Unpin" : "Pin"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.togglePin(modelData.buffer.id)
}
MenuItem {
text: root.isMuted(modelData.buffer.id) ? "Unmute" : "Mute"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.toggleMute(modelData.buffer.id)
}
// Channels and DMs - not the server buffer, which
// has no "hide" concept of its own (there's nothing
// to stop it re-recording into if it did). One-way
// from here (see hideBuffer()'s own comment); the
// only way back is AccountsPanel.qml's per-account
// "Hidden" list. DMs matter just as much as
// channels here - a spammer whose messages keep
// recreating the buffer needs a way to keep it out
// of the sidebar for good, same as an unwanted
// channel.
MenuItem {
visible: modelData.buffer.kind === "channel" || modelData.buffer.kind === "dm"
height: visible ? implicitHeight : 0
text: "Hide"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.hideBuffer(modelData.buffer.id)
}
// The server buffer can't be closed (see the close
// icon's own comment above) - offer connect/
// disconnect for the account instead, since that's
// the equivalent "make this go away" action here.
MenuItem {
visible: modelData.buffer.kind === "server"
height: visible ? implicitHeight : 0
text: {
const acct = root.accountFor(modelData.buffer.accountId);
return acct && acct.state !== "disconnected" ? "Disconnect" : "Connect";
}
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: {
const acct = root.accountFor(modelData.buffer.accountId);
root.connectionToggleRequested(modelData.buffer.accountId, !acct || acct.state === "disconnected");
}
}
MenuItem {
visible: modelData.buffer.kind !== "server"
height: visible ? implicitHeight : 0
text: "Close"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.bufferCloseRequested(modelData.buffer.id)
}
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.outlineMedium
visible: root.accounts.length > 0
}
RowLayout {
Layout.fillWidth: true
Layout.margins: Theme.spacingS
visible: root.accounts.length > 0
spacing: Theme.spacingS
Rectangle {
width: 28
height: 28
radius: 14
color: root.currentAccount ? root.avatarColor(root.currentAccount.displayName) : Theme.outlineMedium
clip: true
// Real avatar image when the account has one (currently
// Discord only, see model.rs's Buffer.avatarUrl / accounts.rs's
// avatar_url capture) - falls back to the colored-initial
// circle underneath for IRC and any account with none set.
Image {
anchors.fill: parent
visible: !!(root.currentAccount && root.currentAccount.avatarUrl)
source: root.currentAccount && root.currentAccount.avatarUrl ? root.currentAccount.avatarUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
StyledText {
anchors.centerIn: parent
visible: !(root.currentAccount && root.currentAccount.avatarUrl)
text: root.currentAccount ? root.currentAccount.displayName.charAt(0).toUpperCase() : "?"
color: "white"
font.weight: Font.Bold
}
}
StyledText {
Layout.fillWidth: true
text: root.currentAccount ? root.currentAccount.displayName : "No account"
color: Theme.surfaceText
font.weight: Font.Medium
wrapMode: Text.NoWrap
elide: Text.ElideRight
}
DankIcon {
id: joinIcon
name: "add"
size: Theme.iconSizeSmall
color: joinIconHover.hovered ? Theme.primary : Theme.surfaceVariantText
HoverHandler {
id: joinIconHover
}
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: root.joinPanelRequested(root.currentAccount ? root.currentAccount.id : "")
}
}
DankIcon {
id: bottomMenuIcon
name: "more_horiz"
size: Theme.iconSizeSmall
color: bottomMenuHover.hovered ? Theme.primary : Theme.surfaceVariantText
HoverHandler {
id: bottomMenuHover
}
MouseArea {
id: bottomMenuButton
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: bottomMenu.popup(bottomMenuButton, 0, -bottomMenu.height - Theme.spacingXS)
}
}
}
Menu {
id: bottomMenu
width: 160
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: BlurService.enabled ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius
border.width: 1
border.color: Theme.outlineStrong
}
MenuItem {
text: "Accounts"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.accountsRequested()
}
MenuItem {
text: "Settings"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.settingsRequested()
}
}
}