- 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]>
1212 lines
54 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|