Skip to content

Commit 610b12d

Browse files
committed
Threads: promote the next reply to full when a group head is deleted
Follow-up to thread message grouping. When the first reply of a same-author group (the row that carries the avatar and name) was deleted, the compact replies under it kept their headerless form and looked authorless until reload. Add `_promoteThreadCompactToFull` (the thread mirror of `_promoteCompactToFull`): it rebuilds the avatar and author header on a compact row while keeping its existing content, toolbar, and reactions. The message-deleted handler calls it when a full thread row is removed and the next sibling is a compact reply. Deleting a middle compact row promotes nothing, since the head above it stands. Thread rows now stash the raw username and avatar so promotion is accurate, and reply/quote resolve the author's nickname from that data on compact rows.
1 parent a922e17 commit 610b12d

2 files changed

Lines changed: 62 additions & 5 deletions

File tree

public/js/modules/app-socket.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,6 +1657,12 @@ _setupSocketListeners() {
16571657
const next = msgEl.nextElementSibling;
16581658
if (next && next.classList.contains('message-compact')) {
16591659
try { this._promoteCompactToFull(next); } catch (e) { /* don't let promotion failure block removal */ }
1660+
} else if (next && next.classList.contains('thread-compact')
1661+
&& !msgEl.classList.contains('thread-compact')) {
1662+
// Deleting the head of a thread group (a full row): promote the next
1663+
// compact reply so it keeps an author header. Deleting a middle
1664+
// compact row needs no promotion — the head above it still stands.
1665+
try { this._promoteThreadCompactToFull(next); } catch (e) { /* non-fatal */ }
16601666
}
16611667
msgEl.remove();
16621668
});

public/js/modules/app-utilities.js

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2243,7 +2243,9 @@ _setThreadParentHeader(meta = {}) {
22432243
},
22442244

22452245
_setThreadReply(msgEl, msgId) {
2246-
const author = msgEl.querySelector('.thread-msg-author')?.textContent || msgEl.dataset.username || 'someone';
2246+
const author = msgEl.querySelector('.thread-msg-author')?.textContent
2247+
|| this._getNickname?.(parseInt(msgEl.dataset.userId, 10), msgEl.dataset.username)
2248+
|| msgEl.dataset.username || 'someone';
22472249
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
22482250
const preview = rawContent.length > 70 ? rawContent.substring(0, 70) + '…' : rawContent;
22492251
this._threadReplyingTo = { id: msgId, username: author, content: rawContent };
@@ -2266,7 +2268,9 @@ _clearThreadReply() {
22662268

22672269
_quoteThreadMessage(msgEl) {
22682270
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.thread-msg-content')?.textContent || '';
2269-
const author = msgEl.querySelector('.thread-msg-author')?.textContent || msgEl.dataset.username || 'someone';
2271+
const author = msgEl.querySelector('.thread-msg-author')?.textContent
2272+
|| this._getNickname?.(parseInt(msgEl.dataset.userId, 10), msgEl.dataset.username)
2273+
|| msgEl.dataset.username || 'someone';
22702274
const quotedLines = rawContent.split('\n').map(l => `> ${l}`).join('\n');
22712275
const quoteText = `> @${author} wrote:\n${quotedLines}\n`;
22722276

@@ -2947,9 +2951,12 @@ _appendThreadMessage(msg) {
29472951
el.dataset.rawContent = msg.content;
29482952
el.dataset.userId = msg.user_id;
29492953
el.dataset.time = msg.created_at;
2950-
// Stash the display name so reply/quote still resolve the author on compact
2951-
// rows, which don't render a `.thread-msg-author` element.
2952-
el.dataset.username = displayName;
2954+
// Stash the raw username + avatar so a compact row can be promoted back to a
2955+
// full row (with the header restored) if the group head above it is deleted,
2956+
// and so reply/quote can resolve the author on compact rows that have no
2957+
// `.thread-msg-author` element.
2958+
el.dataset.username = msg.username || '';
2959+
if (msg.avatar) el.dataset.avatar = msg.avatar;
29532960
if (msg.persona_id) el.dataset.personaId = String(msg.persona_id);
29542961
if (threadCompact) {
29552962
const shortTime = new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -2994,6 +3001,50 @@ _appendThreadMessage(msg) {
29943001
container.scrollTop = container.scrollHeight;
29953002
},
29963003

3004+
// Promote a compact thread reply back to a full row (avatar + author header
3005+
// restored), keeping its existing content/toolbar/reactions. Called when the
3006+
// group head above it is deleted, so the new head still shows who sent it —
3007+
// the thread mirror of `_promoteCompactToFull`.
3008+
_promoteThreadCompactToFull(compactEl) {
3009+
if (!compactEl) return;
3010+
const userId = parseInt(compactEl.dataset.userId, 10);
3011+
const rawUsername = compactEl.dataset.username || t('app.messages.unknown_user');
3012+
const displayName = this._getNickname?.(userId, rawUsername) || rawUsername;
3013+
const time = compactEl.dataset.time;
3014+
const color = this._getUserColor(rawUsername);
3015+
const initial = (displayName || '?').charAt(0).toUpperCase();
3016+
3017+
// Preserve the already-rendered content, toolbar, and reactions.
3018+
const contentHtml = compactEl.querySelector('.thread-msg-content')?.innerHTML || '';
3019+
const toolbarHtml = compactEl.querySelector('.thread-msg-toolbar')?.outerHTML || '';
3020+
const reactionsHtml = compactEl.querySelector('.reactions-row')?.outerHTML || '';
3021+
3022+
// Avatar: stored at render time, else the online/member list, else initial.
3023+
const _pool = (this._lastOnlineUsers || []).concat(this.channelMembers || []);
3024+
const onlineUser = _pool.find(u => u.id === userId) || null;
3025+
const avatar = compactEl.dataset.avatar || (onlineUser && onlineUser.avatar) || null;
3026+
const avatarHtml = avatar
3027+
? `<img class="thread-msg-avatar" src="${this._escapeHtml(avatar)}" alt="${initial}">`
3028+
: `<div class="thread-msg-avatar thread-msg-avatar-initial" style="background:${color}">${initial}</div>`;
3029+
3030+
compactEl.classList.remove('thread-compact');
3031+
compactEl.innerHTML = `
3032+
<div class="thread-msg-row">
3033+
${avatarHtml}
3034+
<div class="thread-msg-body">
3035+
<div class="thread-msg-header">
3036+
<span class="thread-msg-author" style="color:${color}">${this._escapeHtml(displayName)}</span>
3037+
<span class="thread-msg-time">${this._formatTime(time)}</span>
3038+
<span class="thread-msg-header-spacer"></span>
3039+
${toolbarHtml}
3040+
</div>
3041+
<div class="thread-msg-content">${contentHtml}</div>
3042+
${reactionsHtml}
3043+
</div>
3044+
</div>
3045+
`;
3046+
},
3047+
29973048
_updateThreadPreview(parentId, thread) {
29983049
const msgEl = document.querySelector(`[data-msg-id="${parentId}"]`);
29993050
if (!msgEl) return;

0 commit comments

Comments
 (0)