- 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]>
269 lines
8.3 KiB
QML
269 lines
8.3 KiB
QML
import QtQuick
|
|
import QtQuick.Layouts
|
|
import qs.Common
|
|
import qs.Widgets
|
|
|
|
// Discord's own join/DM page - see IrcJoinPanel.qml's doc comment for why
|
|
// this is a separate per-protocol page rather than one shared page.
|
|
// Discord's own mechanisms: accept a real invite code/link (this backend
|
|
// runs on a user token, not a bot - joining is "accept an invite" the same
|
|
// way any human user does it, not a bot-add flow) or create a brand-new
|
|
// server; a friends list (fetched fresh every time this page loads, see
|
|
// Component.onCompleted) with click-to-DM; or DM someone not yet a friend
|
|
// by their real numeric user ID (no guild-wide member search available to
|
|
// a user-token client - see backend/discord.rs's own userlist-
|
|
// investigation history for why).
|
|
ColumnLayout {
|
|
id: root
|
|
|
|
property var account: null
|
|
// Array of {userId, username, globalName, avatarUrl, status} - fetched
|
|
// by the caller (see friendsRequested below) and handed back in via
|
|
// this prop, same one-way-data-flow shape every other panel here uses.
|
|
property var friends: []
|
|
|
|
signal joinRequested(string invite)
|
|
signal createGuildRequested(string name)
|
|
signal dmRequested(string userId)
|
|
signal friendsRequested
|
|
signal addFriendRequested(string username)
|
|
|
|
// Defaults to the "Online" tab, matching the real Discord client's own
|
|
// default view.
|
|
property bool onlineOnly: true
|
|
|
|
Component.onCompleted: root.friendsRequested()
|
|
|
|
spacing: Theme.spacingS
|
|
|
|
StyledText {
|
|
Layout.fillWidth: true
|
|
text: "Join a server"
|
|
color: Theme.surfaceVariantText
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
|
|
DankTextField {
|
|
Layout.fillWidth: true
|
|
placeholderText: "invite code or discord.gg/..."
|
|
onAccepted: {
|
|
if (text)
|
|
root.joinRequested(text);
|
|
text = "";
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
Layout.topMargin: Theme.spacingS
|
|
Layout.fillWidth: true
|
|
text: "Create a server"
|
|
color: Theme.surfaceVariantText
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
|
|
DankTextField {
|
|
Layout.fillWidth: true
|
|
placeholderText: "server name"
|
|
onAccepted: {
|
|
if (text)
|
|
root.createGuildRequested(text);
|
|
text = "";
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.topMargin: Theme.spacingM
|
|
Layout.fillWidth: true
|
|
spacing: Theme.spacingXS
|
|
|
|
StyledText {
|
|
Layout.fillWidth: true
|
|
text: "Friends"
|
|
font.pixelSize: Theme.fontSizeLarge
|
|
font.weight: Font.Medium
|
|
color: Theme.surfaceText
|
|
}
|
|
|
|
StyledText {
|
|
text: "Online"
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
color: root.onlineOnly ? Theme.primary : Theme.surfaceVariantText
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.onlineOnly = true
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
text: "/"
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
color: Theme.surfaceVariantText
|
|
}
|
|
|
|
StyledText {
|
|
text: "All"
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
color: !root.onlineOnly ? Theme.primary : Theme.surfaceVariantText
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.onlineOnly = false
|
|
}
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
Layout.fillWidth: true
|
|
visible: root.friends.length === 0
|
|
text: "No friends to show yet."
|
|
color: Theme.surfaceVariantText
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
|
|
Repeater {
|
|
// Online-first when the "All" tab is showing offline friends too,
|
|
// then alphabetical - matches the ordering a real Discord client's
|
|
// friends list uses.
|
|
model: {
|
|
const shown = root.onlineOnly ? root.friends.filter(f => f.status !== "offline") : root.friends;
|
|
return shown.slice().sort((a, b) => {
|
|
const aOffline = a.status === "offline";
|
|
const bOffline = b.status === "offline";
|
|
if (aOffline !== bOffline)
|
|
return aOffline ? 1 : -1;
|
|
const an = (a.globalName || a.username).toLowerCase();
|
|
const bn = (b.globalName || b.username).toLowerCase();
|
|
return an < bn ? -1 : (an > bn ? 1 : 0);
|
|
});
|
|
}
|
|
|
|
RowLayout {
|
|
id: friendRow
|
|
required property var modelData
|
|
|
|
Layout.fillWidth: true
|
|
spacing: Theme.spacingXS
|
|
|
|
Item {
|
|
implicitWidth: 24
|
|
implicitHeight: 24
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
visible: !!friendRow.modelData.avatarUrl
|
|
radius: width / 2
|
|
clip: true
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
source: friendRow.modelData.avatarUrl || ""
|
|
fillMode: Image.PreserveAspectCrop
|
|
asynchronous: true
|
|
}
|
|
}
|
|
|
|
DankIcon {
|
|
anchors.centerIn: parent
|
|
visible: !friendRow.modelData.avatarUrl
|
|
name: "person"
|
|
size: Theme.iconSizeSmall
|
|
color: Theme.surfaceVariantText
|
|
}
|
|
|
|
Rectangle {
|
|
width: 8
|
|
height: 8
|
|
radius: 4
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
border.width: 2
|
|
border.color: Theme.surfaceContainerHigh
|
|
color: {
|
|
if (friendRow.modelData.status === "online")
|
|
return Theme.success;
|
|
if (friendRow.modelData.status === "idle")
|
|
return Theme.warning;
|
|
if (friendRow.modelData.status === "dnd")
|
|
return Theme.error;
|
|
return Theme.outlineMedium;
|
|
}
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
Layout.fillWidth: true
|
|
text: friendRow.modelData.globalName || friendRow.modelData.username
|
|
color: Theme.surfaceText
|
|
wrapMode: Text.NoWrap
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
DankIcon {
|
|
name: "chat_bubble"
|
|
size: Theme.iconSizeSmall
|
|
color: dmHover.hovered ? Theme.primary : Theme.surfaceVariantText
|
|
|
|
HoverHandler {
|
|
id: dmHover
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.dmRequested(friendRow.modelData.userId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
Layout.topMargin: Theme.spacingS
|
|
Layout.fillWidth: true
|
|
text: "Add a friend"
|
|
color: Theme.surfaceVariantText
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
|
|
DankTextField {
|
|
Layout.fillWidth: true
|
|
placeholderText: "username"
|
|
onAccepted: {
|
|
if (text)
|
|
root.addFriendRequested(text);
|
|
text = "";
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
Layout.topMargin: Theme.spacingS
|
|
Layout.fillWidth: true
|
|
text: "DM by user ID"
|
|
color: Theme.surfaceVariantText
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
|
|
DankTextField {
|
|
Layout.fillWidth: true
|
|
placeholderText: "user ID"
|
|
onAccepted: {
|
|
if (text)
|
|
root.dmRequested(text);
|
|
text = "";
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
Layout.fillWidth: true
|
|
text: "For someone not in your friends list above: their real Discord user ID, not a username - right-click their profile in any Discord client and \"Copy User ID\" (Developer Mode on)."
|
|
wrapMode: Text.WordWrap
|
|
font.pixelSize: Theme.fontSizeSmall - 1
|
|
color: Theme.surfaceVariantText
|
|
}
|
|
}
|