feat(chan): represent replies/backlinks and fix styling for reply cards with media

This commit is contained in:
2026-09-26 15:42:45 +02:00
parent 27f5e3563f
commit 62f0fbde63
2 changed files with 520 additions and 151 deletions
+47 -5
View File
@@ -48,13 +48,37 @@ export default (router, tpl) => {
function formatComment(com) {
if (!com) return '';
// Format quote lines
let formatted = com.replace(/(?:^|<br\s*\/?>)((?:&gt;|>)((?!&gt;|>)[^\n<]+))/g, '$1<span class="chan-quote">&gt;$2</span>');
// Format post cross-references >>123456
formatted = formatted.replace(/(?:&gt;|>)&gt;(\d+)/g, '<a href="#p$1" class="chan-ref" data-post="$1">&gt;&gt;$1</a>');
let formatted = com;
// 1. Normalize existing 4chan intra-thread quotelinks to standard format
formatted = formatted.replace(/<a\s+[^>]*href="#p(\d+)"[^>]*>.*?<\/a>/gi, '___CHANREF_$1___');
// 2. Normalize cross-thread/cross-board quotelinks if any
formatted = formatted.replace(/<a\s+[^>]*href="(\/[^"]+)"[^>]*>(.*?)<\/a>/gi, '<a href="$1" class="chan-ref chan-cross-ref" target="_blank">$2</a>');
// 3. Format any unlinked quotes &gt;&gt;123456
formatted = formatted.replace(/(?<!&gt;)&gt;&gt;(\d+)\b/g, '___CHANREF_$1___');
// 4. Also support plain >>123456 if plain text was supplied
formatted = formatted.replace(/(?<![>\w])>>(\d+)\b/g, '___CHANREF_$1___');
// 5. Restore CHANREF placeholders
formatted = formatted.replace(/___CHANREF_(\d+)___/g, '<a href="#p$1" class="chan-ref quotelink" data-post="$1">&gt;&gt;$1</a>');
// 6. Format quote lines (greentext)
formatted = formatted.replace(/(^|<br\s*\/?>)&gt;(?!&gt;)([^\n<]+)/g, '$1<span class="chan-quote">&gt;$2</span>');
return formatted;
}
function extractQuotes(com) {
if (!com) return [];
const quotes = new Set();
for (const m of com.matchAll(/href="#p(\d+)"/g)) {
quotes.add(Number(m[1]));
}
for (const m of com.matchAll(/(?<!&gt;)&gt;&gt;(\d+)\b/g)) {
quotes.add(Number(m[1]));
}
for (const m of com.matchAll(/(?<![>\w])>>(\d+)\b/g)) {
quotes.add(Number(m[1]));
}
return Array.from(quotes);
}
// ───────────────────────────────────────────────────────────────────────────
// 0. GET /4 -> redirect to default board catalogue
// ───────────────────────────────────────────────────────────────────────────
@@ -145,6 +169,21 @@ export default (router, tpl) => {
});
function formatPosts(posts, board, rehostMap, userFavSet, targetPostNo = null, lang = 'en') {
// Build reply mapping (targetPostNo -> array of replier post numbers)
const postNoSet = new Set(posts.map(p => p.no));
const replyMap = new Map();
for (const p of posts) {
if (!p.com) continue;
const quotes = extractQuotes(p.com);
for (const qNo of quotes) {
if (postNoSet.has(qNo) && qNo !== p.no) {
if (!replyMap.has(qNo)) replyMap.set(qNo, []);
replyMap.get(qNo).push(p.no);
}
}
}
return posts.map(p => {
const ext = (p.ext || '').toLowerCase();
const isVideo = ['.webm', '.mp4'].includes(ext);
@@ -154,6 +193,7 @@ export default (router, tpl) => {
const localId = rehostInfo ? rehostInfo.id : null;
const effectiveTime = (localId && rehostInfo?.stamp) ? Number(rehostInfo.stamp) : p.time;
const isoStr = new Date(effectiveTime * 1000).toISOString();
const postReplies = replyMap.get(p.no) || [];
return {
no: p.no,
@@ -181,7 +221,9 @@ export default (router, tpl) => {
local_id: localId,
rehosted: !!localId,
user_has_favorited: localId ? userFavSet.has(Number(localId)) : false,
is_onara_active: targetPostNo ? p.no === targetPostNo : false
is_onara_active: targetPostNo ? p.no === targetPostNo : false,
replies: postReplies,
replies_count: postReplies.length
};
});
}