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 // 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, "&").replace(//g, ">"); } // 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 -> //
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 tags formatMessage below uses for everything else - // Qt's StyledText/RichText image support has no concept of an // animated 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
) 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(/([\s\S]*?)<\/a>/gi, (m, href, label) => stow(`${label}`)); // 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(` ${root.escapeHtml(code)} `)); // 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(`${root.escapeHtml(inner)}`); return stow(`${root.escapeHtml(inner)}`); }); // 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 sigh 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 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 // 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(``); }); } // Markdown links: [label](url) out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (m, label, href) => stow(`${label}`)); // BBCode: [url=link]label[/url] and [url]link[/url] out = out.replace(/\[url=(https?:\/\/[^\]\s]+)\]([\s\S]*?)\[\/url\]/gi, (m, href, label) => stow(`${label}`)); out = out.replace(/\[url\](https?:\/\/[^\]\s]+)\[\/url\]/gi, (m, href) => stow(`${href}`)); // 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, "$1"); out = out.replace(/\[(?:i|em)\]([\s\S]*?)\[\/\s*(?:i|em)\]/gi, "$1"); out = out.replace(/\[u\]([\s\S]*?)\[\/\s*u\]/gi, "$1"); out = out.replace(/\[(?:s|strike)\]([\s\S]*?)\[\/\s*(?:s|strike)\]/gi, "$1"); // [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) => `${inner}`); // 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, "$1"); out = out.replace(/__([^_]+)__/g, "$1"); out = out.replace(/~~([^~]+)~~/g, "$1"); out = out.replace(/\*([^*]+)\*/g, "$1"); out = out.replace(/(^|\s)_([^_]+)_(?=\s|$)/g, "$1$2"); // Any bare URL not already wrapped by one of the formats above. out = out.replace(/(https?:\/\/[^\s<]+)/g, "$1"); // 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
is what StyledText actually needs. out = out.replace(/\n/g, "
"); 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 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 // (, , , ) 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(/^$/) 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) } } } }