mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-29 10:05:56 +00:00
203 lines
9.0 KiB
JavaScript
203 lines
9.0 KiB
JavaScript
// Thread replies you have not seen, and which of them make a sound.
|
|
//
|
|
// Every reply in a channel reaches everyone in it, so a busy thread could
|
|
// ping people who never looked at it, with nothing on screen to say where
|
|
// the noise came from. Now:
|
|
// - a reply makes a sound only for those taking part in the thread (they
|
|
// started it, replied in it, or have it open), or when it mentions you or
|
|
// answers one of your messages; muting a forum topic takes you out
|
|
// - the thread's chip under its message shows a dot and "N new", the
|
|
// channel's Threads button shows how many threads have news, and a channel
|
|
// in the sidebar gets a dot for news in a thread you take part in
|
|
// - opening the thread clears it, here and on your other devices
|
|
// Forum topics keep their own dots (app-forum-unread.js); only the sound
|
|
// rule applies to them. The server names who takes part (src/threadNotify.js).
|
|
|
|
export default {
|
|
|
|
// Which sound a thread reply makes, or null. `quiet` is the server and member
|
|
// mutes (app-server-mute.js), which already let mentions through when set to.
|
|
_threadReplySound({ mine = false, channelMuted = false, quiet = false, isMention = false, isReplyToMe = false, followed = false, open = false } = {}) {
|
|
if (mine || channelMuted || quiet) return null;
|
|
if (isMention) return 'mention';
|
|
if (isReplyToMe) return 'reply';
|
|
if (followed || open) return 'message';
|
|
return null;
|
|
},
|
|
|
|
_setupThreadUnread() {
|
|
if (!this._threadUnread) this._threadUnread = new Map();
|
|
// Opened on another device: the news is read here too.
|
|
this.socket.on('thread-read', (data) => {
|
|
if (data && data.parentId != null) this._clearThreadUnread(data.parentId);
|
|
});
|
|
},
|
|
|
|
_threadIsOpen(parentId) {
|
|
const panel = document.getElementById('thread-panel');
|
|
return !!panel && panel.style.display !== 'none' && String(this._activeThreadParent) === String(parentId);
|
|
},
|
|
|
|
_isForumCode(code) {
|
|
return !!this.channels?.find(c => c.code === code && c.is_forum);
|
|
},
|
|
|
|
// A reply landed (new-thread-message): sound, notification and markers.
|
|
_onThreadReply(data) {
|
|
const msg = data && data.message;
|
|
if (!msg || !this.user) return;
|
|
const parentId = Number(data.parentId);
|
|
const code = data.channelCode;
|
|
const mine = msg.user_id != null && msg.user_id === this.user.id;
|
|
if (mine) return;
|
|
let mutedChs = [];
|
|
try { mutedChs = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]'); } catch (err) { console.warn('[Thread] could not read muted channels', err); }
|
|
const channelMuted = mutedChs.includes(code);
|
|
const content = msg.content || '';
|
|
const meEsc = (this.user.username || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const isMention = (!!meEsc && new RegExp(`@${meEsc}(?!\\w)`, 'i').test(content))
|
|
|| /(?<![\w@])@(everyone|here)\b/i.test(content)
|
|
|| !!this._mentionsMyRole?.(content);
|
|
// Someone starting a thread on your message counts as a reply to you.
|
|
const isReplyToMe = !!(msg.replyContext && msg.replyContext.user_id === this.user.id)
|
|
|| data.threadStartedFor === this.user.id;
|
|
const followed = Array.isArray(data.followerIds) && data.followerIds.includes(this.user.id);
|
|
const open = this._threadIsOpen(parentId);
|
|
// A silent reply (app-silent.js) still marks the thread, but makes no sound.
|
|
const quiet = !!msg.silent || !!this._notifyBlockedByMutes?.({ senderId: msg.user_id, isDm: false, isMention });
|
|
const sound = this._threadReplySound({ channelMuted, quiet, isMention, isReplyToMe, followed, open });
|
|
|
|
// The bell in the header and the sidebar, unless the thread is on screen.
|
|
if ((isMention || isReplyToMe) && !channelMuted && !open) this._recordThreadMention(code, parentId, msg);
|
|
if (sound) {
|
|
const opts = sound === 'mention' ? { isMention: true } : sound === 'reply' ? { isReply: true } : undefined;
|
|
this.notifications.play(sound, opts);
|
|
if (document.hidden) this._fireNativeNotification({ ...msg, content: `[thread] ${content}` }, code, opts);
|
|
}
|
|
|
|
if (this._isForumCode(code)) return;
|
|
if (open) {
|
|
// Seen as it arrived: keep it read after a reload and on other devices.
|
|
this.socket.emit('mark-thread-read', { parentId });
|
|
return;
|
|
}
|
|
const entry = this._threadUnread.get(parentId) || { code, count: 0, followed: false };
|
|
entry.count += 1;
|
|
entry.followed = entry.followed || followed || isMention || isReplyToMe;
|
|
this._threadUnread.set(parentId, entry);
|
|
this._paintThreadUnread(parentId);
|
|
},
|
|
|
|
// How many replies in this thread are new to you. A count from the server
|
|
// (the threads you follow) is the truth and replaces the one kept here.
|
|
_threadNewCount(parentId, thread) {
|
|
const id = Number(parentId);
|
|
if (!this._threadUnread) this._threadUnread = new Map();
|
|
if (thread && typeof thread.newCount === 'number') {
|
|
if (thread.newCount > 0) {
|
|
const prev = this._threadUnread.get(id);
|
|
this._threadUnread.set(id, { code: this.currentChannel, count: thread.newCount, followed: true, ...(prev ? { code: prev.code } : {}) });
|
|
} else {
|
|
this._threadUnread.delete(id);
|
|
}
|
|
return thread.newCount;
|
|
}
|
|
return this._threadUnread.get(id)?.count || 0;
|
|
},
|
|
|
|
// The "N new" marker inside a thread chip.
|
|
_threadChipNewHtml(count) {
|
|
if (!count) return '';
|
|
const label = t(count === 1 ? 'thread_runtime.new_one' : 'thread_runtime.new_other', { count: count > 99 ? '99+' : count });
|
|
return `<span class="thread-preview-new"><span class="thread-new-dot" aria-hidden="true"></span>${this._escapeHtml(label)}</span>`;
|
|
},
|
|
|
|
_clearThreadUnread(parentId) {
|
|
const id = Number(parentId);
|
|
if (!this._threadUnread || !this._threadUnread.has(id)) return;
|
|
const code = this._threadUnread.get(id).code;
|
|
this._threadUnread.delete(id);
|
|
this._paintThreadUnread(id, code);
|
|
},
|
|
|
|
// Brings every marker for one thread up to date.
|
|
_paintThreadUnread(parentId, code) {
|
|
const id = Number(parentId);
|
|
const entry = this._threadUnread?.get(id);
|
|
const chCode = entry ? entry.code : code;
|
|
const chip = document.querySelector(`#messages .thread-preview[data-thread-parent="${id}"]`);
|
|
if (chip) {
|
|
chip.querySelector('.thread-preview-new')?.remove();
|
|
const html = this._threadChipNewHtml(entry ? entry.count : 0);
|
|
chip.classList.toggle('thread-preview-has-new', !!html);
|
|
if (html) (chip.querySelector('.thread-preview-count') || chip.lastElementChild)?.insertAdjacentHTML('afterend', html);
|
|
}
|
|
this._updateThreadsButtonBadge();
|
|
if (chCode) this._updateThreadDot(chCode);
|
|
},
|
|
|
|
// Chips are drawn many at a time with a channel; the button is counted once
|
|
// they are all in.
|
|
_queueThreadsBadge() {
|
|
if (this._threadsBadgeQueued) return;
|
|
this._threadsBadgeQueued = true;
|
|
setTimeout(() => { this._threadsBadgeQueued = false; this._paintThreadDots(); }, 0);
|
|
},
|
|
|
|
// The channel header's Threads button: how many threads here have news.
|
|
_updateThreadsButtonBadge() {
|
|
const btn = document.getElementById('threads-toggle-btn');
|
|
if (!btn) return;
|
|
let n = 0;
|
|
for (const e of (this._threadUnread || new Map()).values()) if (e.code === this.currentChannel && e.count > 0) n++;
|
|
let badge = btn.querySelector('.threads-btn-badge');
|
|
if (!n) { badge?.remove(); btn.classList.remove('threads-btn-has-new'); return; }
|
|
if (!badge) {
|
|
badge = document.createElement('span');
|
|
badge.className = 'threads-btn-badge';
|
|
btn.appendChild(badge);
|
|
}
|
|
btn.classList.add('threads-btn-has-new');
|
|
badge.textContent = n > 9 ? '9+' : String(n);
|
|
badge.title = t(n === 1 ? 'thread_runtime.threads_new_one' : 'thread_runtime.threads_new_other', { count: n });
|
|
},
|
|
|
|
// A dot on the channel in the sidebar while a thread you take part in has
|
|
// replies you have not seen. Forums have their own; a muted channel none.
|
|
_updateThreadDot(code, el = null) {
|
|
el = el || document.querySelector(`.channel-item[data-code="${code}"]`);
|
|
if (!el) return;
|
|
let muted = false;
|
|
try { muted = JSON.parse(localStorage.getItem('haven_muted_channels') || '[]').includes(code); } catch (err) { console.warn('[Thread] could not read muted channels', err); }
|
|
let any = false;
|
|
for (const e of (this._threadUnread || new Map()).values()) if (e.code === code && e.followed && e.count > 0) { any = true; break; }
|
|
const show = any && !muted && !this._isForumCode(code);
|
|
let dot = el.querySelector('.channel-thread-dot');
|
|
if (show && !dot) {
|
|
dot = document.createElement('span');
|
|
dot.className = 'channel-thread-dot';
|
|
dot.title = t('thread_runtime.sidebar_unread');
|
|
el.appendChild(dot);
|
|
} else if (!show && dot) {
|
|
dot.remove();
|
|
}
|
|
},
|
|
|
|
// After the sidebar or the channel is drawn again.
|
|
_paintThreadDots() {
|
|
const codes = new Set();
|
|
for (const e of (this._threadUnread || new Map()).values()) codes.add(e.code);
|
|
document.querySelectorAll('.channel-thread-dot').forEach(d => codes.add(d.closest('.channel-item')?.dataset.code));
|
|
codes.forEach(c => c && this._updateThreadDot(c));
|
|
this._updateThreadsButtonBadge();
|
|
},
|
|
|
|
// The marker on a row of the Threads list.
|
|
_threadListNewHtml(th) {
|
|
const n = this._threadNewCount(th.id, typeof th.new_count === 'number' ? { newCount: th.new_count } : null);
|
|
this._queueThreadsBadge();
|
|
return n ? `<span class="thread-list-new">${this._threadChipNewHtml(n)}</span>` : '';
|
|
},
|
|
|
|
};
|