- 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]>
556 lines
23 KiB
QML
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|