diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css
index 6dfbb05..d033d33 100644
--- a/public/s/css/f0ckm.css
+++ b/public/s/css/f0ckm.css
@@ -7667,7 +7667,7 @@ video.autoplay-gif {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease-in-out, background 0.2s;
- box-shadow: 0 2px 4px rgba(0,0,0,0.5);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.image-embed-wrap:hover .admin-delete-attachment-btn,
@@ -11006,7 +11006,7 @@ body.layout-modern .tag-controls {
}
.dm-msg-mine .dm-bubble {
- background: var(--badge-bg);
+ background: #090909;
color: var(--white);
border-bottom-right-radius: 4px;
}
@@ -11064,7 +11064,7 @@ body.layout-modern .tag-controls {
.dm-msg-action-btn:hover {
color: var(--fg, #ddd);
- background: rgba(255,255,255,0.07);
+ background: rgba(255, 255, 255, 0.07);
}
.dm-msg-action-btn[data-action="delete"]:hover {
@@ -11079,8 +11079,8 @@ body.layout-modern .tag-controls {
.dm-edit-textarea {
width: 100%;
min-height: 60px;
- background: rgba(255,255,255,0.05);
- border: 1px solid rgba(255,255,255,0.15);
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: var(--fg, #ddd);
font: inherit;
@@ -11111,7 +11111,7 @@ body.layout-modern .tag-controls {
}
.dm-edit-cancel {
- background: rgba(255,255,255,0.08);
+ background: rgba(255, 255, 255, 0.08);
color: var(--fg, #ddd);
}
@@ -11124,7 +11124,14 @@ body.layout-modern .tag-controls {
/* Delete fade-out */
@keyframes dm-msg-fade-out {
- to { opacity: 0; transform: scaleY(0); max-height: 0; margin: 0; padding: 0; overflow: hidden; }
+ to {
+ opacity: 0;
+ transform: scaleY(0);
+ max-height: 0;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ }
}
.dm-msg-deleting {
@@ -11133,6 +11140,64 @@ body.layout-modern .tag-controls {
overflow: hidden;
}
+/* ── Reply banner ────────────────────────────────────────── */
+.dm-reply-banner {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ background: rgba(255, 255, 255, 0.05);
+ border-left: 3px solid var(--badge-bg, #4a90d9);
+ border-radius: 4px;
+ padding: 5px 8px;
+ font-size: 0.8em;
+ color: #aaa;
+ margin-bottom: 5px;
+ animation: dm-reply-in 0.12s ease;
+}
+
+@keyframes dm-reply-in {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.dm-reply-banner__icon {
+ color: var(--badge-bg, #4a90d9);
+ flex-shrink: 0;
+}
+
+.dm-reply-banner__body {
+ flex: 1;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.dm-reply-banner__body strong {
+ color: var(--fg, #ddd);
+}
+
+.dm-reply-banner__close {
+ background: none;
+ border: none;
+ color: #555;
+ cursor: pointer;
+ padding: 2px 4px;
+ font-size: 1em;
+ flex-shrink: 0;
+ transition: color 0.12s;
+}
+
+.dm-reply-banner__close:hover {
+ color: var(--fg, #ddd);
+}
+
/* ── Send form ───────────────────────────────────────────── */
.dm-send-form {
gap: 8px;
diff --git a/public/s/js/messages.js b/public/s/js/messages.js
index 98b5916..68ee6ff 100644
--- a/public/s/js/messages.js
+++ b/public/s/js/messages.js
@@ -305,6 +305,7 @@ if (window.__dmLoaded) {
const renderedIds = new Set();
let threadMessages = []; // Cache for re-rendering (e.g. emojis)
const dmPostPreviewCache = new Map(); // itemId → { item, meta } | null
+ let activeReply = null; // { senderName, preview } | null
// Title management — global across all pages
let _dmTitleCount = 0;
@@ -718,21 +719,24 @@ if (window.__dmLoaded) {
`
` +
`${escHtml(time)}${editedBit}`;
- if (isMine) {
- const actions = document.createElement('div');
- actions.className = 'dm-msg-actions';
- actions.innerHTML =
- `` +
- ``;
- actions.addEventListener('click', async (e) => {
- const btn = e.target.closest('[data-action]');
- if (!btn) return;
- const action = btn.dataset.action;
- if (action === 'edit') editDmMessage(m, div);
- if (action === 'delete') deleteDmMessage(m, div);
- });
- div.appendChild(actions);
- }
+ // Actions row — always shown (reply for all, edit/delete for own)
+ const actions = document.createElement('div');
+ actions.className = 'dm-msg-actions';
+
+ const replyBtn = ``;
+ const editBtn = ``;
+ const delBtn = ``;
+
+ actions.innerHTML = replyBtn + (isMine ? editBtn + delBtn : '');
+ actions.addEventListener('click', async (e) => {
+ const btn = e.target.closest('[data-action]');
+ if (!btn) return;
+ const action = btn.dataset.action;
+ if (action === 'reply') replyToDmMessage(m, div);
+ if (action === 'edit') editDmMessage(m, div);
+ if (action === 'delete') deleteDmMessage(m, div);
+ });
+ div.appendChild(actions);
// Async: extract post IDs from raw plaintext and inject preview cards into the bubble
if (m.plaintext) resolvePostPreviews(div, m.plaintext);
@@ -743,6 +747,62 @@ if (window.__dmLoaded) {
return div;
}
+ function replyToDmMessage(m, div) {
+ const thread = document.getElementById('dm-thread');
+ const otherName = thread?.dataset.otherName || 'them';
+ const myName = window.f0ckSession?.user || 'me';
+ const senderName = (m.sender_id === myId) ? myName : otherName;
+
+ const raw = m.plaintext || '';
+
+ // Strip attachment sentinels from the text part, collect their filenames
+ const attachments = parseAttachmentSentinels(raw);
+ const textOnly = raw.replace(/\[attachment:\d+:[A-Za-z0-9+/=]+:[^:]+:\d+\]/g, '').trim();
+ const textPreview = textOnly.replace(/\n/g, ' ').slice(0, 80) + (textOnly.length > 80 ? '…' : '');
+
+ // One preview string for the banner; quoteLines drives the actual sent quote
+ const preview = [textPreview, ...attachments.map(a => a.filename)].filter(Boolean).join(' · ');
+
+ // Build the multi-line blockquote: first line has the sender + text, then one line per attachment
+ const quoteLines = [];
+ quoteLines.push(`> @${senderName}: ${textPreview}`);
+ for (const att of attachments) quoteLines.push(`> ${att.filename}`);
+
+ activeReply = { senderName, preview, quoteLines };
+ showReplyBanner();
+
+ const input = document.getElementById('dm-input');
+ if (input) { input.focus(); }
+ }
+
+ function showReplyBanner() {
+ const form = document.getElementById('dm-send-form');
+ if (!form) return;
+
+ let banner = form.querySelector('.dm-reply-banner');
+ if (!banner) {
+ banner = document.createElement('div');
+ banner.className = 'dm-reply-banner';
+ form.insertBefore(banner, form.firstChild);
+ }
+
+ const name = escHtml(activeReply?.senderName || '');
+ const preview = escHtml(activeReply?.preview || '');
+ banner.innerHTML =
+ `` +
+ `${name} ${preview}` +
+ ``;
+
+ banner.querySelector('.dm-reply-banner__close').addEventListener('click', clearReply);
+ }
+
+ function clearReply() {
+ activeReply = null;
+ const banner = document.querySelector('.dm-reply-banner');
+ if (banner) banner.remove();
+ }
+
+
async function editDmMessage(m, div) {
if (div.dataset.editing === 'true') return;
if (!m.plaintext) { showFlashMsg('Cannot edit — message could not be decrypted', 'error'); return; }
@@ -1670,8 +1730,13 @@ if (window.__dmLoaded) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (sendInFlight) return; // prevent double-submit
- const text = input.value.trim();
- if (!text) return;
+ const rawText = input.value.trim();
+ if (!rawText) return;
+
+ // Prepend reply quote if active
+ const text = activeReply
+ ? `${activeReply.quoteLines.join('\n')}\n\n${rawText}`
+ : rawText;
// If recipient has no key, block sending — we cannot encrypt for them
if (!currentOtherPubKey) {
@@ -1692,6 +1757,7 @@ if (window.__dmLoaded) {
if (res.success) {
input.value = '';
input.style.height = '';
+ clearReply();
// Optimistic render with the real server ID so dedup works
const thread = document.getElementById('dm-thread');