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

431 lines
17 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtCore
import QtMultimedia
import Quickshell
import qs.Common
import qs.Widgets
// Discord-style inline preview for a single URL detected in a message body
// (see MessageList.qml's extractMedia()). Three kinds:
// - "image": loads directly.
// - "video": a direct video file link (.mp4/.webm/...) - click to load and
// play in place via QtMultimedia's Video convenience type (already used
// elsewhere in DMS, see Modules/Lock/VideoScreensaverPlayer.qml). No
// poster frame - getting one means fetching/decoding the file anyway, so
// there's no cheaper preview to show before that click.
// - "youtube": no inline playback - actually streaming YouTube's video
// requires either a full web engine or a stream-resolving helper like
// yt-dlp, neither of which this project depends on. Shows the video's
// public static thumbnail (a single well-known image URL per video id,
// not HTML scraping) and opens the real video in the default browser on
// click - a deliberate scope line, same spirit as the SASL EXTERNAL and
// rich-link-unfurling cuts documented in the README.
Rectangle {
id: root
property string url: ""
property string kind: "image" // "image" | "video" | "youtube"
property string youtubeId: ""
// Only needed for the "expired Discord link" fallback below - which
// message/buffer to ask chatd for a real discord.com deep link to.
property string messageId: ""
property string bufferId: ""
signal openInDiscordRequested(string bufferId, string messageId)
// Every cdn.discordapp.com/media.discordapp.net link Discord hands out
// is signed with an ex=/is=/hm= query string (expiry/issue time/HMAC)
// that lapses roughly 24h after issue. This backend has no way to
// silently re-sign one the way a real client does (Discord's own
// /attachments/refresh-urls endpoint hard-rejects a user-token request
// with a blanket 401 regardless of headers or whether the URL has
// actually expired yet - confirmed live against message
// 1539718847580348537, see rpc/methods.rs's getDiscordMessageLink doc
// comment) - so once expired, the only real fix is opening the actual
// message in a real Discord session instead of trying to render the
// dead link here.
function isDiscordAttachmentUrl(u) {
return /^https:\/\/(cdn\.discordapp\.com|media\.discordapp\.net)\//i.test(u);
}
function attachmentExpiryMs(u) {
const m = u.match(/[?&]ex=([0-9a-fA-F]+)/);
return m ? parseInt(m[1], 16) * 1000 : -1;
}
readonly property bool isDiscordMedia: root.isDiscordAttachmentUrl(root.url)
readonly property real expiryMs: root.isDiscordMedia ? root.attachmentExpiryMs(root.url) : -1
readonly property bool urlExpired: root.expiryMs > 0 && Date.now() >= root.expiryMs
// Set on an actual load failure too (Image/AnimatedImage/MediaPlayer
// error) as a fallback in case a link is bad for some reason expiry
// detection alone wouldn't catch.
property bool loadFailed: false
readonly property bool mediaUnavailable: root.isDiscordMedia && (root.urlExpired || root.loadFailed)
// Never actually points the Image/Video at a known-dead URL - avoids a
// guaranteed-failed request and the broken-image flash that would
// otherwise cause on every view of month-old scrollback.
readonly property string effectiveUrl: root.mediaUnavailable ? "" : root.url
// From DisplaySettingsPanel.qml, threaded down via MessageList - only
// meaningful for an animated image (below); a still image or the
// youtube-thumbnail/video-poster cases just ignore both.
property bool mediaAutoplay: true
property bool mediaLoop: true
// Whether the video has ever been started - keeps the underlying
// Video element (and its network fetch) from being created until the
// user actually asks for playback, same rationale as before this
// gained real controls.
property bool loaded: false
readonly property bool isVideoKind: root.kind === "video"
// Plain QML Image never animates multi-frame formats at all (shows a
// static first frame) - AnimatedImage is the type that actually plays
// them, and is what autoplay/loop below actually control.
readonly property bool isAnimatedImage: root.kind === "image" && /\.(gif|webp)(\?\S*)?$/i.test(root.url)
readonly property real maxW: 360
readonly property real maxH: 260
width: maxW
height: maxH
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
clip: true
Image {
anchors.fill: parent
visible: !root.mediaUnavailable && ((root.kind === "image" && !root.isAnimatedImage) || root.kind === "youtube")
fillMode: Image.PreserveAspectFit
asynchronous: true
source: {
if (root.kind === "image" && !root.isAnimatedImage)
return root.effectiveUrl;
if (root.kind === "youtube")
return "https://i.ytimg.com/vi/" + root.youtubeId + "/hqdefault.jpg";
return "";
}
onStatusChanged: if (status === Image.Error && root.kind === "image") root.loadFailed = true
}
AnimatedImage {
id: animatedImage
anchors.fill: parent
visible: root.isAnimatedImage && !root.mediaUnavailable
fillMode: Image.PreserveAspectFit
asynchronous: true
source: root.isAnimatedImage ? root.effectiveUrl : ""
playing: root.mediaAutoplay
onStatusChanged: if (status === AnimatedImage.Error) root.loadFailed = true
// This build's AnimatedImage has no "loops" property at all (not
// just defaulted - genuinely absent from its metaobject, confirmed
// via qmltypes; assigning to it is a hard "Cannot assign to
// non-existent property" load error, not a silent no-op). Detect a
// full pass manually instead: currentFrame wrapping from the last
// frame back to 0 means one loop just completed.
property int lastFrame: -1
onCurrentFrameChanged: {
if (!root.mediaLoop && animatedImage.lastFrame === animatedImage.frameCount - 1 && animatedImage.currentFrame === 0)
animatedImage.pause();
animatedImage.lastFrame = animatedImage.currentFrame;
}
}
// Autoplay off still needs some way to start it - a paused
// AnimatedImage otherwise looks indistinguishable from a broken/still
// image with no obvious next step.
DankIcon {
anchors.centerIn: parent
name: "play_circle"
size: 40
color: "white"
visible: root.isAnimatedImage && !root.mediaAutoplay && !animatedImage.playing
}
// Built from MediaPlayer/VideoOutput/AudioOutput directly rather than
// QtMultimedia's own Video convenience type: Video.qml wires an
// AudioOutput with no explicit device, and on this system's ffmpeg
// multimedia backend that silently produced no sound at all (a known
// Qt6/Linux issue - the backend can auto-detect the wrong audio sink,
// e.g. picking PipeWire's raw API when PulseAudio/pipewire-pulse is
// what's actually routing audio). Pointing AudioOutput at
// MediaDevices.defaultAudioOutput explicitly sidesteps that
// auto-detection. Every property/function name below matches what
// Video.qml itself exposes, so nothing downstream that references
// `videoItem` needed to change.
Item {
id: videoItem
anchors.fill: parent
visible: root.isVideoKind && root.loaded && !root.mediaUnavailable
property alias playbackState: player.playbackState
property alias duration: player.duration
property alias position: player.position
property alias source: player.source
function play() {
player.play();
}
function pause() {
player.pause();
}
VideoOutput {
id: videoOutput
anchors.fill: parent
fillMode: VideoOutput.PreserveAspectFit
}
MediaDevices {
id: mediaDevices
}
MediaPlayer {
id: player
source: root.loaded ? root.effectiveUrl : ""
videoOutput: videoOutput
audioOutput: AudioOutput {
device: mediaDevices.defaultAudioOutput
}
onSourceChanged: if (source != "") play()
onErrorOccurred: root.loadFailed = true
}
}
Rectangle {
anchors.fill: parent
visible: root.isVideoKind && !root.loaded && !root.mediaUnavailable
color: Theme.surfaceContainerHighest
}
DankIcon {
anchors.centerIn: parent
name: "play_circle"
size: 48
color: "white"
visible: !root.mediaUnavailable && (root.kind === "youtube" || (root.isVideoKind && (!root.loaded || videoItem.playbackState !== MediaPlayer.PlayingState)))
}
// The expired-link fallback: no attempt to load a URL we already know
// is dead (see effectiveUrl above) - just a clear explanation and a
// way to actually see the media, in a real Discord session that gets
// its own freshly-signed link.
ColumnLayout {
anchors.centerIn: parent
anchors.margins: Theme.spacingM
visible: root.mediaUnavailable
spacing: Theme.spacingXS
DankIcon {
Layout.alignment: Qt.AlignHCenter
name: "link_off"
size: 32
color: Theme.surfaceVariantText
}
StyledText {
Layout.alignment: Qt.AlignHCenter
text: "Media link expired"
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
}
StyledText {
Layout.alignment: Qt.AlignHCenter
text: "Open in Discord"
color: Theme.primary
font.pixelSize: Theme.fontSizeSmall
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: root.openInDiscordRequested(root.bufferId, root.messageId)
}
}
}
// Play/pause toggle + scrub bar, Discord-style - only shown once the
// video has actually been loaded (before that, the whole rectangle is
// itself the play button).
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Theme.spacingXS
spacing: Theme.spacingXS
visible: root.isVideoKind && root.loaded
DankIcon {
name: videoItem.playbackState === MediaPlayer.PlayingState ? "pause" : "play_arrow"
size: Theme.iconSizeSmall
color: "white"
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: {
if (videoItem.playbackState === MediaPlayer.PlayingState) {
videoItem.pause();
} else {
// Reaching the end naturally leaves playbackState
// at Stopped with position sitting at (or very
// near) duration - videoItem's own Video-convenience-
// type ancestor exposes no mediaStatus to check
// for EndOfMedia directly, and just calling play()
// again from there silently no-ops on this
// backend rather than restarting from the
// beginning the way clicking play on a finished
// video should.
if (videoItem.duration > 0 && videoItem.position >= videoItem.duration - 250)
videoItem.position = 0;
videoItem.play();
}
}
}
}
Rectangle {
id: scrubTrack
Layout.fillWidth: true
height: 4
radius: 2
color: Theme.withAlpha("white", 0.35)
Rectangle {
height: parent.height
radius: parent.radius
color: "white"
width: videoItem.duration > 0 ? parent.width * (videoItem.position / videoItem.duration) : 0
}
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
// preventStealing so a horizontal scrub drag can't get
// reinterpreted as a vertical scroll gesture by the
// message list this is embedded in partway through.
preventStealing: true
function seekToX(x) {
if (videoItem.duration <= 0)
return;
// videoItem has no setPosition() at all - it's a
// Video-convenience-type-shaped Item (see its own
// definition above), which exposes position as a
// plain settable property, not a method - calling a
// method that doesn't exist here silently did
// nothing, which is why the scrub bar never actually
// seeked.
const ratio = Math.max(0, Math.min(1, x / scrubTrack.width));
videoItem.position = Math.round(ratio * videoItem.duration);
}
onPressed: mouse => seekToX(mouse.x)
onPositionChanged: mouse => {
if (pressed)
seekToX(mouse.x);
}
}
}
}
MouseArea {
anchors.fill: parent
visible: !root.mediaUnavailable && !(root.isVideoKind && root.loaded)
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.isVideoKind)
root.loaded = true;
else if (root.isAnimatedImage && !root.mediaAutoplay)
animatedImage.playing = !animatedImage.playing;
else
Qt.openUrlExternally(root.effectiveUrl);
}
}
TapHandler {
acceptedButtons: Qt.RightButton
enabled: !root.mediaUnavailable
onTapped: mediaContextMenu.popup()
}
Menu {
id: mediaContextMenu
width: 220
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
}
function mediaLabel() {
return root.isVideoKind ? "Video" : "Image";
}
function downloadsPath() {
const dl = "" + StandardPaths.writableLocation(StandardPaths.DownloadLocation);
const dir = dl.replace(/^file:\/\//, "");
const base = root.url.split("?")[0].split("/").pop() || ("media-" + Date.now());
return dir + "/" + base;
}
MenuItem {
text: "Copy " + mediaContextMenu.mediaLabel()
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
}
// "-d" downloads the URL and copies it to the clipboard as a
// real image/file payload (not just the URL text) - same
// convention Discord's own "Copy Image" uses.
onTriggered: Quickshell.execDetached(["dms", "clipboard", "copy", "-d", root.effectiveUrl])
}
MenuItem {
text: "Download " + mediaContextMenu.mediaLabel()
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: Quickshell.execDetached(["curl", "-sL", "-o", mediaContextMenu.downloadsPath(), root.effectiveUrl])
}
MenuItem {
text: "Copy " + mediaContextMenu.mediaLabel() + " URL"
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: Quickshell.execDetached(["dms", "clipboard", "copy", root.effectiveUrl])
}
}
}