mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-30 02:25:38 +00:00
803 lines
36 KiB
JavaScript
803 lines
36 KiB
JavaScript
// Tools that act on messages: the pinned messages panel and its pop-out,
|
||
// moving and bulk-deleting messages, the message right-click menu, and
|
||
// editing the tags on a message's attachments.
|
||
|
||
export default {
|
||
|
||
// ── Pinned Messages Panel ─────────────────────────────
|
||
|
||
_renderPinnedPanel(pins) {
|
||
// Always cache the latest pin list. It's used by the pop-out button and by
|
||
// the PiP to refresh after a pin/unpin event without a full re-open.
|
||
this._lastPins = pins;
|
||
|
||
const panel = document.getElementById('pinned-panel');
|
||
const list = document.getElementById('pinned-list');
|
||
const count = document.getElementById('pinned-count');
|
||
|
||
count.textContent = `📌 ${t(pins.length !== 1 ? 'pinned_panel.count_other' : 'pinned_panel.count_one', { count: pins.length })}`;
|
||
|
||
const canUnpin = this.user?.isAdmin || this._hasPerm('pin_message');
|
||
|
||
if (pins.length === 0) {
|
||
list.innerHTML = `<p class="muted-text" style="padding:12px">${t('pinned_panel.no_messages')}</p>`;
|
||
} else {
|
||
list.innerHTML = pins.map(p => `
|
||
<div class="pinned-item" data-msg-id="${p.id}">
|
||
<div class="pinned-item-header">
|
||
<span class="pinned-item-author" style="color:${this._getUserColor(p.username)}">${this._escapeHtml(this._getNickname(p.user_id, p.username))}</span>
|
||
<span class="pinned-item-time">${this._formatTime(p.created_at)}</span>
|
||
</div>
|
||
<div class="pinned-item-content">${this._formatContent(p.content)}</div>
|
||
<div class="pinned-item-footer" style="display:flex;align-items:center;justify-content:space-between">
|
||
<span>${t('pinned_panel.pinned_by', { user: this._escapeHtml(p.pinned_by) })}</span>
|
||
${canUnpin ? `<button class="pinned-unpin-btn btn-xs" data-msg-id="${p.id}" title="${this._escapeHtml(t('msg_toolbar.unpin'))}">${this._escapeHtml(t('msg_toolbar.unpin'))}</button>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// When this render was triggered by a PiP auto-refresh (message-pinned
|
||
// event), skip showing/re-showing the sidebar panel; only update it if
|
||
// the user already has it visible.
|
||
const silentRefresh = this._pinsPipSilentRefresh;
|
||
this._pinsPipSilentRefresh = false;
|
||
if (silentRefresh) {
|
||
if (panel.style.display === 'block') {
|
||
// Sidebar is already open: re-wire its click handlers to the fresh DOM
|
||
this._rewirePinnedSidebarHandlers(list, panel);
|
||
}
|
||
} else {
|
||
panel.style.display = 'block';
|
||
this._rewirePinnedSidebarHandlers(list, panel);
|
||
}
|
||
|
||
// If the PiP is currently open, refresh it with the latest pin data too.
|
||
const pipPanel = document.getElementById('pins-pip-panel');
|
||
if (pipPanel && pipPanel.style.display !== 'none') {
|
||
this._renderPinsPiPList(pins);
|
||
}
|
||
},
|
||
|
||
// Wire the sidebar pinned-panel click handlers. Extracted so both the
|
||
// normal open path and the silent-refresh path can call it without
|
||
// repeating code.
|
||
_rewirePinnedSidebarHandlers(list, panel) {
|
||
// Click to scroll to pinned message (uses _jumpToMessage to handle
|
||
// messages that have been trimmed from the DOM)
|
||
list.querySelectorAll('.pinned-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const msgId = parseInt(item.dataset.msgId, 10);
|
||
panel.style.display = 'none';
|
||
if (msgId) this._jumpToMessage(msgId);
|
||
});
|
||
});
|
||
|
||
// Unpin buttons: stop propagation so click doesn't also jump to message
|
||
list.querySelectorAll('.pinned-unpin-btn').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const msgId = parseInt(btn.dataset.msgId, 10);
|
||
if (!msgId) return;
|
||
const ok = await this._showConfirmModal(t('confirm.unpin_message'), '');
|
||
if (ok) this.socket.emit('unpin-message', { messageId: msgId });
|
||
});
|
||
});
|
||
},
|
||
|
||
// ── Pinned Messages PiP ───────────────────────────────
|
||
|
||
/** Open the floating PiP overlay for pinned messages. */
|
||
_openPinsPiP(pins) {
|
||
const panel = document.getElementById('pins-pip-panel');
|
||
if (!panel) return;
|
||
this._pinsPipChannelCode = this.currentChannel;
|
||
panel.style.display = 'flex';
|
||
|
||
// Title: channel name
|
||
const titleEl = document.getElementById('pins-pip-title');
|
||
if (titleEl) {
|
||
const ch = (this.channels || []).find(c => c.code === this.currentChannel);
|
||
titleEl.textContent = ch ? `# ${ch.name}` : (this.currentChannel || t('status_bar.channel'));
|
||
}
|
||
|
||
this._renderPinsPiPList(pins || []);
|
||
this._applyPinsPiPGeometry(panel);
|
||
this._bindPinsPiPDrag();
|
||
},
|
||
|
||
/** Close the pinned messages PiP. */
|
||
_closePinsPiP() {
|
||
this._pinsPipChannelCode = null;
|
||
const panel = document.getElementById('pins-pip-panel');
|
||
if (panel) panel.style.display = 'none';
|
||
},
|
||
|
||
/** Render the pin list inside the PiP overlay.
|
||
* Uses delegated click handlers (wired once in app-ui.js), no inline
|
||
* event listeners attached here to avoid double-binding on refresh. */
|
||
_renderPinsPiPList(pins) {
|
||
const list = document.getElementById('pins-pip-list');
|
||
if (!list) return;
|
||
const canUnpin = this.user?.isAdmin || this._hasPerm('pin_message');
|
||
if (!pins || pins.length === 0) {
|
||
list.innerHTML = `<p class="muted-text" style="padding:12px">${t('pinned_panel.no_messages')}</p>`;
|
||
return;
|
||
}
|
||
list.innerHTML = pins.map(p => `
|
||
<div class="pinned-item" data-msg-id="${p.id}">
|
||
<div class="pinned-item-header">
|
||
<span class="pinned-item-author" style="color:${this._getUserColor(p.username)}">${this._escapeHtml(this._getNickname(p.user_id, p.username))}</span>
|
||
<span class="pinned-item-time">${this._formatTime(p.created_at)}</span>
|
||
</div>
|
||
<div class="pinned-item-content">${this._formatContent(p.content)}</div>
|
||
<div class="pinned-item-footer" style="display:flex;align-items:center;justify-content:space-between">
|
||
<span>${t('pinned_panel.pinned_by', { user: this._escapeHtml(p.pinned_by) })}</span>
|
||
${canUnpin ? `<button class="pinned-unpin-btn btn-xs" data-msg-id="${p.id}" title="${this._escapeHtml(t('msg_toolbar.unpin'))}">${this._escapeHtml(t('msg_toolbar.unpin'))}</button>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
},
|
||
|
||
/** Restore saved PiP position + size from localStorage. */
|
||
_applyPinsPiPGeometry(panel) {
|
||
let saved = null;
|
||
try { saved = JSON.parse(localStorage.getItem('haven_pins_pip_rect') || 'null'); } catch { /* corrupt saved position: use the default placement */ }
|
||
const minW = 280, minH = 220;
|
||
const maxW = Math.min(600, window.innerWidth - 28);
|
||
const maxH = Math.max(minH, window.innerHeight - 28);
|
||
const width = Math.max(minW, Math.min(maxW, (saved && saved.width) || 360));
|
||
const height = Math.max(minH, Math.min(maxH, (saved && saved.height) || 440));
|
||
const defaultLeft = Math.max(0, window.innerWidth - width - 20);
|
||
const defaultTop = Math.max(0, window.innerHeight - height - 80);
|
||
const left = Math.max(0, Math.min(window.innerWidth - width, (saved && Number.isFinite(saved.left)) ? saved.left : defaultLeft));
|
||
const top = Math.max(0, Math.min(window.innerHeight - height, (saved && Number.isFinite(saved.top)) ? saved.top : defaultTop));
|
||
panel.style.width = `${Math.round(width)}px`;
|
||
panel.style.height = `${Math.round(height)}px`;
|
||
panel.style.left = `${Math.round(left)}px`;
|
||
panel.style.top = `${Math.round(top)}px`;
|
||
},
|
||
|
||
/** Bind drag-to-move on the PiP header (called once). */
|
||
_bindPinsPiPDrag() {
|
||
if (this._pinsPipDragBound) return;
|
||
this._pinsPipDragBound = true;
|
||
const panel = document.getElementById('pins-pip-panel');
|
||
if (!panel) return;
|
||
const header = panel.querySelector('.pins-pip-header');
|
||
if (!header) return;
|
||
let startX = 0, startY = 0, startLeft = 0, startTop = 0, dragging = false;
|
||
header.addEventListener('mousedown', (e) => {
|
||
if (e.target.closest('button, a')) return;
|
||
if (panel.classList.contains('pins-pip-maximized')) return;
|
||
dragging = true;
|
||
startX = e.clientX; startY = e.clientY;
|
||
const r = panel.getBoundingClientRect();
|
||
startLeft = r.left; startTop = r.top;
|
||
e.preventDefault();
|
||
});
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!dragging) return;
|
||
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||
const left = Math.max(0, Math.min(window.innerWidth - w, startLeft + (e.clientX - startX)));
|
||
const top = Math.max(0, Math.min(window.innerHeight - h, startTop + (e.clientY - startY)));
|
||
panel.style.left = `${left}px`;
|
||
panel.style.top = `${top}px`;
|
||
});
|
||
const persist = () => {
|
||
if (!panel || panel.style.display === 'none') return;
|
||
try {
|
||
localStorage.setItem('haven_pins_pip_rect', JSON.stringify({
|
||
left: parseInt(panel.style.left, 10) || 0,
|
||
top: parseInt(panel.style.top, 10) || 0,
|
||
width: panel.offsetWidth,
|
||
height: panel.offsetHeight
|
||
}));
|
||
} catch { /* storage blocked (private mode): nothing is remembered, nothing else breaks */ }
|
||
};
|
||
window.addEventListener('mouseup', () => {
|
||
if (dragging) { dragging = false; persist(); }
|
||
});
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
const ro = new ResizeObserver(() => persist());
|
||
ro.observe(panel);
|
||
}
|
||
},
|
||
|
||
// ── Move Messages (multi-select) ──────────────────────
|
||
|
||
_moveSelectionActive: false,
|
||
_moveSelectedIds: new Set(),
|
||
|
||
_enterMoveSelectionMode() {
|
||
if (this._moveSelectionActive) return;
|
||
this._moveSelectionActive = true;
|
||
this._moveSelectedIds.clear();
|
||
// Where the selection is; "Move here as thread" can take it to another
|
||
// channel (app-move-to-thread.js).
|
||
this._moveSourceChannel = this.currentChannel;
|
||
this._watchMoveSelection?.(true);
|
||
document.body.classList.add('move-selection-mode');
|
||
const toolbar = document.getElementById('move-msg-toolbar');
|
||
if (toolbar) toolbar.style.display = 'flex';
|
||
this._updateMoveCount();
|
||
},
|
||
|
||
_exitMoveSelectionMode() {
|
||
this._moveSelectionActive = false;
|
||
this._moveSelectedIds.clear();
|
||
this._lastMoveSelectedEl = null;
|
||
this._moveSourceChannel = null;
|
||
this._watchMoveSelection?.(false);
|
||
this._endMoveThreadPick?.();
|
||
document.body.classList.remove('move-selection-mode');
|
||
const toolbar = document.getElementById('move-msg-toolbar');
|
||
if (toolbar) toolbar.style.display = 'none';
|
||
document.querySelectorAll('.move-selected').forEach(el => el.classList.remove('move-selected'));
|
||
},
|
||
|
||
_toggleMoveSelect(msgEl) {
|
||
if (!this._moveSelectionActive) return;
|
||
const id = parseInt(msgEl.dataset.msgId);
|
||
if (!id) return;
|
||
if (this._moveSelectedIds.has(id)) {
|
||
this._moveSelectedIds.delete(id);
|
||
msgEl.classList.remove('move-selected');
|
||
} else {
|
||
if (this._moveSelectedIds.size >= 200) {
|
||
this._showToast(t('modals.move_messages.max_messages', { max: 200 }), 'error');
|
||
return;
|
||
}
|
||
this._moveSelectedIds.add(id);
|
||
msgEl.classList.add('move-selected');
|
||
}
|
||
this._updateMoveCount();
|
||
},
|
||
|
||
_updateMoveCount() {
|
||
const countEl = document.getElementById('move-msg-count');
|
||
const moveBtn = document.getElementById('move-msg-move-btn');
|
||
const deleteBtn = document.getElementById('move-msg-delete-btn');
|
||
const n = this._moveSelectedIds.size;
|
||
if (countEl) countEl.textContent = t('modals.move_messages.selected', { n });
|
||
if (moveBtn) moveBtn.disabled = n === 0;
|
||
if (deleteBtn) deleteBtn.disabled = n === 0;
|
||
this._syncMoveThreadUi?.();
|
||
},
|
||
|
||
_showMoveChannelPicker() {
|
||
if (this._moveSelectedIds.size === 0) return;
|
||
const list = document.getElementById('move-msg-channel-list');
|
||
const modal = document.getElementById('move-msg-modal');
|
||
const desc = document.getElementById('move-msg-desc');
|
||
if (!list || !modal) return;
|
||
|
||
const _n = this._moveSelectedIds.size;
|
||
desc.textContent = t(_n === 1 ? 'modals.move_messages.move_one' : 'modals.move_messages.move_many', { n: _n });
|
||
list.innerHTML = '';
|
||
|
||
const source = this._moveSourceChannel || this.currentChannel;
|
||
const channels = (this.channels || []).filter(ch =>
|
||
!ch.is_dm && ch.code !== source
|
||
);
|
||
|
||
if (channels.length === 0) {
|
||
list.innerHTML = `<div class="move-msg-empty">${t('modals.move_messages.no_channels')}</div>`;
|
||
} else {
|
||
for (const ch of channels) {
|
||
const row = document.createElement('div');
|
||
row.className = 'move-msg-channel-row';
|
||
const item = document.createElement('button');
|
||
item.className = 'move-msg-channel-item';
|
||
item.textContent = `# ${ch.name}`;
|
||
item.addEventListener('click', () => {
|
||
this._executeMoveMessages(ch.code, ch.name);
|
||
modal.style.display = 'none';
|
||
});
|
||
// Or go there and pick a message to put them under as a thread.
|
||
const toThread = document.createElement('button');
|
||
toThread.className = 'move-msg-thread-pick';
|
||
toThread.textContent = `🧵 ${t('modals.move_messages.pick_in_channel')}`;
|
||
toThread.title = t('modals.move_messages.pick_in_channel_title', { name: ch.name });
|
||
toThread.addEventListener('click', () => this._startMoveThreadPick?.(ch.code));
|
||
row.append(item, toThread);
|
||
list.appendChild(row);
|
||
}
|
||
}
|
||
|
||
modal.style.display = 'flex';
|
||
},
|
||
|
||
_executeMoveMessages(toCode, toName) {
|
||
const ids = [...this._moveSelectedIds];
|
||
const fromCode = this._moveSourceChannel || this.currentChannel;
|
||
|
||
this.socket.emit('move-messages', {
|
||
messageIds: ids,
|
||
fromChannel: fromCode,
|
||
toChannel: toCode
|
||
}, (resp) => {
|
||
if (resp && resp.error) {
|
||
this._showToast(resp.error, 'error');
|
||
} else if (resp && resp.success) {
|
||
this._showToast(t(resp.moved === 1 ? 'modals.move_messages.moved_one' : 'modals.move_messages.moved_many', { n: resp.moved, name: toName }), 'success');
|
||
}
|
||
this._exitMoveSelectionMode();
|
||
});
|
||
},
|
||
|
||
async _executeDeleteMessages() {
|
||
const n = this._moveSelectedIds.size;
|
||
if (n === 0) return;
|
||
const ok = await this._showConfirmModal(
|
||
t(n === 1 ? 'modals.move_messages.delete_confirm_one' : 'modals.move_messages.delete_confirm_many', { n }),
|
||
t('modals.move_messages.delete_confirm_warn'),
|
||
{ danger: true, confirmLabel: t('msg_toolbar.delete') }
|
||
);
|
||
if (!ok) return;
|
||
|
||
const ids = [...this._moveSelectedIds];
|
||
const code = this._moveSourceChannel || this.currentChannel;
|
||
// For E2E DMs the server can't read attachment URLs out of ciphertext, so
|
||
// hand it the URLs per message the same way single-delete does. The move
|
||
// selector is mod/non-DM gated today, but keep this future-proof.
|
||
const attachmentsByMessage = {};
|
||
for (const id of ids) {
|
||
const urls = this._getMessageAttachments?.(id);
|
||
if (Array.isArray(urls) && urls.length) attachmentsByMessage[id] = urls;
|
||
}
|
||
|
||
this.socket.emit('delete-messages', {
|
||
code,
|
||
messageIds: ids,
|
||
attachmentsByMessage
|
||
}, (resp) => {
|
||
if (resp && resp.error && !resp.deleted) {
|
||
this._showToast(resp.error, 'error');
|
||
} else if (resp && resp.success) {
|
||
const deleted = resp.deleted || 0;
|
||
const skipped = resp.skipped || 0;
|
||
if (skipped > 0) {
|
||
this._showToast(t('modals.move_messages.deleted_partial', { n: deleted, skipped }), 'warning');
|
||
} else {
|
||
this._showToast(t(deleted === 1 ? 'modals.move_messages.deleted_one' : 'modals.move_messages.deleted_many', { n: deleted }), 'success');
|
||
}
|
||
}
|
||
this._exitMoveSelectionMode();
|
||
});
|
||
},
|
||
|
||
_initMoveMessages() {
|
||
// Header "Select messages" toggle button
|
||
const selectBtn = document.getElementById('move-select-btn');
|
||
if (selectBtn) selectBtn.addEventListener('click', () => {
|
||
if (this._moveSelectionActive) this._exitMoveSelectionMode();
|
||
else this._enterMoveSelectionMode();
|
||
});
|
||
|
||
// "Move to..." button in toolbar
|
||
const moveBtn = document.getElementById('move-msg-move-btn');
|
||
if (moveBtn) moveBtn.addEventListener('click', () => this._showMoveChannelPicker());
|
||
|
||
// "Delete" button in toolbar (#5460)
|
||
const deleteBtn = document.getElementById('move-msg-delete-btn');
|
||
if (deleteBtn) deleteBtn.addEventListener('click', () => this._executeDeleteMessages());
|
||
|
||
// Cancel button in toolbar
|
||
const cancelBtn = document.getElementById('move-msg-cancel-btn');
|
||
if (cancelBtn) cancelBtn.addEventListener('click', () => this._exitMoveSelectionMode());
|
||
|
||
// Cancel button in modal
|
||
const modalCancel = document.getElementById('move-msg-modal-cancel');
|
||
if (modalCancel) modalCancel.addEventListener('click', () => {
|
||
document.getElementById('move-msg-modal').style.display = 'none';
|
||
});
|
||
|
||
// Close modal on overlay click
|
||
const modal = document.getElementById('move-msg-modal');
|
||
if (modal) modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) modal.style.display = 'none';
|
||
});
|
||
},
|
||
|
||
/* ── Message right-click context menu (main #messages pane) ───────────
|
||
A right-click twin of the hover toolbar carrying the same set of actions
|
||
(edit, reply, quote, pin, react, thread, copy-link, archive, delete). It
|
||
calls the exact same underlying methods the toolbar dispatch does (no new
|
||
API surface), gates each item on the same permissions the toolbar uses, and
|
||
borrows the channel context menu's CSS classes (.channel-ctx-menu /
|
||
.channel-ctx-item / .channel-ctx-sep / .danger) so it inherits every
|
||
theme (including win95) for free. Cursor-positioned and self-closing,
|
||
modelled on _showImageContextMenu. */
|
||
_showMessageContextMenu(e, msgEl) {
|
||
this._hideMessageContextMenu();
|
||
const msgId = parseInt(msgEl.dataset.msgId, 10);
|
||
if (!msgId) return;
|
||
|
||
const curCh = this.channels?.find(c => c.code === this.currentChannel);
|
||
const isDm = !!curCh?.is_dm;
|
||
const isOwn = String(msgEl.dataset.userId) === String(this.user?.id);
|
||
const isPinned = msgEl.dataset.pinned === '1' || msgEl.classList.contains('pinned');
|
||
const isArchived = msgEl.dataset.archived === '1' || msgEl.classList.contains('archived');
|
||
const canPin = !!(this.user?.isAdmin || this._hasPerm('pin_message'));
|
||
const canArchive = !!(this.user?.isAdmin || this._hasPerm('archive_messages'));
|
||
const canShareLink = !isDm && !!this._canShareChannelLink?.(this.currentChannel);
|
||
const postBlocked = !isDm && !!this._channelBlocksPosting?.(this.currentChannel);
|
||
// Same level-vs-permission gap as the toolbar above (#5461).
|
||
const canDelete = isOwn || this.user?.isAdmin || this._canModerate() ||
|
||
this._hasPerm('delete_message');
|
||
// Retroactive tag editing (#tagging phase 3): only on non-DM messages that
|
||
// carry an upload. Your own always; anyone else's needs manage_tags.
|
||
const hasAttachment = (this._getMessageAttachments?.(msgId) || []).length > 0;
|
||
const canEditTags = !isDm && hasAttachment &&
|
||
(isOwn || this.user?.isAdmin || this._hasPerm('manage_tags'));
|
||
|
||
// Layout: the actions defined first (Edit, Reply, Quote, Pin), separator,
|
||
// the remaining hover-toolbar actions (React, Thread, Copy Link, Protect),
|
||
// separator, Delete. Every item carries the same data-action the toolbar
|
||
// uses, and each action is gated on the same permission, so the two menus
|
||
// stay behaviourally identical.
|
||
const items = [];
|
||
// Right-clicking a link: its address first, as the browser's menu would.
|
||
const link = e.target?.closest?.('a[href]');
|
||
const linkHref = link && /^https?:/i.test(link.href) ? link.href : null;
|
||
if (linkHref) {
|
||
items.push(`<button class="channel-ctx-item" data-action="copy-href">🔗 <span>${t('msg_toolbar.copy_link_address')}</span></button>`);
|
||
items.push('<hr class="channel-ctx-sep">');
|
||
}
|
||
// First group (as originally defined)
|
||
if (isOwn && !isArchived) items.push(`<button class="channel-ctx-item" data-action="edit">✏️ <span>${t('msg_toolbar.edit')}</span></button>`);
|
||
if (!postBlocked) items.push(`<button class="channel-ctx-item" data-action="reply">↩️ <span>${t('msg_toolbar.reply')}</span></button>`, `<button class="channel-ctx-item" data-action="quote">💬 <span>${t('msg_toolbar.quote')}</span></button>`);
|
||
if (canPin) {
|
||
items.push(isPinned
|
||
? `<button class="channel-ctx-item" data-action="unpin">📌 <span>${t('msg_toolbar.unpin')}</span></button>`
|
||
: `<button class="channel-ctx-item" data-action="pin">📌 <span>${t('msg_toolbar.pin')}</span></button>`);
|
||
}
|
||
// Separator, then the rest of the hover-toolbar actions
|
||
items.push('<hr class="channel-ctx-sep">');
|
||
items.push(`<button class="channel-ctx-item" data-action="react">😀 <span>${t('msg_toolbar.react')}</span></button>`);
|
||
if (!isDm && !postBlocked) items.push(`<button class="channel-ctx-item" data-action="thread">🧵 <span>${t('msg_toolbar.thread')}</span></button>`);
|
||
if (canShareLink) items.push(`<button class="channel-ctx-item" data-action="copy-link">🔗 <span>${t('msg_toolbar.copy_link')}</span></button>`);
|
||
if (canArchive) {
|
||
items.push(isArchived
|
||
? `<button class="channel-ctx-item" data-action="unarchive">🛡️ <span>${t('app.messages.unprotect_btn')}</span></button>`
|
||
: `<button class="channel-ctx-item" data-action="archive">🛡️ <span>${t('app.messages.protect_btn')}</span></button>`);
|
||
}
|
||
// A posted role menu's roles, emojis and text can be changed later (#5644).
|
||
const canEditRoleMenu = !!msgEl.querySelector('.role-menu-widget') &&
|
||
!!(this.user?.isAdmin || this._hasPerm('manage_roles') || this._hasPerm('promote_user'));
|
||
if (canEditRoleMenu) items.push(`<button class="channel-ctx-item" data-action="edit-role-menu">🎭 <span>${t('settings.admin.role_menu.edit')}</span></button>`);
|
||
if (canEditTags) items.push(`<button class="channel-ctx-item" data-action="edit-tags">🏷️ <span>${t('tags.edit')}</span></button>`);
|
||
if (this._canSetMessageNsfw?.(msgEl, isDm)) items.push(this._messageNsfwMenuHtml(msgEl.dataset.nsfw === '1'));
|
||
// Picking messages to move or put in a thread, starting with this one. The
|
||
// header's Select button is hidden on phones, so this is their way in.
|
||
if (!isDm && (this.user?.isAdmin || this._canModerate())) items.push(`<button class="channel-ctx-item" data-action="select-messages">☑️ <span>${t('header.select_messages_to_move')}</span></button>`);
|
||
// Official message: mark your own; take the mark off your own, or anyone's
|
||
// you could delete.
|
||
const isHighlighted = msgEl.dataset.highlighted === '1';
|
||
const isPlainOwn = isOwn && !msgEl.dataset.personaId && !msgEl.classList.contains('webhook-message');
|
||
if (!isDm && !isHighlighted && isPlainOwn && this._canHighlightMessages?.()) {
|
||
items.push(`<button class="channel-ctx-item" data-action="highlight-on">📣 <span>${t('msg_toolbar.highlight_on')}</span></button>`);
|
||
} else if (!isDm && isHighlighted && (isOwn || canDelete)) {
|
||
items.push(`<button class="channel-ctx-item" data-action="highlight-off">📣 <span>${t('msg_toolbar.highlight_off')}</span></button>`);
|
||
}
|
||
// Separator right above Delete. A protected message has no Delete: nobody
|
||
// can delete it until it is unprotected.
|
||
if (canDelete && !isArchived) {
|
||
items.push('<hr class="channel-ctx-sep">');
|
||
items.push(`<button class="channel-ctx-item danger" data-action="delete">🗑️ <span>${t('msg_toolbar.delete')}</span></button>`);
|
||
}
|
||
|
||
const menu = document.createElement('div');
|
||
menu.id = 'message-context-menu';
|
||
menu.className = 'channel-ctx-menu';
|
||
menu.innerHTML = items.join('');
|
||
menu.style.left = e.clientX + 'px';
|
||
menu.style.top = e.clientY + 'px';
|
||
document.body.appendChild(menu);
|
||
|
||
// Clamp inside the viewport (same as the image/channel menus).
|
||
const rect = menu.getBoundingClientRect();
|
||
if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 8) + 'px';
|
||
if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 8) + 'px';
|
||
|
||
menu.addEventListener('click', async (ev) => {
|
||
const btn = ev.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
const action = btn.dataset.action;
|
||
this._hideMessageContextMenu();
|
||
if (['reply', 'quote', 'thread'].includes(action) && this._channelBlocksPosting?.()) return this._showToast?.(t('thread_runtime.read_only_no_replies'), 'info');
|
||
if (action === 'edit') {
|
||
this._startEditMessage(msgEl, msgId);
|
||
} else if (action === 'reply') {
|
||
this._setReply(msgEl, msgId);
|
||
} else if (action === 'quote') {
|
||
this._quoteMessage(msgEl);
|
||
} else if (action === 'react') {
|
||
this._showReactionPicker(msgEl, msgId);
|
||
} else if (action === 'thread') {
|
||
// Defence in depth: threads never exist in DMs.
|
||
if (this.channels?.find(c => c.code === this.currentChannel)?.is_dm) {
|
||
this._showToast?.(t('thread_list.unavailable_in_dm'), 'info');
|
||
} else {
|
||
this._toggleThread(msgId);
|
||
}
|
||
} else if (action === 'copy-href') {
|
||
const onCopied = () => this._showToast(t('toasts.link_address_copied'), 'success');
|
||
if (navigator.clipboard?.writeText) {
|
||
navigator.clipboard.writeText(linkHref).then(onCopied).catch(() => this._copyTextFallback(linkHref, onCopied));
|
||
} else {
|
||
this._copyTextFallback(linkHref, onCopied);
|
||
}
|
||
} else if (action === 'copy-link') {
|
||
this._copyChannelLink(this.currentChannel, msgId);
|
||
} else if (action === 'pin') {
|
||
if (await this._showConfirmModal(t('confirm.pin_message'), '')) {
|
||
this.socket.emit('pin-message', { messageId: msgId });
|
||
}
|
||
} else if (action === 'unpin') {
|
||
this.socket.emit('unpin-message', { messageId: msgId });
|
||
} else if (action === 'archive') {
|
||
this.socket.emit('archive-message', { messageId: msgId });
|
||
} else if (action === 'unarchive') {
|
||
this.socket.emit('unarchive-message', { messageId: msgId });
|
||
} else if (action === 'edit-role-menu') {
|
||
this._openRoleMenuBuilder?.({ messageId: msgId });
|
||
} else if (action === 'edit-tags') {
|
||
this._openMessageTagEditor(msgId, msgEl);
|
||
} else if (action === 'nsfw' || action === 'unnsfw') {
|
||
this._setMessageNsfw(msgId, action === 'nsfw');
|
||
} else if (action === 'select-messages') {
|
||
this._enterMoveSelectionMode();
|
||
this._toggleMoveSelect(msgEl);
|
||
this._lastMoveSelectedEl = msgEl;
|
||
} else if (action === 'highlight-on' || action === 'highlight-off') {
|
||
this.socket.emit('set-message-highlight', { messageId: msgId, on: action === 'highlight-on' });
|
||
} else if (action === 'delete') {
|
||
if (await this._showConfirmModal(t('confirm.delete_message'), '', { danger: true, confirmLabel: t('msg_toolbar.delete') })) {
|
||
this.socket.emit('delete-message', { messageId: msgId, attachments: this._getMessageAttachments?.(msgId) });
|
||
}
|
||
}
|
||
});
|
||
|
||
// Dismiss on outside click, another right-click, or scroll of the pane.
|
||
// This mirrors the image context menu's self-closing lifecycle.
|
||
const closer = (ev) => {
|
||
if (ev && ev.type !== 'scroll' && menu.contains(ev.target)) return;
|
||
this._hideMessageContextMenu();
|
||
};
|
||
this._msgCtxCloser = closer;
|
||
setTimeout(() => {
|
||
document.addEventListener('click', closer, true);
|
||
document.addEventListener('contextmenu', closer, true);
|
||
document.getElementById('messages')?.addEventListener('scroll', closer, true);
|
||
}, 0);
|
||
},
|
||
|
||
_hideMessageContextMenu() {
|
||
const existing = document.getElementById('message-context-menu');
|
||
if (existing) existing.remove();
|
||
if (this._msgCtxCloser) {
|
||
document.removeEventListener('click', this._msgCtxCloser, true);
|
||
document.removeEventListener('contextmenu', this._msgCtxCloser, true);
|
||
document.getElementById('messages')?.removeEventListener('scroll', this._msgCtxCloser, true);
|
||
this._msgCtxCloser = null;
|
||
}
|
||
},
|
||
|
||
// ── Retroactive tag editor (#tagging phase 3) ───────────────────────────────
|
||
// A small popup, opened from the message context menu, that edits the tag set
|
||
// on a message's attachment. Reuses the composer's tag primitives (server
|
||
// lookup, normalize, limits) and the shared .tag-* styles. Each change emits
|
||
// set-message-tags with the full set; the server replaces + broadcasts, and the
|
||
// message-tags-updated handler repaints every footer, including this one.
|
||
_openMessageTagEditor(msgId, msgEl, knownTags = null) {
|
||
this._closeMessageTagEditor();
|
||
if (!msgId) return;
|
||
// Seed the working set from the message's current footer chips, or from the
|
||
// list the caller already has (a forum card has no footer, #5682).
|
||
const current = Array.isArray(knownTags) ? [...knownTags] : Array.from(msgEl?.querySelectorAll('.message-tags .message-tag') || [])
|
||
.map(el => el.dataset.tag).filter(Boolean);
|
||
this._msgTagEditor = { msgId, tags: current };
|
||
|
||
const pop = document.createElement('div');
|
||
pop.id = 'message-tag-editor';
|
||
pop.className = 'tag-editor-popup';
|
||
pop.innerHTML = `
|
||
<div class="tag-editor-head">
|
||
<span class="tag-editor-title">${t('tags.edit')}</span>
|
||
<button type="button" class="tag-editor-close" aria-label="${t('media.remove')}">×</button>
|
||
</div>
|
||
<div class="tag-editor-chips" id="mte-chips"></div>
|
||
<input id="mte-input" class="tag-popup-input" type="text" autocomplete="off" spellcheck="false"
|
||
maxlength="${this._maxTagLen()}" placeholder="${this._escapeHtml(t('tags.search_placeholder'))}">
|
||
<div id="mte-list" class="tag-popup-list"></div>`;
|
||
document.body.appendChild(pop);
|
||
|
||
// Anchor near the message; positioned (and flipped above when there is no
|
||
// room below) once laid out, then re-clamped as async content changes height.
|
||
this._msgTagEditorAnchor = msgEl || null;
|
||
this._positionMessageTagEditor();
|
||
|
||
pop.querySelector('.tag-editor-close').addEventListener('click', () => this._closeMessageTagEditor());
|
||
const input = pop.querySelector('#mte-input');
|
||
input.addEventListener('input', () => {
|
||
clearTimeout(this._mteTimer);
|
||
const q = input.value;
|
||
this._mteTimer = setTimeout(() => this._msgTagEditorSearch(q), 250);
|
||
});
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') { e.preventDefault(); this._closeMessageTagEditor(); }
|
||
else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const first = pop.querySelector('#mte-list .tag-popup-item');
|
||
if (first) first.click();
|
||
}
|
||
});
|
||
// Outside-click closer (deferred so the opening click doesn't instantly close).
|
||
this._mteCloser = (ev) => { if (!pop.contains(ev.target)) this._closeMessageTagEditor(); };
|
||
setTimeout(() => document.addEventListener('click', this._mteCloser, true), 0);
|
||
|
||
this._msgTagEditorRenderChips();
|
||
this._msgTagEditorSearch('');
|
||
input.focus();
|
||
},
|
||
|
||
_closeMessageTagEditor() {
|
||
clearTimeout(this._mteTimer);
|
||
document.getElementById('message-tag-editor')?.remove();
|
||
if (this._mteCloser) { document.removeEventListener('click', this._mteCloser, true); this._mteCloser = null; }
|
||
this._msgTagEditor = null;
|
||
this._msgTagEditorAnchor = null;
|
||
},
|
||
|
||
// Place the editor below its anchor message, flipping above when the popup
|
||
// would overflow the viewport bottom (messages near the bottom of the list),
|
||
// and clamping horizontally. Re-run whenever the popup's height changes.
|
||
_positionMessageTagEditor() {
|
||
const pop = document.getElementById('message-tag-editor');
|
||
if (!pop) return;
|
||
const anchor = this._msgTagEditorAnchor;
|
||
const rect = (anchor || document.body).getBoundingClientRect();
|
||
const margin = 8;
|
||
const h = pop.offsetHeight;
|
||
const w = pop.offsetWidth;
|
||
let top = rect.bottom + 4;
|
||
if (top + h > window.innerHeight - margin) {
|
||
const above = rect.top - h - 4;
|
||
top = above >= margin ? above : Math.max(margin, window.innerHeight - h - margin);
|
||
}
|
||
let left = Math.min(rect.left + 8, window.innerWidth - w - 12);
|
||
left = Math.max(margin, left);
|
||
pop.style.top = top + 'px';
|
||
pop.style.left = left + 'px';
|
||
},
|
||
|
||
_msgTagEditorRenderChips() {
|
||
const wrap = document.getElementById('mte-chips');
|
||
if (!wrap || !this._msgTagEditor) return;
|
||
wrap.innerHTML = '';
|
||
this._msgTagEditor.tags.forEach(name => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'tag-chip';
|
||
const label = document.createElement('span');
|
||
label.className = 'tag-chip-label';
|
||
label.textContent = name;
|
||
const rm = document.createElement('button');
|
||
rm.type = 'button';
|
||
rm.className = 'tag-chip-remove';
|
||
rm.textContent = '×';
|
||
rm.addEventListener('click', () => this._msgTagEditorRemove(name));
|
||
chip.appendChild(label);
|
||
chip.appendChild(rm);
|
||
wrap.appendChild(chip);
|
||
});
|
||
},
|
||
|
||
_msgTagEditorSearch(query) {
|
||
const input = document.getElementById('mte-input');
|
||
if (!input || !this.socket || !this._msgTagEditor) return;
|
||
const q = query;
|
||
this.socket.emit('search-upload-tags', { query: q, code: this.currentChannel }, (res) => {
|
||
if (!this._msgTagEditor || input.value !== q) return;
|
||
if (res && res.error === 'rate_limited') return;
|
||
this._msgTagEditorRenderList(q, (res && res.tags) || []);
|
||
});
|
||
},
|
||
|
||
_msgTagEditorRenderList(query, results) {
|
||
const list = document.getElementById('mte-list');
|
||
if (!list || !this._msgTagEditor) return;
|
||
list.innerHTML = '';
|
||
const applied = new Set(this._msgTagEditor.tags.map(x => x.toLocaleLowerCase()));
|
||
const norm = this._normalizeTag(query);
|
||
(results || []).filter(tg => !applied.has(String(tg.name).toLocaleLowerCase())).forEach(tg => {
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.className = 'tag-popup-item';
|
||
item.textContent = tg.name;
|
||
item.addEventListener('click', () => this._msgTagEditorAdd(tg.name));
|
||
list.appendChild(item);
|
||
});
|
||
const exact = norm && (applied.has(norm.norm) || (results || []).some(tg => String(tg.name).toLocaleLowerCase() === norm.norm));
|
||
if (norm && !exact && this._hasPerm && this._hasPerm('manage_tags')) {
|
||
const create = document.createElement('button');
|
||
create.type = 'button';
|
||
create.className = 'tag-popup-item tag-popup-create';
|
||
create.textContent = t('tags.add_new', { name: norm.name });
|
||
create.addEventListener('click', () => this._msgTagEditorAdd(norm.name));
|
||
list.appendChild(create);
|
||
}
|
||
if (!list.children.length) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'tag-popup-empty';
|
||
empty.textContent = norm ? t('tags.none_found') : t('tags.none_yet');
|
||
list.appendChild(empty);
|
||
}
|
||
// The list height just changed; re-anchor so a bottom message stays flipped.
|
||
this._positionMessageTagEditor();
|
||
},
|
||
|
||
_msgTagEditorAdd(rawName) {
|
||
if (!this._msgTagEditor) return;
|
||
const norm = this._normalizeTag(rawName);
|
||
if (!norm) return this._showToast(t('tags.invalid'), 'error');
|
||
const tags = this._msgTagEditor.tags;
|
||
if (tags.some(x => x.toLocaleLowerCase() === norm.norm)) return;
|
||
if (tags.length >= this._maxTagsPerAttachment()) {
|
||
return this._showToast(t('tags.limit_reached', { n: this._maxTagsPerAttachment() }), 'error');
|
||
}
|
||
tags.push(norm.name);
|
||
const input = document.getElementById('mte-input');
|
||
if (input) input.value = '';
|
||
this._msgTagEditorRenderChips();
|
||
this._msgTagEditorSearch('');
|
||
this._msgTagEditorSave();
|
||
},
|
||
|
||
_msgTagEditorRemove(name) {
|
||
if (!this._msgTagEditor) return;
|
||
this._msgTagEditor.tags = this._msgTagEditor.tags.filter(x => x !== name);
|
||
this._msgTagEditorRenderChips();
|
||
this._msgTagEditorSearch(document.getElementById('mte-input')?.value || '');
|
||
this._msgTagEditorSave();
|
||
},
|
||
|
||
// Push the full working set to the server. It replaces the message's tags and
|
||
// broadcasts message-tags-updated, which repaints every footer for this id.
|
||
_msgTagEditorSave() {
|
||
if (!this._msgTagEditor || !this.socket) return;
|
||
this.socket.emit('set-message-tags', { messageId: this._msgTagEditor.msgId, tags: this._msgTagEditor.tags });
|
||
},
|
||
|
||
// Repaint the Tags footer for every rendered copy of a message (main list,
|
||
// search results, thread, PiP) after a live tag change. (#tagging phase 3)
|
||
_updateMessageTagsFooter(msgId, tags) {
|
||
const list = Array.isArray(tags) ? tags : [];
|
||
const topic = this._forumTopics && this._forumTopics.get(msgId);
|
||
if (topic) {
|
||
topic.attachmentTags = list.length ? list : undefined;
|
||
if (this._activeThreadParent === msgId) this._forumThreadRenderTopic?.();
|
||
}
|
||
document.querySelectorAll(`[data-msg-id="${msgId}"]`).forEach(el => {
|
||
if (el.classList.contains('forum-topic')) return;
|
||
const existing = el.querySelector('.message-tags');
|
||
if (existing) existing.remove();
|
||
if (!list.length) return;
|
||
const html = this._renderAttachmentTags(list);
|
||
if (!html) return;
|
||
const tmp = document.createElement('template');
|
||
tmp.innerHTML = html.trim();
|
||
const node = tmp.content.firstChild;
|
||
const anchor = el.querySelector('.reactions-row')
|
||
|| el.querySelector('.message-content, .search-result-content, .thread-msg-content');
|
||
if (anchor && anchor.parentNode) anchor.insertAdjacentElement('afterend', node);
|
||
else (el.querySelector('.message-body') || el).appendChild(node);
|
||
});
|
||
// Keep the render cache in sync so a scroll/re-render doesn't drop the change.
|
||
const cached = (this._lastRenderedMessages || []).find(m => m && m.id === msgId);
|
||
if (cached) cached.attachmentTags = list.length ? list : undefined;
|
||
},
|
||
|
||
};
|