Files
dms-chat/views/NickList.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

556 lines
23 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Common
import qs.Services
import qs.Widgets
// Right-hand member list for the active channel buffer. Two grouping modes:
// "tier" (default, IRC) - owner/moderator/regular by rank prefix, sorted
// alphabetically within each. "presence" (Matrix) - online/offline by
// away, sorted by numeric powerLevel descending then alphabetically within
// each - see rows below. Protocol-agnostic by design: everything here works
// off the wire contract's presenceChange shape ({nick, prefix, away},
// optionally userId/powerLevel for presence mode), same as BufferList/
// MessageList never assume IRC specifically - it just happens to be the
// first backend that emitted this event (see project plan; Matrix now
// emits it too - see backend/matrix/roomstate.rs's emit_matrix_presence).
ColumnLayout {
id: root
property var members: [] // [{nick, prefix, away}] or, in "presence" mode, [{nick, userId, away, powerLevel}]
property string ownNick: ""
// Matrix's own identity is its full user id (mxid), not a display name
// (which can collide/be renamed) - used instead of ownNick for isSelf
// and mod-action targeting when groupMode is "presence".
property string ownUserId: ""
property string accountId: ""
property var blockedNicks: [] // "<accountId>|<nick>" entries, from ChatView
property string groupMode: "tier" // "tier" (IRC) | "presence" (Matrix)
property bool canKick: false
property bool canBan: false
property bool canMute: false
signal messageRequested(string nick)
signal blockRequested(string nick)
signal modActionRequested(string command, string nick)
signal kickRequested(string userId)
signal banRequested(string userId)
signal muteRequested(string userId)
signal openDmRequested(string userId, string displayName)
property bool searchActive: false
property string searchQuery: ""
onSearchActiveChanged: if (!searchActive) root.searchQuery = ""
spacing: 0
function isBlocked(nick) {
return root.blockedNicks.indexOf(root.accountId + "|" + nick) !== -1;
}
// Simple subsequence match (query chars must all appear in order, not
// necessarily contiguous) - the standard lightweight "fuzzy find"
// behavior (same idea as e.g. VS Code's file switcher), good enough
// for narrowing down a nick without needing a real scoring algorithm.
function fuzzyMatch(query, text) {
if (!query)
return true;
const q = query.toLowerCase();
const t = text.toLowerCase();
let qi = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi])
qi++;
}
return qi === q.length;
}
function tierOf(prefix) {
if (prefix === "~")
return "owner";
if (prefix === "@" || prefix === "%")
return "moderator";
return "regular";
}
readonly property string myRank: {
const me = root.members.find(m => m.nick === root.ownNick);
return me ? me.prefix : "";
}
// Founder/op can grant or remove op; founder/op/halfop can kick, ban,
// and voice/devoice - a common, conservative reading of what each IRC
// rank conventionally lets you do (halfops typically can't hand out
// op on most ircds).
readonly property bool canModerate: ["~", "@", "%"].includes(root.myRank)
readonly property bool canGrantOp: ["~", "@"].includes(root.myRank)
// Fuzzy-find mode replaces the normal owner/moderator/regular grouping
// entirely with a flat, ungrouped list of matches - the point is to
// cut straight to "which of these 1500 people is named roughly this,"
// not to browse tiers while also filtering them.
readonly property var searchResults: {
if (!root.searchActive)
return [];
const matches = root.members.filter(m => root.fuzzyMatch(root.searchQuery, m.nick));
matches.sort((a, b) => a.nick.localeCompare(b.nick, undefined, { "sensitivity": "base" }));
return matches;
}
readonly property var rows: {
if (root.searchActive)
return root.searchResults;
if (root.groupMode === "presence")
return root.presenceRows;
const owner = [];
const moderator = [];
const regular = [];
for (const m of root.members) {
const tier = root.tierOf(m.prefix);
if (tier === "owner")
owner.push(m);
else if (tier === "moderator")
moderator.push(m);
else
regular.push(m);
}
const byNick = (a, b) => a.nick.localeCompare(b.nick, undefined, { "sensitivity": "base" });
owner.sort(byNick);
moderator.sort(byNick);
regular.sort(byNick);
const result = [];
for (const group of [owner, moderator, regular]) {
if (group.length === 0)
continue;
if (result.length > 0)
result.push({ "divider": true });
for (const m of group)
result.push(m);
}
return result;
}
// Two major divisions - online then offline - each sorted by numeric
// power level descending, alphabetically among ties (e.g. power level
// 100: alice, bob; power level 90: carol, dave; ...).
readonly property var presenceRows: {
const online = [];
const offline = [];
for (const m of root.members)
(m.away ? offline : online).push(m);
const byRankThenNick = (a, b) => (b.powerLevel || 0) - (a.powerLevel || 0) || a.nick.localeCompare(b.nick, undefined, { "sensitivity": "base" });
online.sort(byRankThenNick);
offline.sort(byRankThenNick);
const result = [];
for (const group of [{ "label": "Online", "members": online }, { "label": "Offline", "members": offline }]) {
if (group.members.length === 0)
continue;
result.push({ "divider": true, "label": group.label + " (" + group.members.length + ")" });
for (const m of group.members)
result.push(m);
}
return result;
}
RowLayout {
Layout.fillWidth: true
Layout.margins: Theme.spacingS
spacing: Theme.spacingXS
StyledText {
Layout.fillWidth: true
visible: !root.searchActive
text: "Members (" + root.members.length + ")"
font.weight: Font.Medium
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankTextField {
id: searchField
Layout.fillWidth: true
visible: root.searchActive
placeholderText: "Find a member..."
text: root.searchQuery
onTextChanged: root.searchQuery = text
Component.onCompleted: if (root.searchActive) forceActiveFocus()
}
DankIcon {
name: root.searchActive ? "close" : "search"
size: Theme.iconSizeSmall
color: searchIconHover.hovered ? Theme.primary : Theme.surfaceVariantText
HoverHandler {
id: searchIconHover
}
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
onClicked: {
root.searchActive = !root.searchActive;
if (root.searchActive)
Qt.callLater(() => searchField.forceActiveFocus());
}
}
}
}
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: root.rows
ScrollBar.vertical: DankScrollbar {}
delegate: Loader {
required property var modelData
width: ListView.view.width
sourceComponent: modelData.divider === true ? dividerDelegate : memberDelegate
Component {
id: dividerDelegate
ColumnLayout {
width: parent ? parent.width : 0
spacing: 2
StyledText {
Layout.fillWidth: true
Layout.leftMargin: Theme.spacingM
Layout.topMargin: Theme.spacingXS
visible: !!modelData.label
text: modelData.label || ""
font.weight: Font.Medium
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.outlineMedium
}
}
}
Component {
id: memberDelegate
Rectangle {
id: memberRow
width: parent ? parent.width : 0
height: 28
color: rowHover.hovered ? Theme.surfaceContainerHigh : "transparent"
readonly property bool isSelf: root.groupMode === "presence" ? (!!modelData.userId && modelData.userId === root.ownUserId) : modelData.nick === root.ownNick
HoverHandler {
id: rowHover
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: Qt.PointingHandCursor
}
TapHandler {
acceptedButtons: Qt.RightButton
enabled: !memberRow.isSelf
onTapped: contextMenu.popup()
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingXS
StyledText {
readonly property string rankLabel: root.groupMode === "presence" ? String(modelData.powerLevel !== undefined ? modelData.powerLevel : "") : modelData.prefix
visible: rankLabel !== ""
text: rankLabel
color: Theme.primary
font.weight: Font.Bold
}
StyledText {
Layout.fillWidth: true
text: modelData.nick
color: modelData.away ? Theme.surfaceVariantText : Theme.surfaceText
opacity: modelData.away ? 0.6 : 1
wrapMode: Text.NoWrap
elide: Text.ElideRight
}
DankIcon {
visible: root.isBlocked(modelData.nick)
name: "block"
size: Theme.iconSizeSmall
color: Theme.error
}
}
Menu {
id: contextMenu
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 {
// "Message" sends a literal /msg <nick> slash
// command (see ChatView.qml's
// nickMessageRequested) - an IRC-only mechanic,
// so hidden in presence mode rather than
// silently sending broken literal text into a
// Matrix room.
visible: root.groupMode === "tier"
height: visible ? implicitHeight : 0
text: "Message"
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.messageRequested(modelData.nick)
}
MenuItem {
// Matrix's own equivalent of "Message" - opens
// (creating if needed) a real 1:1 DM room with
// this member via openMatrixDm, rather than the
// /msg-slash-command mechanic the tier-mode
// "Message" item above uses.
visible: root.groupMode === "presence" && !memberRow.isSelf
height: visible ? implicitHeight : 0
text: "Open DM"
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.openDmRequested(modelData.userId, modelData.nick)
}
MenuItem {
// IRC has no protocol-level block/ignore - this
// is a purely local display filter (see
// ChatView.qml's isBlocked/toggleBlocked), so
// it's always "capable" here regardless of
// server support.
text: root.isBlocked(modelData.nick) ? "Unblock" : "Block"
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.blockRequested(modelData.nick)
}
MenuSeparator {
visible: root.groupMode === "tier" && root.canModerate
height: visible ? implicitHeight : 0
}
MenuItem {
visible: root.groupMode === "tier" && root.canModerate
height: visible ? implicitHeight : 0
text: "Kick"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.modActionRequested("kick", modelData.nick)
}
MenuItem {
visible: root.groupMode === "tier" && root.canModerate
height: visible ? implicitHeight : 0
text: "Ban"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.modActionRequested("ban", modelData.nick)
}
MenuItem {
visible: root.groupMode === "tier" && root.canGrantOp
height: visible ? implicitHeight : 0
text: modelData.prefix === "@" ? "Remove Op" : "Give Op"
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.modActionRequested(modelData.prefix === "@" ? "deop" : "op", modelData.nick)
}
MenuItem {
visible: root.groupMode === "tier" && root.canModerate
height: visible ? implicitHeight : 0
text: modelData.prefix === "+" ? "Remove Voice" : "Give Voice"
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.modActionRequested(modelData.prefix === "+" ? "devoice" : "voice", modelData.nick)
}
MenuSeparator {
visible: root.groupMode === "presence" && (root.canMute || root.canKick || root.canBan)
height: visible ? implicitHeight : 0
}
MenuItem {
visible: root.groupMode === "presence" && root.canMute
height: visible ? implicitHeight : 0
text: "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.muteRequested(modelData.userId)
}
MenuItem {
visible: root.groupMode === "presence" && root.canKick
height: visible ? implicitHeight : 0
text: "Kick"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.kickRequested(modelData.userId)
}
MenuItem {
visible: root.groupMode === "presence" && root.canBan
height: visible ? implicitHeight : 0
text: "Ban"
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.banRequested(modelData.userId)
}
}
}
}
}
}
}