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

2496 lines
115 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
// Log-style message view (nick-colored senders, timestamps, highlighted
// row background for mentions) rather than chat bubbles - matches IRC
// client conventions. See the project plan's "Chat UI" section for why
// this diverges from e.g. the AI assistant plugin's MessageBubble.qml.
ListView {
id: root
property var messages: []
// Buffer id this instance currently shows - not used for rendering,
// only to detect an actual buffer switch (as opposed to the same
// buffer's message array growing) so scroll-position bookkeeping
// below doesn't leak from one buffer into another.
property string bufferId: ""
// Owned by ChatView.qml (per-buffer, since more than one buffer's
// pagination could be in flight across the app) - this instance only
// reads it, to avoid firing loadMoreRequested again while a fetch for
// the currently-shown buffer is already outstanding.
property bool loadingMore: false
// Whether the active buffer's account is Discord - drives whether
// reply clicks become a real Discord reply vs. IRC's plain "nick: "
// prefill (see ChatView.qml's onReplyRequested). Edit/Delete visibility
// is gated separately by canEditDelete below, since Sneedchat supports
// those too despite having no native-reply concept of its own.
property bool isDiscord: false
// Gates smiley-shortcode rendering (see substituteSmilies) to only
// Sneedchat buffers - a bare ":)" or ";)" in an IRC/Discord message is
// just text there and must never turn into an image.
property bool isSockchat: false
// Sneedchat's site-wide smiley table, bundled with the plugin rather
// than fetched from kiwifarms.st - see ChatView.qml's sockchatSmilies/
// fetchSockchatSmilies. [{label, aliases, url}].
property var sockchatSmilies: []
// The active buffer's guild's own custom Discord emoji - same data
// ChatView.qml's own composer EmojiPicker already uses
// (bufferEmojiByBuffer), threaded through here too for the shared
// reaction picker below (reactionPicker/openReactionPicker).
property var customEmoji: []
// Whether the active buffer's service supports editing/deleting your
// own messages at all (Discord, Sneedchat, and Matrix) - see
// ChatView.qml's activeBufferSupportsEditDelete.
property bool canEditDelete: false
// This account's own moderation permissions in the active buffer's
// room (Matrix only - see backend/matrix/moderation.rs's
// permissions_json/ChatView.qml's activeBufferPermissions). Gate
// *offering* these actions at all; the homeserver is still what
// actually enforces them, same as canEditDelete's own Delete already
// relies on server-side redaction rules for anything beyond "is this
// my own message".
property bool canRedactOthers: false
property bool canKick: false
property bool canBan: false
property bool canMute: false
// The id of the message currently being edited inline, or "" - only
// ever one at a time. Cleared on submit/Escape or when the buffer
// changes out from under it.
property string editingId: ""
// Discord-style "New" divider boundary - a unix ts owned by
// ChatView.qml (see its dividerTsByBuffer/markBufferRead), snapshotted
// once when this buffer was switched into and held fixed for the
// duration of that viewing session. 0 means "don't show a divider at
// all" (covers both "never seen this buffer before" and "everything
// already read").
property real dividerTs: 0
// From DisplaySettingsPanel.qml, via ChatView.qml - see the delegate's
// showHeader/isGrouped for what comfyMode actually changes visually.
property bool comfyMode: false
property bool mediaAutoplay: true
property bool mediaLoop: true
property bool relativeTimestamps: false
// From DisplaySettingsPanel.qml ("Embed unrecognized links by content
// type") - see sniffUrl's own doc comment for what this actually does
// and why it defaults on despite the privacy trade-off the settings
// panel's description spells out.
property bool contentSniffingEnabled: true
// formatTime()'s relative-mode strings ("8m ago") go stale as real
// time passes without any of their own inputs changing, so nothing
// would normally trigger the bound StyledText to re-evaluate - this
// tick just gives that binding something to depend on that changes on
// its own. 30s is finer than the coarsest displayed unit (minutes)
// without re-evaluating needlessly often.
property int timeTick: 0
Timer {
interval: 30000
running: root.relativeTimestamps
repeat: true
onTriggered: root.timeTick += 1
}
// ShareX-style personal upload hosts that serve images/screenshots at
// an opaque short-code path (e.g. https://i.ddos.lgbt/u/vRRSUD) with no
// file extension anywhere in the URL - extractMedia's normal
// extension-based detection can never match these no matter how the
// regex is tuned, since the information just isn't in the URL text.
//
// Checked before falling through to sniffUrl's Content-Type probe
// below: a known host needs no network round-trip at all to classify,
// so it's strictly better to list one here than to rely on sniffing
// for it. Add more hostnames here as they come up.
readonly property var opaqueImageHosts: ["i.ddos.lgbt"]
// Every URL sniffUrl has ever resolved, keyed by URL: "image", "video",
// or "none" (checked, not media). Shared across every message row
// (rather than each row probing independently) so the same link
// appearing twice - or scrolling a row back into view - doesn't repeat
// a request that's already answered. `sniffInFlight` is a plain dedupe
// guard, not meant to drive any binding.
property var sniffedTypes: ({})
property var sniffInFlight: ({})
// Content-Type sniffing for links extractMedia otherwise has no way to
// classify (no recognizable extension, not a known host from
// opaqueImageHosts above) - see DisplaySettingsPanel.qml's "Embed
// unrecognized links by content type" toggle, which this is gated on
// and defaults on despite the trade-off described there: this is a
// plain HTTP(S) request straight from the frontend, not routed through
// chatd or (for Sneedchat) its embedded Tor client, so it reveals a
// connection to whatever's linked the instant a message arrives,
// whether or not it's ever clicked. Fire-and-forget: extractMedia
// re-evaluates on its own once sniffedTypes gains the answer (reading
// root.sniffedTypes inside a QML binding makes the binding depend on
// it, same as reading any other property).
function sniffUrl(url) {
if (root.sniffedTypes[url] !== undefined || root.sniffInFlight[url])
return;
root.sniffInFlight[url] = true;
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE)
return;
delete root.sniffInFlight[url];
const contentType = xhr.getResponseHeader("Content-Type") || "";
let kind = "none";
if (contentType.indexOf("image/") === 0)
kind = "image";
else if (contentType.indexOf("video/") === 0)
kind = "video";
const next = Object.assign({}, root.sniffedTypes);
next[url] = kind;
root.sniffedTypes = next;
};
try {
xhr.open("HEAD", url);
xhr.send();
} catch (e) {
delete root.sniffInFlight[url];
}
}
// Comfy mode's own grouping window - consecutive messages from the
// same author within this many seconds of each other collapse under
// one avatar/name header, same idea as Discord's own grouping (it uses
// a similar few-minutes threshold, not a fixed message count).
readonly property int groupWindowSecs: 300
function isGroupedWithPrevious(index) {
if (!root.comfyMode || index <= 0)
return false;
const cur = root.messages[index];
const prev = root.messages[index - 1];
if (!cur || !prev)
return false;
if (cur.isAction || prev.isAction || cur.kind !== "chat" || prev.kind !== "chat")
return false;
if (cur.from !== prev.from)
return false;
if (!!cur.replyTo)
return false; // a reply always shows its own context header
return Math.abs(cur.ts - prev.ts) <= root.groupWindowSecs;
}
// Index of the first message newer than dividerTs - computed once per
// messages/dividerTs change rather than per-delegate, and deliberately
// NOT recomputed just because more messages appended after this: since
// it always returns the *first* match, appending further messages past
// it doesn't change which one that is, so the line stays put exactly
// like Discord's own does while a channel is actively open.
readonly property int newDividerIndex: {
if (root.dividerTs <= 0)
return -1;
for (let i = 0; i < root.messages.length; i++) {
if (root.messages[i].ts > root.dividerTs)
return i;
}
return -1;
}
signal replyRequested(string id, string from, string body)
signal editSubmitted(string id, string newBody)
signal deleteRequested(string id)
// userId (the message's own senderId, a full Matrix id) rather than
// the message id - kick/ban/mute target a *user*, not a message; the
// message that was right-clicked is just how the user got picked.
signal kickRequested(string userId)
signal banRequested(string userId)
signal muteRequested(string userId)
signal retryRequested(string id)
// add true to add our own reaction, false to remove it - see the
// reaction pills/hover toolbar below. Discord-only (see isDiscord).
signal reactionToggled(string id, string emoji, bool add)
signal loadMoreRequested()
// Bubbled straight up from a MediaEmbed whose Discord attachment link
// has expired (see MediaEmbed.qml's own doc comment) - chatd resolves
// bufferId+messageId into a real discord.com deep link.
signal openInDiscordRequested(string bufferId, string messageId)
// A handful of one-click quick reactions on the hover toolbar (see
// msgRow's hoverToolbar below) - Discord itself shows "frequently
// used" emoji there, which would need its own usage-tracking; these
// are just a fixed, commonly-reached-for set instead, the same
// practical scope cut as EmojiPicker.qml's own commonEmoji list.
readonly property var quickReactEmoji: ["👍", "❤️", "😂", "🔥"]
// Single shared EmojiPicker for "add a reaction" (from either the
// hover toolbar's own button or the "+" pill after existing
// reactions) rather than one Popup instance per message row - with
// up to maxMessagesPerBuffer rows, that would mean hundreds of idle
// Popup instances for one feature. reactingToId tracks which
// message's reactions the next emoji pick actually applies to.
property string reactingToId: ""
function openReactionPicker(id, anchorItem) {
root.reactingToId = id;
reactionPicker.parent = anchorItem;
// Right-aligned to the anchor by default (its own right edge,
// not its left, lines up with the anchor's right edge) rather
// than the previous left-aligned x:0 - the hover toolbar's own
// "add reaction" button sits right at the row's right edge (see
// hoverToolbar), which in a narrow panel is also close to the
// window's own right edge, so opening left-aligned there pushed
// the picker's full 280px width straight past it. Then clamped
// against all four edges of the actual visible message list
// (root, this ListView) using the anchor's real position within
// it, so it stays fully on screen regardless of which button
// opened it, how wide the panel currently is, or how close to
// the bottom of the visible list the reacted-to message is.
const anchorPos = anchorItem.mapToItem(root, 0, 0);
let x = anchorItem.width - reactionPicker.width;
if (anchorPos.x + x < 0)
x = -anchorPos.x;
if (anchorPos.x + x + reactionPicker.width > root.width)
x = root.width - reactionPicker.width - anchorPos.x;
reactionPicker.x = x;
let y = anchorItem.height + Theme.spacingXXS;
if (anchorPos.y + y + reactionPicker.height > root.height)
y = -reactionPicker.height - Theme.spacingXXS;
reactionPicker.y = y;
reactionPicker.open();
}
EmojiPicker {
id: reactionPicker
customEmoji: root.customEmoji
onEmojiSelected: text => {
if (root.reactingToId)
root.reactionToggled(root.reactingToId, text, true);
}
}
// Scrolls to a message already present in the currently-loaded page -
// the reply-preview click target. If the original has scrolled out of
// the locally loaded window (paginated away, or never fetched this
// far back), this is simply a no-op rather than fetching context, a
// reasonable scope limit for a first pass.
function jumpToMessage(id) {
const idx = root.messages.findIndex(m => m.id === id);
if (idx !== -1) {
root.positionViewAtIndex(idx, ListView.Center);
// positionViewAtIndex is a programmatic contentY change, same
// as positionViewAtEnd()/positionViewAtBeginning() - doesn't
// drive dragging/flicking, so _updateAnchored() needs an
// explicit nudge here too, otherwise jumping to an older
// reply target from the bottom would leave anchored (and the
// "Jump to present" bar) not reflecting where the view
// actually ended up.
root._updateAnchored();
}
}
// The actual model backing this view. `messages` (above) stays a
// plain array purely as ChatView.qml's external interface and for
// isGroupedWithPrevious/newDividerIndex/jumpToMessage's own array
// lookups; msgModel is what the ListView actually renders from.
//
// The reason this exists at all: ChatView.qml's visibleMessages()
// returns a brand-new filtered array on every single message (sent
// or received), and QML has no way to diff two different array
// object identities - binding `model:` straight to that array meant
// ANY reassignment, including a single new message, was treated as a
// full model reset: every visible row destroyed and recreated from
// scratch. That's what was producing the visible jerk/scroll-jump
// reported live, no matter how carefully the resulting scroll
// position was corrected afterward. _syncModel() below reconciles
// the new array against this ListModel with targeted append/set/
// remove/insert calls instead, so an ordinary new message only ever
// adds the one new row - every other already-rendered delegate is
// left completely alone.
ListModel {
id: msgModel
}
// True on the very first population and right after a buffer switch
// (see onBufferIdChanged) - a full rebuild is both correct and cheap
// in those cases, and skips a pointless id-diff against an unrelated
// buffer's messages.
property bool _fullResetPending: true
onMessagesChanged: root._syncModel()
Component.onCompleted: root._syncModel()
function _syncModel() {
const newList = root.messages;
// Also unconditionally full-rebuild while a load-more fetch is
// landing (root.loadingMore, set by ChatView.qml around the
// getBacklog round-trip) - that's a batch prepend of many
// messages at once, already handled by the dedicated contentY
// compensation in onContentHeightChanged below, which expects
// exactly one wholesale contentHeight jump rather than the many
// smaller ones a loop of individual insert() calls would produce.
if (root._fullResetPending || msgModel.count === 0 || root.loadingMore) {
root._fullResetPending = false;
msgModel.clear();
for (let i = 0; i < newList.length; i++)
msgModel.append({
"modelData": newList[i]
});
if (newList.length > 0 && !root.loadingMore) {
// Land on the "New" divider (Discord-style) if this buffer
// actually has one, same guard newDividerIndex's own
// renderer uses (index 0 means the oldest loaded message
// is already unread - nothing to usefully center on, so
// that falls through to the plain bottom-of-buffer case
// below instead). Otherwise the buffer's fully read (or
// this is the very first ever open), so land at the true
// bottom where new messages arrive.
if (root.newDividerIndex > 0)
root._pendingScrollToDividerIndex = root.newDividerIndex;
else
root._pendingScrollToEnd = true;
}
return;
}
const oldCount = msgModel.count;
// Fast path: exactly one message appended at the end - by far
// the most common case (a single new live message) - skips the
// general id-reconciliation walk below entirely.
if (newList.length === oldCount + 1) {
let prefixMatches = true;
for (let i = 0; i < oldCount; i++) {
if (msgModel.get(i).modelData.id !== newList[i].id) {
prefixMatches = false;
break;
}
}
if (prefixMatches) {
msgModel.append({
"modelData": newList[oldCount]
});
// Only actually schedule the auto-scroll while anchored -
// otherwise this is exactly the "jerked back down while
// reading scrollback" behavior being fixed here. See
// anchored's own doc comment for why this checks the
// already-known value rather than recomputing it fresh.
if (root.anchored)
root._pendingScrollToEnd = true;
else
root.newMessagesWhileUnanchored += 1;
return;
}
}
// General path: reconcile by id so only rows that actually
// changed get touched - an edit, reaction, or delete elsewhere
// in the buffer shouldn't force the whole visible list (and
// every delegate in it) to be torn down and rebuilt just to
// reflect one row's change.
const newIds = newList.map(m => m.id);
for (let i = msgModel.count - 1; i >= 0; i--) {
if (newIds.indexOf(msgModel.get(i).modelData.id) === -1)
msgModel.remove(i);
}
let cursor = 0;
for (let i = 0; i < newList.length; i++) {
const msg = newList[i];
if (cursor < msgModel.count && msgModel.get(cursor).modelData.id === msg.id) {
if (msgModel.get(cursor).modelData !== msg)
msgModel.set(cursor, {
"modelData": msg
});
} else {
msgModel.insert(cursor, {
"modelData": msg
});
}
cursor++;
}
while (msgModel.count > newList.length)
msgModel.remove(msgModel.count - 1);
if (newList.length > oldCount) {
if (root.anchored)
root._pendingScrollToEnd = true;
else
root.newMessagesWhileUnanchored += newList.length - oldCount;
}
}
model: msgModel
clip: true
spacing: 1
verticalLayoutDirection: ListView.TopToBottom
focus: true
boundsBehavior: Flickable.StopAtBounds
// One blank line of breathing room below the most recent message,
// before the composer bar's divider (ChatView.qml) - included in
// contentHeight like any other margin, so positionViewAtEnd()'s
// existing auto-scroll-to-bottom logic below already accounts for it.
bottomMargin: bodyLineMetrics.height
FontMetrics {
id: bodyLineMetrics
font.pixelSize: Theme.fontSizeMedium
}
ScrollBar.vertical: DankScrollbar {}
// Discord-style "Jump to present" bar - an overlay, not part of the
// ListView's own content/layout, docked to the bottom of the viewport
// and only shown once the user's scrolled away from it (anchored:
// false). A plain child Item here renders on top of the view's own
// scrolling content since it's declared after model/delegate binding,
// the same overlay-on-a-Flickable pattern DankScrollbar itself uses.
Rectangle {
id: jumpToPresentBar
visible: !root.anchored
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 44
z: 10
color: Theme.surfaceContainerHigh
border.color: Theme.outlineMedium
border.width: 1
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingS
StyledText {
Layout.fillWidth: true
text: root.newMessagesWhileUnanchored > 0 ? ("You're viewing older messages - " + root.newMessagesWhileUnanchored + (root.newMessagesWhileUnanchored === 1 ? " new message" : " new messages")) : "You're viewing older messages"
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
DankButton {
text: "Jump to present"
iconName: "arrow_downward"
backgroundColor: Theme.primary
textColor: Theme.primaryText
buttonHeight: 30
onClicked: root.jumpToPresent()
}
}
}
// Page Up/Down move by roughly one viewport height; Home asks for
// more history same as scrolling to the top would (see
// onContentYChanged below) then jumps to the new top once it lands,
// while End just snaps to the live bottom - mirrors Discord/IRC
// client conventions for a log-style view.
Keys.onPressed: event => {
if (event.key === Qt.Key_PageUp) {
root.contentY = Math.max(root.originY, root.contentY - root.height * 0.9);
root._updateAnchored();
event.accepted = true;
} else if (event.key === Qt.Key_PageDown) {
root.contentY = Math.min(root.contentY + root.height * 0.9, root.contentHeight - root.height);
root._updateAnchored();
event.accepted = true;
} else if (event.key === Qt.Key_Home) {
if (root.count > 0 && !root.loadingMore) {
root._contentHeightBeforeLoad = root.contentHeight;
root.loadMoreRequested();
}
root.positionViewAtBeginning();
root._updateAnchored();
event.accepted = true;
} else if (event.key === Qt.Key_End) {
root.jumpToPresent();
event.accepted = true;
}
}
property real _contentHeightBeforeLoad: 0
// Set on a new message (while anchored) or a fresh buffer load with no
// "New" divider to land on instead - driven to completion by
// scrollSettleTimer below, not directly by this file's various
// contentY/contentHeight change handlers.
property bool _pendingScrollToEnd: false
// Set instead of _pendingScrollToEnd when a freshly (re)loaded buffer
// has unread history - the index to center on. -1 means none pending.
property int _pendingScrollToDividerIndex: -1
// True while this file's own machinery is actively driving contentY
// toward a target (scrollSettleTimer, or the prepend-height
// compensation below) - anything that changes contentY during this
// window is expected and must not be misread as the user scrolling.
readonly property bool _programmaticScroll: root._pendingScrollToEnd || root._pendingScrollToDividerIndex >= 0 || root._contentHeightBeforeLoad > 0
// Discord-style "sticky scroll": true while the view is close enough
// to the bottom that a new message should keep auto-scrolling it into
// view; false once the user has deliberately scrolled away from it,
// at which point new messages must NOT yank them back down - they get
// the "Jump to present" bar below instead. Deliberately explicit,
// updated-on-genuine-scroll state rather than a value recomputed
// fresh from contentY/contentHeight on every change: a new message's
// own height change happens *before* positionViewAtEnd() has a chance
// to run, so a purely reactive "am I near the bottom right now" check
// would spuriously read false for someone who was legitimately
// anchored the instant before the message arrived.
property bool anchored: true
// How many messages have arrived since the user last scrolled away
// from the bottom - surfaced on the "Jump to present" bar, reset back
// to 0 on re-anchoring (scrolling back down manually, or clicking the
// bar).
property int newMessagesWhileUnanchored: 0
// Two thresholds, not one - a single cutoff flickered the "Jump to
// present" bar in and out repeatedly while scrolling right around
// that exact distance (confirmed live), since a fast flick's contentY
// doesn't move in one clean step - it lands slightly differently frame
// to frame, crossing back and forth over a single boundary value
// several times in a row. Leaving the bottom requires passing
// unanchorThreshold; coming back requires getting all the way within
// reanchorThreshold - the gap between them is a dead zone where
// whatever the current state already is just holds, so hovering near
// either edge can't flip it back and forth.
readonly property real unanchorThreshold: 250
readonly property real reanchorThreshold: 100
// Safe to call from ANY contentY change at all, including this file's
// own programmatic scrolls - only ever moves *toward* anchored, never
// away from it. Needs to run unconditionally (not just for genuine
// scrolls) so that scrollSettleTimer's repeated positionViewAtEnd()
// calls while auto-scrolling a new message into view reliably end up
// marked anchored again once they actually get there.
function _reanchorIfClose() {
if (root.contentHeight <= root.height + 1) {
if (!root.anchored) {
root.anchored = true;
root.newMessagesWhileUnanchored = 0;
}
return;
}
if (!root.anchored && (root.contentHeight - root.height - root.contentY) <= root.reanchorThreshold) {
root.anchored = true;
root.newMessagesWhileUnanchored = 0;
}
}
// Full bidirectional check - only ever called from a genuine scroll
// action or an intentional jump elsewhere in history (jumpToMessage,
// the "New" divider settle below). This is the only path allowed to
// transition AWAY from anchored.
//
// A programmatic auto-scroll-to-bottom must NOT be allowed to trip
// this, even though on paper it "happens to compute the right
// answer": a new message's delegate can take more than one Qt Quick
// Layouts polish pass to settle its real height (wrapped text,
// comfy-mode avatar sizing), and scrollSettleTimer keeps re-calling
// positionViewAtEnd() each tick while that settles. Between one tick
// and the next, contentY can transiently lag the still-growing
// contentHeight by more than unanchorThreshold - confirmed live as
// new messages silently no longer auto-scrolling into view at all
// after the very first one, because that one stray mid-settle frame
// permanently (and wrongly) flipped a legitimately-anchored view to
// unanchored, and every message after that read _syncModel's
// `if (root.anchored)` check as false forever after.
// _reanchorIfClose() above is what onContentYChanged runs during any
// of this file's own programmatic scrolls instead - it can only ever
// correct back toward anchored, so that failure mode can't happen the
// other way around. Real user input (including scrollbar drags, which
// - unlike a direct touch/mouse drag on the view - don't set this
// Flickable's own dragging/flicking properties at all) always reaches
// this function via onContentYChanged's non-programmatic branch.
function _updateAnchored() {
root._reanchorIfClose();
if (root.anchored && root.contentHeight > root.height + 1 && (root.contentHeight - root.height - root.contentY) > root.unanchorThreshold)
root.anchored = false;
}
function jumpToPresent() {
root.positionViewAtEnd();
root.anchored = true;
root.newMessagesWhileUnanchored = 0;
}
onBufferIdChanged: {
root._contentHeightBeforeLoad = 0;
root.editingId = "";
root._fullResetPending = true;
// Tentatively true - scrollSettleTimer corrects this to false
// once the initial scroll actually lands, in case it's the "New"
// divider case and that isn't at the bottom. Starting optimistic
// rather than pessimistic means a fully-read buffer (the common
// case) never shows a spurious "Jump to present" flash while
// settling.
root.anchored = true;
root.newMessagesWhileUnanchored = 0;
}
// Drives both _pendingScrollToEnd and _pendingScrollToDividerIndex to
// completion. A single positionViewAtEnd()/positionViewAtIndex() call
// right when the flag is set isn't reliable on its own: contentHeight
// only reaches its true, final value once Qt Quick Layouts' deferred
// "polish" pass for every new/resized delegate has actually run, which
// for a bulk load (buffer switch, initial open with hundreds of
// messages) can take many polish passes, not just one or two - a
// fixed number of retries previously gave up too early and could land
// anywhere in the middle of the buffer. This instead keeps retrying
// every frame until the position has actually stopped moving (a few
// consecutive stable ticks), with a generous hard cap as an absolute
// backstop so it can never run forever.
Timer {
id: scrollSettleTimer
interval: 16
repeat: true
running: root._pendingScrollToEnd || root._pendingScrollToDividerIndex >= 0
property int attempts: 0
property real lastY: -1
property int stableTicks: 0
onRunningChanged: {
if (running) {
attempts = 0;
lastY = -1;
stableTicks = 0;
}
}
onTriggered: {
attempts += 1;
if (root._pendingScrollToEnd)
root.positionViewAtEnd();
else
root.positionViewAtIndex(root._pendingScrollToDividerIndex, ListView.Center);
const stable = Math.abs(root.contentY - lastY) < 1;
stableTicks = stable ? stableTicks + 1 : 0;
lastY = root.contentY;
const settled = stableTicks >= 3 || attempts > 90; // ~1.4s hard cap
if (root._pendingScrollToEnd) {
root._reanchorIfClose();
if (root.atYEnd || settled)
root._pendingScrollToEnd = false;
} else if (settled) {
root._pendingScrollToDividerIndex = -1;
// One-time bidirectional correction now that position has
// actually settled - safe here (unlike per-tick) since
// this only runs once, after real settling, not on a
// possibly-transient mid-settle frame.
root._updateAnchored();
}
}
}
// Ask for an older page once the user scrolls near the top. Gated on
// !_programmaticScroll rather than atYBeginning alone: atYBeginning is
// also trivially true for a Flickable whose content is shorter than
// its own viewport (a short conversation, or just a tall window -
// simultaneously "at the beginning" and "at the end"), and separately,
// this file's own programmatic contentY adjustments (scrollSettleTimer,
// the prepend-compensation below) can themselves leave contentY
// sitting back at/near the origin without the user having scrolled at
// all. Both of those previously caused this to fire immediately and
// keep re-firing as each successful fetch legitimately returned more
// real history - confirmed live, one channel's scrollback grew to
// 1499 messages with nobody touching the scrollbar.
onContentYChanged: {
if (root._programmaticScroll) {
// Safe, one-directional check only - see _updateAnchored's own
// doc comment for why the full check must never run here.
root._reanchorIfClose();
return;
}
// Real user input reaches here - a direct drag/flick, a wheel
// tick, or a scrollbar drag (which, unlike a drag on the view
// itself, never sets this Flickable's own dragging/flicking
// properties at all, so checking those specifically here would
// silently miss it - confirmed live as the "Jump to present" bar
// not reliably appearing when scrolling via the scrollbar handle).
root._updateAnchored();
if (root.contentY <= root.originY + 40 && root.count > 0 && !root.loadingMore && !root.anchored) {
root._contentHeightBeforeLoad = root.contentHeight;
root.loadMoreRequested();
}
}
onContentHeightChanged: {
if (root._contentHeightBeforeLoad > 0 && root.contentHeight > root._contentHeightBeforeLoad) {
root.contentY += root.contentHeight - root._contentHeightBeforeLoad;
root._contentHeightBeforeLoad = 0;
}
}
// A response that added nothing (genuinely reached the start of
// history) never triggers the growth branch above, so
// _contentHeightBeforeLoad would otherwise stay set - and then
// wrongly get treated as "still compensating for a prepend" the next
// time contentHeight changes for an unrelated reason (a new live
// message arriving at the bottom), yanking the view up instead of
// just letting it scroll down normally.
onLoadingMoreChanged: {
if (!root.loadingMore)
root._contentHeightBeforeLoad = 0;
}
// Belt-and-suspenders alongside the dragging/flicking check above: on
// a desktop shell, the overwhelmingly common scroll input is a plain
// mouse wheel, and depending on the Qt version a discrete wheel tick
// doesn't always drive Flickable through its drag/flick state machine
// the way a touchpad's kinetic scroll or a click-drag does (confirmed
// live: this worked for one buffer but not another with no difference
// in the QML itself, pointing at exactly this kind of input-path
// inconsistency rather than anything buffer-specific). A WheelHandler
// sees the raw event directly, so it's unambiguous evidence an actual
// scroll just happened, independent of whatever Flickable's own
// dragging/flicking bookkeeping did with it.
WheelHandler {
onWheel: event => {
root._updateAnchored();
if (event.angleDelta.y > 0 && root.atYBeginning && root.count > 0 && !root.loadingMore && !root.anchored) {
root._contentHeightBeforeLoad = root.contentHeight;
root.loadMoreRequested();
}
}
}
// Only for the normal "new message arrived" / "buffer just opened"
// cases - suppressed while a load-more prepend is in flight so it
// doesn't yank the view back down to the bottom the moment older
// messages land (the onContentHeightChanged adjustment above is what
// handles that case instead). Just raises a flag rather than calling
// positionViewAtEnd() directly - see onContentHeightChanged for why.
// Gated on anchored for the same reason _syncModel's own paths are -
// this is a belt-and-suspenders backstop for the same count-growing
// cases, not a separate path that should bypass that check.
onCountChanged: if (count > 0 && !root.loadingMore && root.anchored) root._pendingScrollToEnd = true
function nickColor(nick) {
let hash = 0;
for (let i = 0; i < nick.length; i++)
hash = (hash << 5) - hash + nick.charCodeAt(i);
const hue = Math.abs(hash) % 360;
return Qt.hsla(hue / 360, 0.55, 0.65, 1);
}
function formatTime(ts) {
if (root.relativeTimestamps)
return root.formatRelativeTime(ts);
const d = new Date(ts * 1000);
return d.toLocaleTimeString(Qt.locale(), "HH:mm");
}
// "8m ago" / "2h ago" for anything recent, "Yesterday at 5:27 PM" once
// it's been at least 23h, and a bare short date ("8/4/26, 10:19PM")
// beyond 47h - literal elapsed-time thresholds rather than calendar-day
// awareness (so this only cares how long ago it was, not what day it
// is right now). The hover tooltip (formatFullTime) is unaffected by
// this setting and always shows the full spelled-out date/time.
function formatRelativeTime(ts) {
void root.timeTick;
const now = Date.now() / 1000;
const diff = now - ts;
if (diff < 60)
return "just now";
if (diff < 3600)
return Math.floor(diff / 60) + "m ago";
if (diff < 23 * 3600)
return Math.floor(diff / 3600) + "h ago";
const d = new Date(ts * 1000);
let hours = d.getHours() % 12;
if (hours === 0)
hours = 12;
const minutes = String(d.getMinutes()).padStart(2, "0");
const ampm = d.getHours() >= 12 ? "PM" : "AM";
if (diff < 47 * 3600)
return "Yesterday at " + hours + ":" + minutes + " " + ampm;
const shortYear = String(d.getFullYear()).slice(-2);
return (d.getMonth() + 1) + "/" + d.getDate() + "/" + shortYear + ", " + hours + ":" + minutes + ampm;
}
// Discord's embed `color` field is a plain decimal RGB integer
// (e.g. 15844367), not a CSS color string.
function formatEmbedColor(color) {
if (color === undefined || color === null)
return "";
return "#" + Math.max(0, Math.min(0xffffff, color)).toString(16).padStart(6, "0");
}
// Discord's own embed footer convention - real calendar-day awareness
// ("Today"/"Yesterday" mean the actual current/previous date), unlike
// formatRelativeTime above which is purely elapsed-time based. Distinct
// formatting need, so it's not just reusing that function.
function formatEmbedTimestamp(iso) {
if (!iso)
return "";
const d = new Date(iso);
const now = new Date();
let hours = d.getHours() % 12;
if (hours === 0)
hours = 12;
const minutes = String(d.getMinutes()).padStart(2, "0");
const ampm = d.getHours() >= 12 ? "PM" : "AM";
const timeStr = hours + ":" + minutes + " " + ampm;
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
if (sameDay(d, now))
return "Today at " + timeStr;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (sameDay(d, yesterday))
return "Yesterday at " + timeStr;
const shortYear = String(d.getFullYear()).slice(-2);
return (d.getMonth() + 1) + "/" + d.getDate() + "/" + shortYear + ", " + timeStr;
}
function ordinalSuffix(day) {
if (day >= 11 && day <= 13)
return "th";
switch (day % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
}
// The hover-tooltip long form - "Wednesday, August 5th 2025, at
// 05:01pm" - deliberately spelled out rather than using a locale
// format string, since the ordinal day suffix and lowercase "am"/"pm"
// with no space aren't something Qt's date format tokens produce on
// their own.
function formatFullTime(ts) {
const d = new Date(ts * 1000);
const weekday = d.toLocaleDateString(Qt.locale(), "dddd");
const month = d.toLocaleDateString(Qt.locale(), "MMMM");
const day = d.getDate();
const year = d.getFullYear();
let hours = d.getHours();
const minutes = String(d.getMinutes()).padStart(2, "0");
const ampm = hours >= 12 ? "pm" : "am";
hours = hours % 12;
if (hours === 0)
hours = 12;
const hoursStr = String(hours).padStart(2, "0");
return `${weekday}, ${month} ${day}${root.ordinalSuffix(day)} ${year}, at ${hoursStr}:${minutes}${ampm}`;
}
// Message bodies arrive in whatever mix of formatting the sender/network
// used - bare "https://..." text most of the time, but some channels
// (e.g. Libera's #archlinux topic) embed literal HTML anchors, and some
// bots/bridges use Markdown or BBCode. Text.StyledText only makes actual
// <a href> markup clickable, so all of these need normalizing into that
// before rendering - and in a specific order, so a URL already wrapped
// by one format doesn't get double-processed by a later step and come
// out garbled.
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Sneedchat (SockChat) messages carry BBCode - the site's own greentext
// is literally `[color=#72ff72]>text` (see backend/sockchat/protocol.rs's
// prepare_outgoing, which emits exactly that for our own `>` lines).
// Rather than a separate protocol-gated formatter, this normalizes
// BBCode tags into the same conventions the existing extraction
// pipeline and formatMessage() already understand ([b]/[i]/[u]/[url]
// BBCode already flow straight into formatMessage below; this fills in
// the rest) - IRC/Discord bodies never contain these tags, so this is
// a safe no-op for them.
function normalizeBBCode(text) {
if (!text)
return text;
let out = text;
// [br] -> a real newline, so it flows through the exact same \n ->
// <br> pipeline formatMessage already applies below - left alone,
// formatMessage's own catch-all "strip any unrecognized BBCode tag"
// step deletes it with nothing in its place, gluing whatever was on
// either side directly together with no separator at all. Bots
// that build their own post text (e.g. Fishtank Observer relaying
// a tweet) commonly emit this instead of a literal newline.
out = out.replace(/\[br\]/gi, "\n");
// [img]/[video] -> a bare URL, so extractMedia's own bare-URL
// scan (unchanged) picks it up exactly like a pasted link.
// Newline-padded, not a bare "$1" - back-to-back tags with no
// separator between them (e.g. a "Media attachments" spoiler
// dump posting two [img] tags right against each other) would
// otherwise unwrap into one glued-together string like
// "https://host/aHOSThost/b", which is neither URL - confirmed
// live from a Fishtank Observer relay post. The [url=X]X[/url]
// collapse just below tolerates the extra whitespace fine (its
// \s* already matches newlines).
out = out.replace(/\[img\]([\s\S]*?)\[\/img\]/gi, "\n$1\n");
out = out.replace(/\[video\]([\s\S]*?)\[\/video\]/gi, "\n$1\n");
// [url=X]X[/url] - a link wrapping its own URL, most often what's
// left after the [img] unwrap just above turns [url=X][img]X[/img]
// [/url] into [url=X]X[/url] (this site's own convention for a
// clickable image: link the picture to its own full-size URL).
// Collapsed to bare X so extractMedia sees one clean URL instead of
// the same link twice - left uncollapsed, the trailing "[/url]"
// would run straight into extractMedia's bare-URL match with no
// separator, swallowing it into the "URL" and breaking the
// extension check (e.g. a .webp image silently not embedding).
// Only fires when the label truly matches the href, so a genuine
// [url=X]click here[/url] custom-label link still reaches
// formatMessage's own [url=] handling below untouched.
out = out.replace(/\[url=(https?:\/\/[^\]\s]+)\]\s*\1\s*\[\/url\]/gi, "$1");
// [spoiler] -> the ||text|| convention formatMessage already
// handles, so its existing click-to-reveal logic just works.
out = out.replace(/\[spoiler\]([\s\S]*?)\[\/spoiler\]/gi, "||$1||");
// [quote] -> "> "-prefixed lines, what extractQuoteBlocks/
// stripQuoteBlocks already expect (Discord-style blockquote
// rendering, pulled out into its own accent-barred block below).
// Wrapped in its own newlines - extractQuoteBlocks/stripQuoteBlocks
// work line-by-line, so an inline "[quote]x[/quote] trailing text"
// usage would otherwise glue "trailing text" onto the quote's last
// line instead of leaving it as ordinary text after the quote.
out = out.replace(/\[quote\]([\s\S]*?)\[\/quote\]/gi, (m, inner) => "\n" + inner.trim().split("\n").map(l => "> " + l).join("\n") + "\n");
// [php]/[plain]/[code=lang] are block code on this site (plain
// [code] stays inline - see formatMessage's own backtick handling
// just below); fenced ``` blocks are what extractCodeBlocks/
// stripCodeBlocks already expect.
out = out.replace(/\[(?:php|plain|code=\w+)\]([\s\S]*?)\[\/(?:php|plain|code)\]/gi, "```$1```");
out = out.replace(/\[code\]([\s\S]*?)\[\/code\]/gi, "`$1`");
// [size=..] has no real equivalent in a compact chat log - unwrap
// rather than risk breaking layout with arbitrary text scaling.
out = out.replace(/\[size=[^\]]*\]([\s\S]*?)\[\/size\]/gi, "$1");
// [*] list items / [list] wrapper - no real list layout here, a
// leading bullet reads close enough to how these actually appear.
out = out.replace(/\[\*\]\s*/gi, "• ");
out = out.replace(/\[\/?list\]/gi, "");
return out;
}
// Alias -> {label, aliases, url} - rebuilt only when sockchatSmilies
// itself changes (once per fetch, see ChatView.qml), not per message.
readonly property var sockchatSmilieIndex: {
const map = {};
for (const s of root.sockchatSmilies) {
for (const a of (s.aliases || []))
map[a] = s;
}
return map;
}
// One compiled alternation of every known shortcode, longest first so
// e.g. a shorter alias can never pre-empt a longer one that happens to
// contain it. Built once per sockchatSmilies fetch, reused for every
// visible message row rather than rebuilt per-row.
readonly property var sockchatSmilieRegex: {
const aliases = Object.keys(root.sockchatSmilieIndex);
if (aliases.length === 0)
return null;
const sorted = aliases.slice().sort((a, b) => b.length - a.length);
const escaped = sorted.map(a => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
return new RegExp(escaped.join("|"), "g");
}
// gif/webp are the only formats this app ever actually animates (see
// MediaEmbed.qml's own isAnimatedImage - same two extensions, same
// reasoning: AnimatedImage is what plays them, plain Image only ever
// shows a static first frame).
function isAnimatedSmilieUrl(url) {
return /\.(gif|webp)$/i.test(url);
}
// Finds every animated-smiley match in `text` (deduped by url) so
// they can be rendered as real AnimatedImage items instead of the
// inline <img> tags formatMessage below uses for everything else -
// Qt's StyledText/RichText image support has no concept of an
// animated <img> at all, only ever painting its static first frame,
// so a gif/webp smiley needs an actual QML Image-family item to
// loop. A second independent pass over the same text formatMessage
// itself reads, matching this file's own existing precedent
// (extractMedia/extractCodeBlocks/extractQuoteBlocks are all already
// separate passes over msgRow.normalizedBody rather than one
// do-everything function).
function extractAnimatedSmilies(text) {
if (!root.isSockchat || !root.sockchatSmilieRegex || !text)
return [];
const re = new RegExp(root.sockchatSmilieRegex.source, "g");
const seen = new Set();
const out = [];
let match;
while ((match = re.exec(text)) !== null) {
const s = root.sockchatSmilieIndex[match[0]];
if (s && s.url && root.isAnimatedSmilieUrl(s.url) && !seen.has(s.url)) {
seen.add(s.url);
out.push(s);
}
}
return out;
}
// `revealedSpoilers` is a per-message {index: true} map (see msgRow's
// own revealedSpoilers property) - spoilers are numbered in the order
// they appear in this call, so the same message always maps the same
// index to the same spoiler run.
function formatMessage(text, revealedSpoilers) {
if (!text)
return text;
revealedSpoilers = revealedSpoilers || {};
// Stash already-formed anchors (case-insensitive - some channels
// send upper-case <A HREF=...>) so nothing below re-touches their
// contents, then restore them at the end.
const stash = [];
// Wrapped in a word marker, not bare digits - a bare-digit
// placeholder collided with any real number already in the
// message (e.g. "item 42" got its "42" blown away and replaced
// by stash[42], usually undefined) the instant more than one
// thing in this function called stow() for the same message,
// which the spoiler/inline-code passes below made routine
// instead of rare.
function stow(html) {
stash.push(html);
return "STOWMARKER" + (stash.length - 1) + "STOWMARKER";
}
let out = text;
// Existing HTML anchors.
out = out.replace(/<a\s+href=["']([^"']+)["']\s*>([\s\S]*?)<\/a>/gi, (m, href, label) => stow(`<a href="${href}">${label}</a>`));
// Inline `code` spans (single backtick) - stowed before anything
// else touches them so backticked content is never itself
// reinterpreted as markdown/links, and HTML-escaped since code
// commonly contains `<`/`>`/`&` that would otherwise corrupt the
// rest of this StyledText's rich-text parsing.
out = out.replace(/`([^`\n]+)`/g, (m, code) => stow(`<span style="font-family:monospace; background-color:${Theme.surfaceContainerHighest};">&nbsp;${root.escapeHtml(code)}&nbsp;</span>`));
// Spoilers: ||text|| - hidden behind a solid bar until clicked,
// same as Discord. Rendered as a real link (href "spoiler:N")
// purely so onLinkActivated can catch the click and flip this
// message's own revealedSpoilers state - it never actually
// navigates anywhere.
let spoilerIndex = 0;
out = out.replace(/\|\|([\s\S]+?)\|\|/g, (m, inner) => {
const idx = spoilerIndex++;
if (revealedSpoilers[idx])
return stow(`<span style="background-color:${Theme.surfaceContainerHighest};">${root.escapeHtml(inner)}</span>`);
return stow(`<a href="spoiler:${idx}" style="color:${Theme.outlineStrong}; background-color:${Theme.outlineStrong}; text-decoration:none;">${root.escapeHtml(inner)}</a>`);
});
// Sneedchat smiley shortcodes (":)", ":heart-empty:", "*sigh*",
// etc. - see sockchatSmilieRegex/sockchatSmilieIndex above) - only
// for a Sneedchat buffer, and only after code/anchor/spoiler
// content is already stowed away above so this never reaches
// inside a code span or a not-yet-revealed spoiler. Must also run
// before the markdown bold/italic pass further down, since
// several real shortcodes ("*sigh*", "*YAWN*") are themselves
// shaped like markdown emphasis and would otherwise be mangled
// into <i>sigh</i> before ever being recognized as a smiley.
if (root.isSockchat && root.sockchatSmilieRegex) {
out = out.replace(root.sockchatSmilieRegex, m => {
const s = root.sockchatSmilieIndex[m];
// s.url is only empty if ChatView.qml's own plugin-path
// resolution failed (see its sockchatSmilies doc comment)
// - leave the plain shortcode text visible rather than a
// broken image reference in that case.
if (!s || !s.url)
return m;
// Animated (gif/webp) smilies are left as plain shortcode
// text here and rendered separately, as a real
// AnimatedImage, by msgRow's own animatedSmilies row below
// - see extractAnimatedSmilies's doc comment for why an
// inline <img> tag can't do this (Qt's rich text image
// support only ever paints a static first frame).
if (root.isAnimatedSmilieUrl(s.url))
return m;
// No explicit width/height - Qt's rich text renders an
// <img> with no size attributes at the image's own native
// pixel size, which is what "full size" means here: most
// of these are already small by design (24-64px), and
// forcing every one to a fixed square previously distorted
// the handful of wider banner-shaped ones (e.g. gunt.gif
// at 68x18) instead of just showing them as-is.
return stow(`<img src="${s.url}" align="middle">`);
});
}
// Markdown links: [label](url)
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (m, label, href) => stow(`<a href="${href}">${label}</a>`));
// BBCode: [url=link]label[/url] and [url]link[/url]
out = out.replace(/\[url=(https?:\/\/[^\]\s]+)\]([\s\S]*?)\[\/url\]/gi, (m, href, label) => stow(`<a href="${href}">${label}</a>`));
out = out.replace(/\[url\](https?:\/\/[^\]\s]+)\[\/url\]/gi, (m, href) => stow(`<a href="${href}">${href}</a>`));
// BBCode formatting ([strong]/[em] are aliases seen from the same
// site - a `\s*` before the closing tag name tolerates the odd
// "[/ b]" spacing variant observed there too).
out = out.replace(/\[(?:b|strong)\]([\s\S]*?)\[\/\s*(?:b|strong)\]/gi, "<b>$1</b>");
out = out.replace(/\[(?:i|em)\]([\s\S]*?)\[\/\s*(?:i|em)\]/gi, "<i>$1</i>");
out = out.replace(/\[u\]([\s\S]*?)\[\/\s*u\]/gi, "<u>$1</u>");
out = out.replace(/\[(?:s|strike)\]([\s\S]*?)\[\/\s*(?:s|strike)\]/gi, "<s>$1</s>");
// [color=..] - most notably the site's own greentext convention
// (`[color=#72ff72]>text`, see backend/sockchat/protocol.rs's
// prepare_outgoing, which emits exactly that for our own `>`
// lines) - rendered as a real color span rather than stripped.
// The captured value is restricted to a safe alphanumeric/#
// pattern before it ever reaches a style attribute, since this is
// otherwise attacker-controlled text landing directly in HTML.
out = out.replace(/\[color=(#?[0-9a-zA-Z]+)\]([\s\S]*?)\[\/color\]/gi, (m, c, inner) => `<span style="color:${c};">${inner}</span>`);
// Markdown bold/italic/strikethrough - bold and strikethrough
// patterns first so their delimiter characters aren't left stray
// for the italic pass to mangle.
out = out.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
out = out.replace(/__([^_]+)__/g, "<b>$1</b>");
out = out.replace(/~~([^~]+)~~/g, "<s>$1</s>");
out = out.replace(/\*([^*]+)\*/g, "<i>$1</i>");
out = out.replace(/(^|\s)_([^_]+)_(?=\s|$)/g, "$1<i>$2</i>");
// Any bare URL not already wrapped by one of the formats above.
out = out.replace(/(https?:\/\/[^\s<]+)/g, "<a href=\"$1\">$1</a>");
// Anything else BBCode-shaped that wasn't recognized above (e.g.
// [USER=..] mentions, [sub]/[sup], a typo'd tag) - stripped rather
// than shown as literal markup, matching how the forum's own
// plain-text views degrade unsupported tags.
out = out.replace(/\[\/?[A-Za-z][A-Za-z0-9\-_]*(?:=[^\]]*)?\]/g, "");
out = out.replace(/STOWMARKER(\d+)STOWMARKER/g, (m, i) => stash[Number(i)]);
// textFormat: Text.StyledText (see bodyText below) is Qt's HTML
// subset - like HTML, a literal "\n" in the string is not a line
// break, it's just whitespace that gets collapsed away entirely.
// A structured multi-line message (a relay bot's "author / link /
// stats / footer" style post, an IRC bot's multi-line notice) was
// rendering as one run-together line with no visible separation
// at all - confirmed live, a Fishtank Observer post's link, emoji
// reaction counts, and footer links all mashed onto a single line.
// An explicit <br> is what StyledText actually needs.
out = out.replace(/\n/g, "<br>");
return out;
}
function youtubeId(url) {
const m = url.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([\w-]{11})/);
return m ? m[1] : null;
}
function hostnameOf(url) {
const m = url.match(/^https?:\/\/([^\/]+)/i);
return m ? m[1].replace(/:\d+$/, "").toLowerCase() : "";
}
// Heuristic, extension/pattern-based detection - IRC has no attachment
// metadata like Discord's API, so this is the same trade-off the
// project plan called out for inline media generally.
function extractMedia(text) {
if (!text)
return [];
// `[` and `]` excluded alongside whitespace/`<` - a URL sitting
// right against an unrecognized/not-yet-unwrapped BBCode tag (no
// whitespace in between, e.g. "...webp[/url]") would otherwise get
// swallowed whole into the "URL", corrupting the extension check
// below. See normalizeBBCode's [url=X]X[/url] collapse for the one
// wrapper this site actually uses; this is the generic backstop
// for anything else shaped like it.
//
// `file://` alongside `https?://` - backend/sockchat/mod.rs's
// attachment resolver rewrites a Kiwi Farms attachment link into a
// local `file://` path once it's fetched through Tor and cached
// (see its own doc comment); this only ever originates from
// chatd's own substitution, never from a remote message's raw
// text, so it's safe to treat the same as any other embeddable
// URL rather than a special case.
const urls = text.match(/(?:https?|file):\/\/[^\s<\[\]]+/g) || [];
const result = [];
// A message legitimately repeating the same link (quoted text plus
// the original, or a wrapper this file doesn't know about yet)
// shouldn't render the same embed twice.
const seen = new Set();
for (const raw of urls) {
const url = raw.replace(/[),.;!?]+$/, "");
if (seen.has(url))
continue;
const yt = root.youtubeId(url);
if (yt) {
seen.add(url);
result.push({ "url": url, "kind": "youtube", "youtubeId": yt });
} else if (/\.(png|jpe?g|gif|webp|bmp)(\?\S*)?$/i.test(url)) {
seen.add(url);
result.push({ "url": url, "kind": "image" });
} else if (/\.(mp4|webm|mov|mkv|ogv)(\?\S*)?$/i.test(url)) {
seen.add(url);
result.push({ "url": url, "kind": "video" });
} else if (root.opaqueImageHosts.includes(root.hostnameOf(url))) {
seen.add(url);
result.push({ "url": url, "kind": "image" });
} else if (root.contentSniffingEnabled && /^https?:\/\//i.test(url)) {
// Nothing else could classify this link - ask sniffUrl
// (see its own doc comment). Not for file:// URLs: those
// are always chatd's own already-classified attachment
// paths (see the [file://] branch above), never something
// that needs a network probe.
const sniffed = root.sniffedTypes[url];
if (sniffed === "image" || sniffed === "video") {
seen.add(url);
result.push({ "url": url, "kind": sniffed });
} else if (sniffed === undefined) {
root.sniffUrl(url);
}
}
}
return result;
}
// Once a URL has rendered as an inline embed below the message, showing
// it a second time as plain link text in the body reads as noise -
// Discord itself hides it the same way. Only strips URLs that actually
// became an embed (mediaItems), so a message with extra non-embeddable
// links keeps showing those normally.
function stripEmbeddedUrls(text, mediaItems) {
if (!text || !mediaItems || mediaItems.length === 0)
return text;
let out = text;
for (const item of mediaItems)
out = out.split(item.url).join("");
return out.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
// Fenced ```code blocks``` (Discord/Markdown convention - e.g. the
// "Sneedchat" IRC-bridge webhook wraps its relayed lines in one so
// Discord doesn't apply markdown/mention parsing inside it) get pulled
// out and rendered separately in a monospace, non-wrapping, horizontally
// scrollable box instead of flowing through the normal word-wrapped
// StyledText - long lines (a URL, a wide table) stay intact and scroll
// rather than wrapping into an unreadable ragged mess.
function extractCodeBlocks(text) {
if (!text)
return [];
const blocks = [];
const re = /```(?:[ \t]*\w+\n)?([\s\S]*?)```/g;
let m;
while ((m = re.exec(text)) !== null)
blocks.push(m[1].replace(/\n$/, ""));
return blocks;
}
function stripCodeBlocks(text) {
if (!text)
return text;
return text.replace(/```(?:[ \t]*\w+\n)?[\s\S]*?```/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
// Discord-style blockquotes: one or more consecutive lines starting
// with "> " are grouped into a single quote and pulled out to render
// separately with a left accent bar (see the quoteBlocks Repeater),
// matching how Discord itself renders them, instead of showing the
// literal "> " prefix inline in the normal word-wrapped body text.
function extractQuoteBlocks(text) {
if (!text)
return [];
const blocks = [];
let current = null;
for (const line of text.split("\n")) {
const m = line.match(/^>\s?(.*)$/);
if (m) {
if (current === null)
current = [];
current.push(m[1]);
} else if (current !== null) {
blocks.push(current.join("\n"));
current = null;
}
}
if (current !== null)
blocks.push(current.join("\n"));
return blocks;
}
function stripQuoteBlocks(text) {
if (!text)
return text;
return text.split("\n").filter(line => !/^>\s?/.test(line)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
function firstUrl(text) {
if (!text)
return "";
// Same bracket exclusion and file:// allowance as extractMedia's
// regex - see its comment.
const m = text.match(/(?:https?|file):\/\/[^\s<\[\]]+/);
return m ? m[0].replace(/[),.;!?]+$/, "") : "";
}
delegate: Rectangle {
id: msgRow
required property var modelData
required property int index
width: ListView.view.width
height: dividerRow.height + contentRow.implicitHeight + Theme.spacingXS * 2
color: {
if (modelData.isHighlight)
return Theme.withAlpha(Theme.primary, 0.12);
// Every protocol, not just Discord - a plain "you're looking
// at this row" highlight has nothing protocol-specific about
// it. hoverToolbar just below stays Discord-only though, since
// reactions/native-reply are the only things there and IRC/
// Sneedchat have neither (see hoverToolbar's own doc comment).
if (rowHover.hovered)
return Theme.surfaceContainerHigh;
return "transparent";
}
// Drives the row hover highlight above (every protocol) and the
// hover toolbar/reaction affordances below (Discord-only, since
// reactions and native reply are the only things there - see
// hoverToolbar's own doc comment). Covers the whole row (not just
// the body text) so hovering anywhere - the avatar, empty space
// past a short message - shows it, the same as Discord's own
// row-wide hover area.
HoverHandler {
id: rowHover
}
// BBCode-normalized once here rather than in each of the
// properties below individually - see normalizeBBCode's own
// comment for why this has to run before extractMedia/
// extractCodeBlocks/extractQuoteBlocks ever see the body.
readonly property string normalizedBody: root.normalizeBBCode(modelData.body)
readonly property var mediaItems: root.extractMedia(msgRow.normalizedBody)
readonly property string rowFirstUrl: root.firstUrl(msgRow.normalizedBody)
readonly property var codeBlocks: root.extractCodeBlocks(msgRow.normalizedBody)
readonly property var quoteBlocks: root.extractQuoteBlocks(root.stripCodeBlocks(msgRow.normalizedBody))
readonly property string displayBody: root.stripQuoteBlocks(root.stripCodeBlocks(root.stripEmbeddedUrls(msgRow.normalizedBody, msgRow.mediaItems)))
// Sneedchat smiley shortcodes in this message that resolve to an
// animated (gif/webp) image - see extractAnimatedSmilies's own
// doc comment for why these can't just be inline <img> tags like
// the rest. Rendered as their own row below the body text instead.
readonly property var animatedSmilies: root.extractAnimatedSmilies(msgRow.displayBody)
// Which ||spoiler|| runs (by position in this message) the user has
// already clicked to reveal - keyed by index, not by content, so
// two identical spoilers in one message can be revealed
// independently. Local to this delegate/message, not persisted -
// re-hides on scroll-out same as Discord's own client-side-only
// reveal state.
property var revealedSpoilers: ({})
// Never on the very first loaded message - with no older context
// above it there's nothing to divide from, so it'd just read as a
// stray red line at the top of the buffer rather than a boundary.
readonly property bool showNewDivider: msgRow.index === root.newDividerIndex && msgRow.index > 0
// Comfy mode only (see isGroupedWithPrevious - always false in
// Classic) - a grouped message hides its own time/name header and
// relies on the first message in the run to have already shown it.
readonly property bool isGrouped: root.isGroupedWithPrevious(msgRow.index)
RowLayout {
id: dividerRow
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
visible: msgRow.showNewDivider
height: visible ? implicitHeight + Theme.spacingXXS * 2 : 0
spacing: Theme.spacingS
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.error
}
StyledText {
text: "New"
color: Theme.error
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Bold
}
}
RowLayout {
id: contentRow
anchors.left: parent.left
anchors.right: parent.right
anchors.top: dividerRow.bottom
anchors.margins: Theme.spacingXS
anchors.leftMargin: Theme.spacingM
spacing: Theme.spacingS
// Discord-style optimistic send: dim the whole row (avatar,
// name, timestamp, body alike) while our own not-yet-confirmed
// message sits waiting on a real echo from the server - see
// ChatView.qml's pendingSends/sendCurrentMessage. A failed send
// is NOT dimmed - it needs to read clearly, not fade out, so
// the red body-text color below is the only cue for that case.
opacity: modelData.sendStatus === "pending" ? 0.5 : 1.0
// Comfy mode's avatar column - the message author's real
// avatar (see model.rs's Message.avatarUrl, Discord only -
// covers our own sent messages too, same field, no special
// casing) when one was captured, otherwise the same
// colored-initial fallback used everywhere else in this app
// that has no real avatar to show (IRC, or a Discord user with
// no custom avatar set). Shown only on the first message of a
// grouped run, but always reserving the same *width* so a
// grouped message's body still lines up under the header
// above it rather than sliding left. The height reservation is
// NOT unconditional the same way, though - forcing every row's
// avatar column to 32px tall even when grouped (no avatar
// actually drawn there) padded every short grouped message out
// to at least 32px regardless of its real (often shorter) text
// height, which is what made consecutive message gaps look
// inconsistent - some pairs hit that artificial floor, others
// didn't, depending on wrapped line count alone. Confirmed live
// in both Classic-grouped IRC and Discord buffers alike, since
// this reservation was never protocol-specific to begin with.
Item {
Layout.preferredWidth: root.comfyMode ? 32 : 0
Layout.preferredHeight: (root.comfyMode && !msgRow.isGrouped) ? 32 : 1
Layout.alignment: Qt.AlignTop
visible: root.comfyMode
Rectangle {
anchors.fill: parent
visible: !msgRow.isGrouped && !msgRow.modelData.isAction
radius: width / 2
color: root.nickColor(msgRow.modelData.from || "")
clip: true
Image {
anchors.fill: parent
visible: !!msgRow.modelData.avatarUrl
source: msgRow.modelData.avatarUrl || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
StyledText {
anchors.centerIn: parent
visible: !msgRow.modelData.avatarUrl
text: (msgRow.modelData.from || "?").charAt(0).toUpperCase()
color: "white"
font.weight: Font.Bold
}
}
}
ColumnLayout {
id: contentColumn
Layout.fillWidth: true
spacing: Theme.spacingXS
// Discord-native reply preview - a cached snapshot of what was
// replied to (see model.rs's ReplyPreview), not a live lookup,
// so it still means something even if the original scrolled
// out of view or was deleted. Clicking jumps to it if it's
// still in the currently-loaded page.
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacingXS
visible: !!msgRow.modelData.replyTo
DankIcon {
name: "reply"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
Layout.fillWidth: true
text: msgRow.modelData.replyTo ? (msgRow.modelData.replyTo.from || "a message") + (msgRow.modelData.replyTo.body ? ": " + msgRow.modelData.replyTo.body : "") : ""
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.NoWrap
elide: Text.ElideRight
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.jumpToMessage(msgRow.modelData.replyTo.id)
}
}
}
// Header (time + name) - suppressed for a grouped comfy-mode
// message (see msgRow.isGrouped), always shown in Classic
// since isGroupedWithPrevious is unconditionally false there.
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacingS
visible: root.editingId !== msgRow.modelData.id && !msgRow.isGrouped
StyledText {
id: timeText
Layout.alignment: Qt.AlignTop
text: root.formatTime(msgRow.modelData.ts)
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
HoverHandler {
id: timeHover
onHoveredChanged: {
if (timeHover.hovered) {
timeTooltipDelay.restart();
} else {
timeTooltipDelay.stop();
timeTooltip.hide();
}
}
}
Timer {
id: timeTooltipDelay
interval: 400
onTriggered: timeTooltip.show(root.formatFullTime(msgRow.modelData.ts), timeText, 0, 0, "top")
}
onVisibleChanged: if (!visible) timeTooltipDelay.stop()
DankTooltipV2 {
id: timeTooltip
}
}
StyledText {
Layout.alignment: Qt.AlignTop
visible: !msgRow.modelData.isAction
text: msgRow.modelData.from
color: root.nickColor(msgRow.modelData.from || "")
font.weight: Font.Medium
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacingS
visible: root.editingId !== msgRow.modelData.id
StyledText {
id: bodyText
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
visible: text !== ""
// libpurple converts mIRC formatting control codes (bold,
// italic, underline, color) into its own pseudo-HTML markup
// (<b>, <i>, <u>, <font color=...>) rather than sending raw
// IRC bytes - StyledText defaults to Text.PlainText, which
// is why those tags were showing up literally instead of
// being rendered. Text.StyledText is Qt's lightweight
// subset of RichText meant for exactly this (inline
// formatting, no block/table/image support needed here).
textFormat: Text.StyledText
text: {
const body = root.formatMessage(msgRow.displayBody, msgRow.revealedSpoilers);
return msgRow.modelData.isAction ? ("* " + msgRow.modelData.from + " " + body) : body;
}
color: {
if (msgRow.modelData.sendStatus === "failed")
return Theme.error;
return msgRow.modelData.isAction ? Theme.surfaceVariantText : Theme.surfaceText;
}
linkColor: "#64B5F6"
font.italic: msgRow.modelData.isAction
wrapMode: Text.WordWrap
onLinkActivated: link => {
if (link.startsWith("spoiler:")) {
const idx = Number(link.slice("spoiler:".length));
const revealed = Object.assign({}, msgRow.revealedSpoilers);
revealed[idx] = true;
msgRow.revealedSpoilers = revealed;
} else {
Qt.openUrlExternally(link);
}
}
// A MouseArea here (even hoverEnabled) sits on top of
// the Text item and competes with Text's own internal
// link-hover tracking for the same pointer events, so
// hoveredLink stops updating once something else is
// also watching hover - HoverHandler layers
// non-exclusively instead of covering the item, so it
// doesn't interfere with Text's own hit-testing.
HoverHandler {
cursorShape: bodyText.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
}
}
StyledText {
Layout.alignment: Qt.AlignTop
visible: !!msgRow.modelData.edited
text: "(edited)"
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.italic: true
}
}
// Animated (gif/webp) Sneedchat smilies used in this message -
// see msgRow.animatedSmilies/extractAnimatedSmilies. Native
// size, same as the inline static ones in bodyText above, and
// always looping - there's no autoplay/loop setting to respect
// here the way MediaEmbed.qml's own animated images have
// (mediaAutoplay/mediaLoop), since a smiley the user
// deliberately typed into a message is the whole point of
// typing it, not incidental page content to maybe suppress.
Flow {
Layout.fillWidth: true
visible: msgRow.animatedSmilies.length > 0
spacing: Theme.spacingXS
Repeater {
model: msgRow.animatedSmilies
AnimatedImage {
required property var modelData
source: modelData.url
playing: true
asynchronous: true
}
}
}
// Inline edit - opens in place of the normal body row above
// (toggled via the visible bindings on each) rather than a
// separate popup, so it reads as "this exact message became
// editable" the way Discord's own inline edit does.
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacingS
visible: root.editingId === msgRow.modelData.id
DankTextField {
id: editField
Layout.fillWidth: true
text: root.editingId === msgRow.modelData.id ? msgRow.modelData.body : ""
Keys.onEscapePressed: root.editingId = ""
onAccepted: {
root.editSubmitted(msgRow.modelData.id, text);
root.editingId = "";
}
// NOT Component.onCompleted - every delegate row gets
// one of these (just hidden via the RowLayout's own
// `visible` above), and since `model:` is bound to a
// plain array whose identity changes on every single
// new message, ALL delegates get destroyed and
// recreated from scratch each time - onCompleted would
// fire forceActiveFocus() for every one of them, most
// invisible, stealing focus from the real composer
// field on every single incoming/sent message
// (confirmed live: this was the whole "input box loses
// focus" report, unrelated to Enter/send specifically).
// onVisibleChanged only fires the one time this
// specific row actually becomes the one being edited.
onVisibleChanged: if (visible) forceActiveFocus()
}
}
Flow {
Layout.fillWidth: true
spacing: Theme.spacingXS
visible: msgRow.mediaItems.length > 0
Repeater {
model: msgRow.mediaItems
MediaEmbed {
required property var modelData
url: modelData.url
kind: modelData.kind
youtubeId: modelData.youtubeId || ""
mediaAutoplay: root.mediaAutoplay
mediaLoop: root.mediaLoop
messageId: msgRow.modelData.id
bufferId: root.bufferId
onOpenInDiscordRequested: (bufId, msgId) => root.openInDiscordRequested(bufId, msgId)
}
}
}
// Discord-style blockquotes (see extractQuoteBlocks) - a left
// accent bar with the quoted text indented next to it, same
// visual Discord itself uses for "> quoted text".
Repeater {
model: msgRow.quoteBlocks
RowLayout {
id: quoteDelegate
required property string modelData
Layout.fillWidth: true
spacing: Theme.spacingS
Rectangle {
Layout.fillHeight: true
Layout.preferredWidth: 3
radius: 1.5
color: Theme.outlineStrong
}
StyledText {
Layout.fillWidth: true
textFormat: Text.StyledText
text: root.formatMessage(quoteDelegate.modelData)
color: Theme.surfaceVariantText
linkColor: "#64B5F6"
wrapMode: Text.WordWrap
onLinkActivated: link => Qt.openUrlExternally(link)
}
}
}
Repeater {
model: msgRow.codeBlocks
Rectangle {
id: codeBlockDelegate
required property string modelData
Layout.fillWidth: true
Layout.preferredHeight: Math.min(codeText.implicitHeight, Theme.fontSizeSmall * 1.4 * 12) + Theme.spacingS * 2
radius: Theme.cornerRadius / 2
color: Theme.surfaceContainerHighest
border.width: 1
border.color: Theme.outlineStrong
clip: true
Flickable {
anchors.fill: parent
anchors.margins: Theme.spacingS
contentWidth: codeText.implicitWidth
contentHeight: codeText.implicitHeight
boundsBehavior: Flickable.StopAtBounds
ScrollBar.horizontal: DankScrollbar {}
ScrollBar.vertical: DankScrollbar {}
StyledText {
id: codeText
text: codeBlockDelegate.modelData
font.family: "monospace"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
wrapMode: Text.NoWrap
textFormat: Text.PlainText
}
}
}
}
// Discord rich embeds (see model.rs's Embed doc comment) -
// bordered box with a colored accent bar down the left edge
// matching the embed's own color field, same convention
// Discord's own client uses. Re-renders live if a later edit
// changes title/description/color (a status-bridge webhook
// commonly does this - e.g. yellow while degraded, green once
// healthy again - see the "messageUpdated" handler above,
// which patches this same modelData.embeds array).
Repeater {
model: msgRow.modelData.embeds || []
Rectangle {
id: embedDelegate
required property var modelData
Layout.fillWidth: true
Layout.preferredHeight: embedContent.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius / 2
color: Theme.surfaceContainerHighest
border.width: 1
border.color: Theme.outlineStrong
clip: true
Rectangle {
id: embedAccentBar
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 4
color: root.formatEmbedColor(embedDelegate.modelData.color) || Theme.outlineStrong
}
ColumnLayout {
id: embedContent
anchors.left: embedAccentBar.right
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingS
spacing: Theme.spacingXXS
StyledText {
Layout.fillWidth: true
visible: !!embedDelegate.modelData.title
text: embedDelegate.modelData.title || ""
font.weight: Font.Bold
color: Theme.surfaceText
wrapMode: Text.WordWrap
}
StyledText {
Layout.fillWidth: true
visible: !!embedDelegate.modelData.description
textFormat: Text.StyledText
text: root.formatMessage(embedDelegate.modelData.description || "")
color: Theme.surfaceText
linkColor: "#64B5F6"
wrapMode: Text.WordWrap
onLinkActivated: link => Qt.openUrlExternally(link)
}
StyledText {
Layout.fillWidth: true
visible: !!embedDelegate.modelData.timestamp
text: root.formatEmbedTimestamp(embedDelegate.modelData.timestamp || "")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
}
// Reaction pills - sized to match Discord's own (~24px tall),
// a real CDN image for a custom emoji (animated .gif vs
// static .png per its own `animated` flag - see model.rs's
// Reaction/backend/discord.rs's extract_reactions) rather
// than showing its name as text, plain Unicode rendered
// natively otherwise. Clickable - toggles *our* reaction on
// that emoji (add if we haven't, remove if we have), the same
// click-to-toggle behavior Discord's own pills have.
Flow {
Layout.fillWidth: true
spacing: Theme.spacingXS
visible: (msgRow.modelData.reactions || []).length > 0
Repeater {
model: msgRow.modelData.reactions || []
Rectangle {
id: reactionDelegate
required property var modelData
readonly property var customEmojiMatch: reactionDelegate.modelData.emoji.match(/^<a?:([^:]+):(\d+)>$/)
readonly property string emojiImageUrl: reactionDelegate.customEmojiMatch ? "https://cdn.discordapp.com/emojis/" + reactionDelegate.customEmojiMatch[2] + (reactionDelegate.modelData.animated ? ".gif" : ".png") : ""
implicitWidth: reactionRow.implicitWidth + Theme.spacingS * 2
implicitHeight: 24
radius: Theme.cornerRadius / 2
color: modelData.me ? Theme.withAlpha(Theme.primary, 0.18) : Theme.surfaceContainerHigh
border.width: modelData.me ? 1 : 0
border.color: Theme.primary
RowLayout {
id: reactionRow
anchors.centerIn: parent
spacing: Theme.spacingXXS
Image {
Layout.preferredWidth: 16
Layout.preferredHeight: 16
visible: reactionDelegate.emojiImageUrl !== ""
asynchronous: true
fillMode: Image.PreserveAspectFit
source: reactionDelegate.emojiImageUrl
}
StyledText {
visible: reactionDelegate.emojiImageUrl === ""
text: reactionDelegate.modelData.emoji
font.pixelSize: Theme.fontSizeMedium
}
StyledText {
text: reactionDelegate.modelData.count
font.pixelSize: Theme.fontSizeSmall
color: reactionDelegate.modelData.me ? Theme.primary : Theme.surfaceVariantText
font.weight: reactionDelegate.modelData.me ? Font.Medium : Font.Normal
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.reactionToggled(msgRow.modelData.id, reactionDelegate.modelData.emoji, !reactionDelegate.modelData.me)
}
}
}
// "+" pill after existing reactions - Discord's own
// shortcut to add a *different* emoji once some are
// already showing, alongside the hover toolbar's own
// add-reaction button above (which works even before any
// reaction exists yet).
Rectangle {
id: addReactionPill
width: 24
height: 24
radius: Theme.cornerRadius / 2
color: addReactionPillHover.hovered ? Theme.surfaceContainerHighest : Theme.surfaceContainerHigh
HoverHandler {
id: addReactionPillHover
}
DankIcon {
anchors.centerIn: parent
name: "add_reaction"
size: 14
color: Theme.surfaceVariantText
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.openReactionPicker(msgRow.modelData.id, addReactionPill)
}
}
}
}
}
// Discord's own floating hover toolbar - quick one-click
// reactions, "add a reaction" (opens the full picker), Reply, and
// "..." (opens the same right-click menu below, so every action
// is still reachable without needing to actually right-click).
// z above everything else in the row since it deliberately
// overlaps the top-right corner rather than reserving its own
// layout space (same visual trick Discord's own client uses -
// it only exists on hover, so there's no reason to permanently
// budget height for it).
Rectangle {
id: hoverToolbar
visible: root.isDiscord && (rowHover.hovered || toolbarHover.hovered)
z: 10
anchors.right: parent.right
anchors.top: parent.top
anchors.rightMargin: Theme.spacingM
anchors.topMargin: -Theme.spacingS
width: toolbarRow.implicitWidth + Theme.spacingS
height: toolbarRow.implicitHeight + Theme.spacingXXS * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineMedium
HoverHandler {
id: toolbarHover
}
RowLayout {
id: toolbarRow
anchors.centerIn: parent
spacing: Theme.spacingXXS
Repeater {
model: root.quickReactEmoji
Rectangle {
id: quickReactDelegate
required property string modelData
width: 28
height: 28
radius: Theme.cornerRadius / 2
color: quickReactHover.hovered ? Theme.surfaceContainerHighest : "transparent"
HoverHandler {
id: quickReactHover
}
StyledText {
anchors.centerIn: parent
text: quickReactDelegate.modelData
font.pixelSize: Theme.fontSizeMedium
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.reactionToggled(msgRow.modelData.id, quickReactDelegate.modelData, true)
}
}
}
// Separates the quick-react emoji from the action icons
// below, matching Discord's own hover toolbar - a small
// margin on both sides rather than relying on toolbarRow's
// own (much smaller) inter-item spacing alone, so it reads
// as a real section break instead of just another gap.
Rectangle {
Layout.leftMargin: Theme.spacingXXS
Layout.rightMargin: Theme.spacingXXS
Layout.preferredWidth: 1
Layout.preferredHeight: 18
color: Theme.outlineMedium
}
// Same 28x28 hover-highlighted box shape as the quick-react
// tiles above (rather than a bare DankIcon with its own
// negative-margin MouseArea) - both for visual consistency
// and because bare icons sitting directly adjacent with no
// box of their own to separate them, at only 16-18px
// across, is what made them look crowded/overlapping in
// the first place.
Rectangle {
id: addReactionButton
width: 28
height: 28
radius: Theme.cornerRadius / 2
color: addReactionHover.hovered ? Theme.surfaceContainerHighest : "transparent"
HoverHandler {
id: addReactionHover
}
DankIcon {
anchors.centerIn: parent
name: "add_reaction"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.openReactionPicker(msgRow.modelData.id, addReactionButton)
}
}
Rectangle {
id: replyButton
width: 28
height: 28
radius: Theme.cornerRadius / 2
visible: !msgRow.modelData.isAction && !!msgRow.modelData.from
color: replyHover.hovered ? Theme.surfaceContainerHighest : "transparent"
HoverHandler {
id: replyHover
}
DankIcon {
anchors.centerIn: parent
name: "reply"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.replyRequested(msgRow.modelData.id, msgRow.modelData.from, msgRow.modelData.body)
}
}
Rectangle {
id: moreButton
width: 28
height: 28
radius: Theme.cornerRadius / 2
color: moreHover.hovered ? Theme.surfaceContainerHighest : "transparent"
HoverHandler {
id: moreHover
}
DankIcon {
anchors.centerIn: parent
name: "more_vert"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: contextMenu.popup()
}
}
}
}
// TapHandler rather than a covering MouseArea: an Item-based
// MouseArea still claims hover priority over whatever it's
// stacked on top of even when it only *accepts* the right button
// (left clicks pass through, but hover/cursorShape on the link
// Text and MediaEmbed underneath never fired - that's why the
// hand cursor never showed up despite both of those being wired
// correctly). TapHandler attaches to this item without covering/
// exclusively claiming the area, so it coexists with the
// HoverHandler/MouseArea underneath instead of blocking them.
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: contextMenu.popup()
}
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 {
id: replyMenuItem
text: "Reply"
visible: !msgRow.modelData.isAction && !!msgRow.modelData.from
height: visible ? implicitHeight : 0
leftPadding: 0
contentItem: Item {
implicitHeight: Math.max(replyIcon.height, replyLabel.implicitHeight)
DankIcon {
id: replyIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "reply"
size: 16
color: Theme.surfaceText
}
StyledText {
id: replyLabel
anchors.left: replyIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: replyMenuItem.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
verticalAlignment: Text.AlignVCenter
}
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.replyRequested(msgRow.modelData.id, msgRow.modelData.from, msgRow.modelData.body)
}
MenuItem {
id: editMenuItem
text: "Edit"
visible: root.canEditDelete && !!msgRow.modelData.isOwn
height: visible ? implicitHeight : 0
leftPadding: 0
contentItem: Item {
implicitHeight: Math.max(editIcon.height, editLabel.implicitHeight)
DankIcon {
id: editIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "edit"
size: 16
color: Theme.surfaceText
}
StyledText {
id: editLabel
anchors.left: editIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: editMenuItem.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
verticalAlignment: Text.AlignVCenter
}
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.editingId = msgRow.modelData.id
}
MenuItem {
id: deleteMenuItem
text: "Delete"
// Own message: same as ever. Someone else's: only offered
// when this room's own power levels actually permit
// redacting others (Matrix only - canRedactOthers is
// always false for every other service) - the RPC call
// itself (root.deleteMessage) is unchanged either way, see
// backend/matrix/moderation.rs's own doc comment on why
// redact-others needed no new backend function at all.
visible: root.canEditDelete && (!!msgRow.modelData.isOwn || (root.canRedactOthers && !!msgRow.modelData.senderId))
height: visible ? implicitHeight : 0
leftPadding: 0
contentItem: Item {
implicitHeight: Math.max(deleteIcon.height, deleteLabel.implicitHeight)
DankIcon {
id: deleteIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "delete"
size: 16
color: Theme.error
}
StyledText {
id: deleteLabel
anchors.left: deleteIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: deleteMenuItem.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
verticalAlignment: Text.AlignVCenter
}
}
background: Rectangle {
color: parent.hovered ? Theme.withAlpha(Theme.error, 0.15) : Theme.withAlpha(Theme.error, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.deleteRequested(msgRow.modelData.id)
}
MenuItem {
id: muteMenuItem
text: "Mute"
visible: root.canMute && !msgRow.modelData.isOwn && !!msgRow.modelData.senderId
height: visible ? implicitHeight : 0
leftPadding: 0
contentItem: Item {
implicitHeight: Math.max(muteIcon.height, muteLabel.implicitHeight)
DankIcon {
id: muteIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "notifications_off"
size: 16
color: Theme.surfaceText
}
StyledText {
id: muteLabel
anchors.left: muteIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: muteMenuItem.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
verticalAlignment: Text.AlignVCenter
}
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.muteRequested(msgRow.modelData.senderId)
}
MenuItem {
id: kickMenuItem
text: "Kick"
visible: root.canKick && !msgRow.modelData.isOwn && !!msgRow.modelData.senderId
height: visible ? implicitHeight : 0
leftPadding: 0
contentItem: Item {
implicitHeight: Math.max(kickIcon.height, kickLabel.implicitHeight)
DankIcon {
id: kickIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "person_remove"
size: 16
color: Theme.error
}
StyledText {
id: kickLabel
anchors.left: kickIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: kickMenuItem.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
verticalAlignment: Text.AlignVCenter
}
}
background: Rectangle {
color: parent.hovered ? Theme.withAlpha(Theme.error, 0.15) : Theme.withAlpha(Theme.error, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.kickRequested(msgRow.modelData.senderId)
}
MenuItem {
id: banMenuItem
text: "Ban"
visible: root.canBan && !msgRow.modelData.isOwn && !!msgRow.modelData.senderId
height: visible ? implicitHeight : 0
leftPadding: 0
contentItem: Item {
implicitHeight: Math.max(banIcon.height, banLabel.implicitHeight)
DankIcon {
id: banIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "block"
size: 16
color: Theme.error
}
StyledText {
id: banLabel
anchors.left: banIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: banMenuItem.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
verticalAlignment: Text.AlignVCenter
}
}
background: Rectangle {
color: parent.hovered ? Theme.withAlpha(Theme.error, 0.15) : Theme.withAlpha(Theme.error, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.banRequested(msgRow.modelData.senderId)
}
MenuItem {
id: retryMenuItem
text: "Retry"
visible: !!msgRow.modelData.isOwn && msgRow.modelData.sendStatus === "failed"
height: visible ? implicitHeight : 0
leftPadding: 0
contentItem: Item {
implicitHeight: Math.max(retryIcon.height, retryLabel.implicitHeight)
DankIcon {
id: retryIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "refresh"
size: 16
color: Theme.surfaceText
}
StyledText {
id: retryLabel
anchors.left: retryIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: retryMenuItem.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
verticalAlignment: Text.AlignVCenter
}
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: root.retryRequested(msgRow.modelData.id)
}
MenuItem {
id: copyMenuItem
text: "Copy text"
leftPadding: 0
contentItem: Item {
implicitHeight: Math.max(copyIcon.height, copyLabel.implicitHeight)
DankIcon {
id: copyIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "content_copy"
size: 16
color: Theme.surfaceText
}
StyledText {
id: copyLabel
anchors.left: copyIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: copyMenuItem.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
verticalAlignment: Text.AlignVCenter
}
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
// Same clipboard mechanism DMS's own DankColorPickerModal.qml
// uses - no direct Qt clipboard API exposed to QML here.
onTriggered: Quickshell.execDetached(["dms", "clipboard", "copy", msgRow.modelData.body || ""])
}
MenuItem {
id: openBrowserMenuItem
text: "Open in browser"
visible: msgRow.rowFirstUrl !== ""
height: visible ? implicitHeight : 0
leftPadding: 0
contentItem: Item {
implicitHeight: Math.max(openBrowserIcon.height, openBrowserLabel.implicitHeight)
DankIcon {
id: openBrowserIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
name: "open_in_new"
size: 16
color: Theme.surfaceText
}
StyledText {
id: openBrowserLabel
anchors.left: openBrowserIcon.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: openBrowserMenuItem.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
verticalAlignment: Text.AlignVCenter
}
}
background: Rectangle {
color: parent.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0)
radius: Theme.cornerRadius / 2
}
onTriggered: Qt.openUrlExternally(msgRow.rowFirstUrl)
}
}
}
}