- 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]>
2496 lines
115 KiB
QML
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, "&").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 ->
|
|
// <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};"> ${root.escapeHtml(code)} </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)
|
|
}
|
|
}
|
|
}
|
|
}
|