From 61ed2314420082a6c90092349f0bf7681a327e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Daubensch=C3=BCtz?= Date: Fri, 31 Jan 2025 13:32:51 +0100 Subject: [PATCH] Emoji reactions v1 --- src/cache.mjs | 78 +++++- src/http.mjs | 1 + src/launch.mjs | 1 + src/store.mjs | 27 +- src/web/src/CommentSection.jsx | 483 ++++++++++++++++++++++----------- 5 files changed, 428 insertions(+), 162 deletions(-) diff --git a/src/cache.mjs b/src/cache.mjs index 01cdf663..7916a936 100644 --- a/src/cache.mjs +++ b/src/cache.mjs @@ -157,6 +157,42 @@ export function initializeNotifications() { `); } +export function initializeReactions() { + const tableExists = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='reactions'", + ) + .get(); + + if (tableExists) { + log( + "Aborting cache.initializeReactions early because table already exists", + ); + return; + } + + log("Creating reactions table"); + db.exec(` + CREATE TABLE reactions ( + id TEXT PRIMARY KEY NOT NULL, + comment_id TEXT NOT NULL, + timestamp INTEGER NOT NULL, + title TEXT NOT NULL, + signer TEXT NOT NULL, + identity TEXT NOT NULL, + FOREIGN KEY(comment_id) REFERENCES comments(id) + ); + CREATE INDEX IF NOT EXISTS idx_reactions_comment_id ON reactions(comment_id); + CREATE INDEX IF NOT EXISTS idx_reactions_timestamp ON reactions(timestamp); + CREATE INDEX IF NOT EXISTS idx_reactions_identity ON reactions(identity); + `); +} + +export function isReactionComment(title) { + const trimmedTitle = title.trim(); + return /^\p{Emoji}+$/u.test(trimmedTitle); +} + export async function getRecommendations(candidates, fingerprint, identity) { if (identity) { const commentedStories = db @@ -704,15 +740,33 @@ export function getSubmission(index, href, identityFilter, hrefs) { .all(submission.id) .map((comment) => { const [, index] = comment.id.split("0x"); + const originalCommentId = comment.id; delete comment.id; - const submissionId = comment.submission_id; delete comment.submission_id; + + const reactions = db + .prepare( + ` + SELECT title as emoji, COUNT(*) as count, GROUP_CONCAT(identity) as reactors + FROM reactions + WHERE comment_id = ? + GROUP BY title + ORDER BY count DESC, timestamp ASC + `, + ) + .all(originalCommentId); + return { ...comment, submissionId, index, type: "comment", + reactions: reactions.map((reaction) => ({ + emoji: reaction.emoji, + count: reaction.count, + reactors: reaction.reactors.split(","), + })), }; }); @@ -815,6 +869,28 @@ export function insertMessage(message) { ); } } else if (type === "comment") { + if (isReactionComment(title)) { + // Insert reaction + const insertReaction = db.prepare( + `INSERT INTO reactions (id, comment_id, timestamp, title, signer, identity) VALUES (?, ?, ?, ?, ?, ?)`, + ); + try { + insertReaction.run( + `kiwi:0x${index}`, + href, + timestamp, + title.trim(), + signer, + identity, + ); + } catch (err) { + log( + `Failing to insert reaction "${title}", error: "${err.toString()}"`, + ); + } + return; + } + // Insert comment const insertComment = db.prepare( `INSERT INTO comments (id, submission_id, timestamp, title, signer, identity) VALUES (?, ?, ?, ?, ?, ?)`, diff --git a/src/http.mjs b/src/http.mjs index 103c0fa2..c3c42d92 100644 --- a/src/http.mjs +++ b/src/http.mjs @@ -658,6 +658,7 @@ export async function launch(trie, libp2p) { try { submission = getSubmission(index); } catch (e) { + log(`/api/v1/stories: Error in getSubmission: ${err.stack}`); const code = 404; const httpMessage = "Not Found"; const details = "Couldn't find the submission"; diff --git a/src/launch.mjs b/src/launch.mjs index 534f3a15..c56da0b4 100644 --- a/src/launch.mjs +++ b/src/launch.mjs @@ -114,6 +114,7 @@ await Promise.allSettled([ cache.initialize([...upvotes, ...comments]); cache.initializeNotifications(); +cache.initializeReactions(); store .cache(upvotes, comments) diff --git a/src/store.mjs b/src/store.mjs index 8fbed46d..f3f70981 100644 --- a/src/store.mjs +++ b/src/store.mjs @@ -27,7 +27,7 @@ import { EIP712_MESSAGE } from "./constants.mjs"; import { elog } from "./utils.mjs"; import * as messages from "./topics/messages.mjs"; import { newWalk } from "./WalkController.mjs"; -import { insertMessage } from "./cache.mjs"; +import { insertMessage, isReactionComment } from "./cache.mjs"; import { triggerNotification } from "./subscriptions.mjs"; const maxReaders = 500; @@ -41,6 +41,7 @@ const piscina = new Piscina({ }); export const upvotes = new Set(); +export const reactions = new Set(); export const commentCounts = new Map(); // TODO: This function is badly named, it should be renamed to @@ -60,14 +61,27 @@ export function passes(marker) { } return !exists; } + +export function passesReaction(marker) { + const exists = reactions.has(marker); + if (!exists) { + reactions.add(marker); + } + return !exists; +} + export async function cache(upvotes, comments) { log("Caching upvote ids of upvotes, this can take a minute..."); for (const { identity, href, type } of upvotes) { const marker = upvoteID(identity, href, type); passes(marker); } - for (const { href } of comments) { + for (const { href, title, identity } of comments) { addComment(href); + if (isReactionComment(title)) { + const reactionMarker = upvoteID(identity, href, "reaction"); + passesReaction(reactionMarker); + } } } @@ -312,6 +326,15 @@ async function atomicPut(trie, message, identity, accounts, delegations) { log(err); throw new Error(err); } + if (isReactionComment(message.title)) { + const reactionMarker = upvoteID(identity, message.href, "reaction"); + const legit = await passesReaction(reactionMarker); + if (!legit) { + const err = `Reaction with marker "${reactionMarker}" doesn't pass legitimacy criteria (duplicate). It was probably submitted and accepted before.`; + log(err); + throw new Error(err); + } + } } try { diff --git a/src/web/src/CommentSection.jsx b/src/web/src/CommentSection.jsx index 0cbd4a67..57bf0bb9 100644 --- a/src/web/src/CommentSection.jsx +++ b/src/web/src/CommentSection.jsx @@ -57,6 +57,158 @@ function truncateName(name) { return name.slice(0, maxLength) + "..."; } +const EmojiReaction = ({ comment, allowlist, delegations, toast }) => { + const [isReacting, setIsReacting] = useState(false); + const [kiwis, setKiwis] = useState( + comment.reactions?.find((r) => r.emoji === "🥝")?.reactors || [], + ); + const [fires, setFires] = useState( + comment.reactions?.find((r) => r.emoji === "🔥")?.reactors || [], + ); + const [eyes, setEyes] = useState( + comment.reactions?.find((r) => r.emoji === "👀")?.reactors || [], + ); + const [hundreds, setHundreds] = useState( + comment.reactions?.find((r) => r.emoji === "💯")?.reactors || [], + ); + const [laughs, setLaughs] = useState( + comment.reactions?.find((r) => r.emoji === "🤭")?.reactors || [], + ); + + const account = useAccount(); + const localAccount = getLocalAccount(account.address, allowlist); + const provider = useProvider(); + + const commonEmojis = ["🥝", "🔥", "👀", "💯", "🤭"]; + const address = localAccount?.identity; + const hasReacted = + address && + (kiwis.includes(address) || + fires.includes(address) || + eyes.includes(address) || + hundreds.includes(address) || + laughs.includes(address)); + const isntLoggedIn = !localAccount?.identity; + + let signer; + if (localAccount?.privateKey) { + signer = new Wallet(localAccount.privateKey, provider); + } + + const handleReaction = async (emoji) => { + if (!signer) { + toast.error("Please connect your wallet first"); + return; + } + + setIsReacting(true); + try { + const address = await signer.getAddress(); + const identity = eligible(allowlist, delegations, address); + + if (!identity) { + window.location.pathname = "/gateway"; + return; + } + + const value = API.messageFab(emoji, `kiwi:0x${comment.index}`, "comment"); + + const signature = await signer._signTypedData( + API.EIP712_DOMAIN, + API.EIP712_TYPES, + value, + ); + + const response = await API.send(value, signature); + + if (response.status === "success") { + switch (emoji) { + case "🥝": + setKiwis([...kiwis, identity]); + break; + case "🔥": + setFires([...fires, identity]); + break; + case "👀": + setEyes([...eyes, identity]); + break; + case "💯": + setHundreds([...hundreds, identity]); + break; + case "🤭": + setLaughs([...laughs, identity]); + break; + } + toast.success("Reaction added!"); + } else { + toast.error(response.details || "Failed to add reaction"); + } + } catch (err) { + console.error("Reaction error:", err); + toast.error("Failed to add reaction"); + } finally { + setIsReacting(false); + } + }; + + return ( +
+ {commonEmojis.map((emoji) => { + const counts = { + "🥝": kiwis.length, + "🔥": fires.length, + "👀": eyes.length, + "💯": hundreds.length, + "🤭": laughs.length, + }; + const disabled = isReacting || hasReacted; + + return ( + + ); + })} +
+ ); +}; + function NotificationOptIn(props) { const account = useAccount(); const localAccount = getLocalAccount(account.address, props.allowlist); @@ -203,172 +355,184 @@ const EmailNotificationLink = (props) => { ); }; -const Comment = React.forwardRef(({ comment, storyIndex }, ref) => { - const [isCollapsed, setIsCollapsed] = useState(false); - const toggleCollapsed = (e) => { - if (e.target.closest("a")) { - // The user clicked a link (or anything inside that link) - return; - } - setIsCollapsed((v) => !v); - }; - - const [isTargeted, setIsTargeted] = useState( - window.location.hash === `#0x${comment.index}`, - ); +const Comment = React.forwardRef( + ({ comment, storyIndex, allowlist, delegations, toast }, ref) => { + const [isCollapsed, setIsCollapsed] = useState(false); + const toggleCollapsed = (e) => { + if (e.target.closest("a")) { + // The user clicked a link (or anything inside that link) + return; + } + setIsCollapsed((v) => !v); + }; - const url = `${window.location.origin}/stories?index=${storyIndex}#0x${comment.index}`; - const handleShare = async (e) => { - e.preventDefault(); - try { - await navigator.share({ url }); - } catch (err) { - if (err.name !== "AbortError") console.error(err); - } - }; + const [isTargeted, setIsTargeted] = useState( + window.location.hash === `#0x${comment.index}`, + ); - useEffect(() => { - const handleHashChange = () => { - setIsTargeted(window.location.hash === `#0x${comment.index}`); + const url = `${window.location.origin}/stories?index=${storyIndex}#0x${comment.index}`; + const handleShare = async (e) => { + e.preventDefault(); + try { + await navigator.share({ url }); + } catch (err) { + if (err.name !== "AbortError") console.error(err); + } }; - window.addEventListener("hashchange", handleHashChange); - return () => window.removeEventListener("hashchange", handleHashChange); - }, [comment.index]); + useEffect(() => { + const handleHashChange = () => { + setIsTargeted(window.location.hash === `#0x${comment.index}`); + }; - return ( - -
window.removeEventListener("hashchange", handleHashChange); + }, [comment.index]); + + return ( + - - {comment.identity.safeAvatar && ( - avatar - )} - - {truncateName(comment.identity.displayName)} - - - - {" "} - •{" "} - - - {ShareIcon({ - padding: "0 3px 1px 0", - verticalAlign: "-3px", - height: "13px", - width: "13px", - })} - - {formatDistanceToNowStrict(new Date(comment.timestamp * 1000))} + {comment.identity.safeAvatar && ( + avatar + )} + + {truncateName(comment.identity.displayName)} - ago - -
-
- {!isCollapsed && ( - - { - if (href.startsWith("https://news.kiwistand.com")) - return "_self"; - return isIOS() ? "_self" : "_blank"; - }, - defaultProtocol: "https", - validate: { - url: (value) => /^https:\/\/.*/.test(value), - email: () => false, - }, - }} - > - {comment.title.split("\n").map((line, i) => { - if (line.startsWith("> ")) { - return ( -
- {line.substring(2)} -
- ); - } - // Only wrap in div if it's not an empty line - return line.trim() ? ( -
{line}
- ) : ( - // Empty lines create spacing between paragraphs -
- ); - })} -
-
- )} -
- ); -}); + + {" "} + •{" "} + + + + {ShareIcon({ + padding: "0 3px 1px 0", + verticalAlign: "-3px", + height: "13px", + width: "13px", + })} + + {formatDistanceToNowStrict(new Date(comment.timestamp * 1000))} + + ago + + + +
+ {!isCollapsed && ( + <> + + { + if (href.startsWith("https://news.kiwistand.com")) + return "_self"; + return isIOS() ? "_self" : "_blank"; + }, + defaultProtocol: "https", + validate: { + url: (value) => /^https:\/\/.*/.test(value), + email: () => false, + }, + }} + > + {comment.title.split("\n").map((line, i) => { + if (line.startsWith("> ")) { + return ( +
+ {line.substring(2)} +
+ ); + } + // Only wrap in div if it's not an empty line + return line.trim() ? ( +
{line}
+ ) : ( + // Empty lines create spacing between paragraphs +
+ ); + })} +
+
+ + + )} + + ); + }, +); const CommentsSection = (props) => { const { storyIndex, commentCount } = props; @@ -437,18 +601,19 @@ const CommentsSection = (props) => { + {comments.length > 0 && + comments.map((comment, index) => ( + + ))} + - {comments.length > 0 && - comments.map((comment, index) => ( - - ))} - ); };