- 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]>
431 lines
17 KiB
QML
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])
|
|
}
|
|
}
|
|
}
|