From 2e40a273c4caa5bc14c328c8e40840a82458d724 Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 14 Jan 2023 12:57:44 +0100 Subject: [PATCH] nip-10: fix duplicate replies in feed some replies rendered twice in different positions, seems to be related to deprecated positional event tags and a regression introduced in a5961218218ab956b13a2437c6f16c658a88b610 on receiving events it analizes the event tags and stores the id of the replied event so the client can easily search for replies later. marked tags are prefered with a fallback to positional tags as described in nip-10. mentions are ignored at the moment. example event that had some replies rendered twice: 22e4ea80161ac591059da611d3ab63c583cb1d47a706826db2fc6955ac0a70b5 --- src/main.js | 71 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/src/main.js b/src/main.js index ed49b87..32c1a59 100644 --- a/src/main.js +++ b/src/main.js @@ -257,7 +257,10 @@ document.body.addEventListener('click', (e) => { const textNoteList = []; // could use indexDB const eventRelayMap = {}; // eventId: [relay1, relay2] + const hasEventTag = tag => tag[0] === 'e'; +const isReply = ([tag, , , marker]) => tag === 'e' && marker !== 'mention'; +const isMention = ([tag, , , marker]) => tag === 'e' && marker === 'mention'; function handleTextNote(evt, relay) { if (eventRelayMap[evt.id]) { @@ -273,7 +276,27 @@ function handleTextNote(evt, relay) { } } -const replyList = []; // could use textNoteList with isReply field +const replyList = []; +const replyDomMap = {}; +const replyToMap = {}; + +function handleReply(evt, relay) { + if ( + replyDomMap[evt.id] // already rendered probably received from another relay + || evt.tags.some(isMention) // ignore mentions for now + ) { + return; + } + if (!replyToMap[evt.id]) { + replyToMap[evt.id] = getReplyTo(evt); + } + replyList.push({ + replyTo: replyToMap[evt.id], + ...evt, + }); + renderReply(evt, relay); +} + const reactionMap = {}; const getReactionList = (id) => { @@ -313,7 +336,6 @@ function handleReaction(evt, relay) { // feed const feedContainer = document.querySelector('#homefeed'); const feedDomMap = {}; -const replyDomMap = {}; const restoredReplyTo = localStorage.getItem('reply_to'); const sortByCreatedAt = (evt1, evt2) => { @@ -434,7 +456,8 @@ function linkPreview(href, id, relay) { } function createTextNote(evt, relay) { - const {host, img, isReply, name, replies, time, userName} = getMetadata(evt, relay); + const {host, img, name, time, userName} = getMetadata(evt, relay); + const replies = replyList.filter(({replyTo}) => replyTo === evt.id); // const isLongContent = evt.content.trimRight().length > 280; // const content = isLongContent ? evt.content.slice(0, 280) : evt.content; const hasReactions = reactionMap[evt.id]?.length > 0; @@ -446,7 +469,6 @@ function createTextNote(evt, relay) { className: 'mbox-header', title: `User: ${userName}\n${time}\n\nUser pubkey: ${evt.pubkey}\n\nRelay: ${host}\n\nEvent-id: ${evt.id} ${evt.tags.length ? `\nTags ${JSON.stringify(evt.tags)}\n` : ''} - ${isReply ? `\nReply to ${evt.tags[0][1]}\n` : ''} ${evt.content}` }, [ elem('small', {}, [ @@ -473,7 +495,6 @@ function createTextNote(evt, relay) { elem('small', {data: {reactions: ''}}, hasReactions ? reactionMap[evt.id].length : ''), ]), ]), - // replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', ]); if (restoredReplyTo === evt.id) { appendReplyForm(body.querySelector('.buttons')); @@ -481,22 +502,13 @@ function createTextNote(evt, relay) { } return renderArticle([ elem('div', {className: 'mbox-img'}, [img]), body, - replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', + replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.sort(sortByCreatedAt).reverse()) : '', ], {data: {id: evt.id, pubkey: evt.pubkey, relay}}); } -function handleReply(evt, relay) { - if (replyDomMap[evt.id]) { - console.log('CALL ME already have reply in replyDomMap', evt, relay); - return; - } - replyList.push(evt); - renderReply(evt, relay); -} - function renderReply(evt, relay) { - const eventId = evt.tags.filter(hasEventTag)[0][1]; // TODO: should check for 'reply' marker with fallback to 'root' marker or last 'e' tag, see nip-10 - const article = feedDomMap[eventId] || replyDomMap[eventId]; + const replyToId = replyToMap[evt.id]; + const article = feedDomMap[replyToId] || replyDomMap[replyToId]; if (!article) { // root article has not been rendered return; } @@ -707,10 +719,29 @@ function getMetadata(evt, relay) { src: userImg, title: `${userName} on ${host} ${userAbout}`, }) : elemCanvas(evt.pubkey); - const isReply = evt.tags.some(hasEventTag); - const replies = replyList.filter(({tags}) => tags.filter(hasEventTag).some(reply => reply[1] === evt.id)); // TODO: nip-10 + const isReply = !!replyToMap[evt.id]; const time = new Date(evt.created_at * 1000); - return {host, img, isReply, name, replies, time, userName}; + return {host, img, isReply, name, time, userName}; +} + +/** + * find reply-to ID according to nip-10, find marked reply or root tag or + * fallback to positional (last) e tag or return null + * @param {event} evt + * @returns replyToID | null + */ +function getReplyTo(evt) { + const eventTags = evt.tags.filter(isReply); + const withReplyMarker = eventTags.filter(([, , , marker]) => marker === 'reply'); + if (withReplyMarker.length === 1) { + return withReplyMarker[0][1]; + } + const withRootMarker = eventTags.filter(([, , , marker]) => marker === 'root'); + if (withReplyMarker.length === 0 && withRootMarker.length === 1) { + return withRootMarker[0][1]; + } + // fallback to deprecated positional 'e' tags (nip-10) + return eventTags.length ? eventTags.at(-1)[1] : null; } const writeForm = document.querySelector('#writeForm');