From 8a15ad170473a4ed9d078ba07c389a908d6bcbe3 Mon Sep 17 00:00:00 2001 From: FrankBStack <294698533+FrankBStack@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:07:26 -0400 Subject: [PATCH 1/2] Sync vote state between feed and comments Each Post/Comment kept its own copy of vote_status/vote_total, so voting on the comments screen left the feed card stale and tapping it again just re-sent the same vote. Added a small shared vote store keyed by id that cards subscribe to. Pull-to-refresh clears it. --- src/components/Comment.jsx | 23 +++++------- src/components/Post.jsx | 32 +++++++++-------- src/screens/HomeScreen.jsx | 2 ++ src/utils/voteStore.js | 71 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 src/utils/voteStore.js diff --git a/src/components/Comment.jsx b/src/components/Comment.jsx index f353a99..be68eba 100644 --- a/src/components/Comment.jsx +++ b/src/components/Comment.jsx @@ -8,6 +8,7 @@ import { AppContext } from '../App.jsx'; import AutoImage from './AutoImage'; import UserAvatar from './UserAvatar.jsx'; import Poll from './Poll.jsx'; +import { useSharedVote } from '../utils/voteStore'; const BORDER_RADIUS = 10; /** @@ -18,25 +19,19 @@ function Comment({ comment, nav, isolated = false }) { const { appState } = React.useContext(AppContext); const API = appState.API; const { colors } = useTheme(); - const [vote, setVote] = React.useState(comment.vote_status); - const [voteCount, setVoteCount] = React.useState(comment.vote_total); + const [vote, voteCount, publishVote] = useSharedVote(comment.id, comment.vote_status, comment.vote_total); const [width, setWidth] = React.useState(); - const upvote = () => { - const action = vote == 'upvote' ? 'none' : 'upvote'; + const applyVote = action => { API.setVote(comment.id, action).then(res => { - setVote(action); - setVoteCount(res.post.vote_total); - }); - }; - - const downvote = () => { - const action = vote == 'downvote' ? 'none' : 'downvote'; - API.setVote(comment.id, action).then(res => { - setVote(action); - setVoteCount(res.post.vote_total); + publishVote( + res?.post?.vote_status || action, + res?.post?.vote_total ?? voteCount, + ); }); }; + const upvote = () => applyVote(vote == 'upvote' ? 'none' : 'upvote'); + const downvote = () => applyVote(vote == 'downvote' ? 'none' : 'downvote'); const deleteComment = () => { Alert.alert('Are you sure?', 'This will permanently delete this comment.', [ diff --git a/src/components/Post.jsx b/src/components/Post.jsx index f0295e9..829fd66 100644 --- a/src/components/Post.jsx +++ b/src/components/Post.jsx @@ -9,6 +9,7 @@ import AutoVideo from './AutoVideo'; import UserAvatar from './UserAvatar'; import Poll from './Poll'; import { useRecyclingState } from '@shopify/flash-list'; +import { useSharedVote } from '../utils/voteStore'; const BORDER_RADIUS = 12; @@ -32,28 +33,31 @@ function Post({ if (!post || !API) { return <>; } - const [vote, setVote] = useRecyclingState(post.vote_status, [post]); - const [voteCount, setVoteCount] = useRecyclingState(post.vote_total, [post]); + // Shared across every card showing this post (feed, comments, profile, thread). + const [vote, voteCount, publishVote] = useSharedVote(post.id, post.vote_status, post.vote_total); const [width, setWidth] = useState(); const [group, setGroup] = useRecyclingState(post.group, [post]); const [identity, setIdentity] = useRecyclingState(post?.identity, [post]); const postID = post.id; + const applyVote = React.useCallback( + action => { + API.setVote(postID, action).then(res => { + publishVote( + res?.post?.vote_status || action, + res?.post?.vote_total ?? voteCount, + ); + }); + }, + [postID, API, publishVote, voteCount], + ); const upvote = React.useCallback(() => { - const action = vote == 'upvote' ? 'none' : 'upvote'; - API.setVote(postID, action).then(res => { - setVote(action); - setVoteCount(res.post.vote_total); - }); - }, [vote, postID, API]); + applyVote(vote == 'upvote' ? 'none' : 'upvote'); + }, [vote, applyVote]); const downvote = React.useCallback(() => { - const action = vote == 'downvote' ? 'none' : 'downvote'; - API.setVote(postID, action).then(res => { - setVote(action); - setVoteCount(res.post.vote_total); - }); - }, [vote, postID, API]); + applyVote(vote == 'downvote' ? 'none' : 'downvote'); + }, [vote, applyVote]); // if (post.attachments.length > 0) { // post.attachments.forEach(a => { diff --git a/src/screens/HomeScreen.jsx b/src/screens/HomeScreen.jsx index a6601c3..0d51470 100644 --- a/src/screens/HomeScreen.jsx +++ b/src/screens/HomeScreen.jsx @@ -35,6 +35,7 @@ import GroupAvatar from '../components/GroupAvatar'; import { createMaterial3Theme } from '@pchmn/expo-material3-theme'; import BottomSheet from '@devvie/bottom-sheet'; import { needsUpdate } from '../utils'; +import { clearVotes } from '../utils/voteStore'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { useMMKVObject, useMMKVString } from 'react-native-mmkv'; import { FlashList } from '@shopify/flash-list'; @@ -127,6 +128,7 @@ function HomeScreen({ navigation }) { try { if (refresh) { crashlytics().log('Fetch triggered by refresh/group change'); + clearVotes(); setPosts([]); API.getGroupPosts(override || currentGroup.id, postSortMethod).then( res => { diff --git a/src/utils/voteStore.js b/src/utils/voteStore.js new file mode 100644 index 0000000..c017776 --- /dev/null +++ b/src/utils/voteStore.js @@ -0,0 +1,71 @@ +import React from 'react'; + +/** + * Tiny in-memory store so every card showing the same post or comment + * agrees on the user's vote and the vote total. Without this, the feed card + * and the comments screen each keep their own copy and drift apart after a + * vote in one of them. + */ +const votes = new Map(); // id -> { status, total } +const listeners = new Map(); // id -> Set + +export function getVote(id) { + return votes.get(id); +} + +export function publishVote(id, status, total) { + if (!id) return; + const entry = { status, total }; + votes.set(id, entry); + const subs = listeners.get(id); + if (subs) subs.forEach(cb => cb(entry)); +} + +export function clearVotes() { + votes.clear(); +} + +function subscribe(id, cb) { + if (!listeners.has(id)) listeners.set(id, new Set()); + listeners.get(id).add(cb); + return () => { + const subs = listeners.get(id); + if (!subs) return; + subs.delete(cb); + if (subs.size === 0) listeners.delete(id); + }; +} + +/** + * Shared vote state for one post or comment. + * Returns [status, total, setFromServer(status, total)]. + * Falls back to the values on the item until the user votes somewhere. + */ +export function useSharedVote(id, initialStatus, initialTotal) { + const compute = () => { + const stored = votes.get(id); + return stored + ? stored + : { status: initialStatus, total: initialTotal }; + }; + const [state, setState] = React.useState(compute); + const lastId = React.useRef(id); + + React.useEffect(() => { + // Item changed under a recycled card, or first mount: resync, then listen. + lastId.current = id; + setState(compute()); + return subscribe(id, entry => setState(entry)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + // Avoid a one-frame flash of the previous item's vote after recycling. + const current = lastId.current === id ? state : compute(); + + const set = React.useCallback( + (status, total) => publishVote(id, status, total), + [id], + ); + + return [current.status, current.total, set]; +} From f817472ea7ecbc8558828ecec23add4c7c017df9 Mon Sep 17 00:00:00 2001 From: FrankBStack <294698533+FrankBStack@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:15:23 -0400 Subject: [PATCH 2/2] Update the count optimistically and don't trust a stale server total Removing a vote on a comment comes back with vote_status cleared but the old vote_total, so the arrow lost its colour and the number stayed put. Apply the delta locally as soon as the arrow is tapped, then only accept the server's total if it actually reflects the action. Roll back if the request fails. --- src/components/Comment.jsx | 13 +++---------- src/components/Post.jsx | 15 ++++----------- src/utils/voteStore.js | 26 ++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/components/Comment.jsx b/src/components/Comment.jsx index be68eba..5e2db16 100644 --- a/src/components/Comment.jsx +++ b/src/components/Comment.jsx @@ -8,7 +8,7 @@ import { AppContext } from '../App.jsx'; import AutoImage from './AutoImage'; import UserAvatar from './UserAvatar.jsx'; import Poll from './Poll.jsx'; -import { useSharedVote } from '../utils/voteStore'; +import { useSharedVote, castVote } from '../utils/voteStore'; const BORDER_RADIUS = 10; /** @@ -19,17 +19,10 @@ function Comment({ comment, nav, isolated = false }) { const { appState } = React.useContext(AppContext); const API = appState.API; const { colors } = useTheme(); - const [vote, voteCount, publishVote] = useSharedVote(comment.id, comment.vote_status, comment.vote_total); + const [vote, voteCount] = useSharedVote(comment.id, comment.vote_status, comment.vote_total); const [width, setWidth] = React.useState(); - const applyVote = action => { - API.setVote(comment.id, action).then(res => { - publishVote( - res?.post?.vote_status || action, - res?.post?.vote_total ?? voteCount, - ); - }); - }; + const applyVote = action => castVote(API, comment.id, vote, voteCount, action); const upvote = () => applyVote(vote == 'upvote' ? 'none' : 'upvote'); const downvote = () => applyVote(vote == 'downvote' ? 'none' : 'downvote'); diff --git a/src/components/Post.jsx b/src/components/Post.jsx index 829fd66..8ee85d0 100644 --- a/src/components/Post.jsx +++ b/src/components/Post.jsx @@ -9,7 +9,7 @@ import AutoVideo from './AutoVideo'; import UserAvatar from './UserAvatar'; import Poll from './Poll'; import { useRecyclingState } from '@shopify/flash-list'; -import { useSharedVote } from '../utils/voteStore'; +import { useSharedVote, castVote } from '../utils/voteStore'; const BORDER_RADIUS = 12; @@ -34,22 +34,15 @@ function Post({ return <>; } // Shared across every card showing this post (feed, comments, profile, thread). - const [vote, voteCount, publishVote] = useSharedVote(post.id, post.vote_status, post.vote_total); + const [vote, voteCount] = useSharedVote(post.id, post.vote_status, post.vote_total); const [width, setWidth] = useState(); const [group, setGroup] = useRecyclingState(post.group, [post]); const [identity, setIdentity] = useRecyclingState(post?.identity, [post]); const postID = post.id; const applyVote = React.useCallback( - action => { - API.setVote(postID, action).then(res => { - publishVote( - res?.post?.vote_status || action, - res?.post?.vote_total ?? voteCount, - ); - }); - }, - [postID, API, publishVote, voteCount], + action => castVote(API, postID, vote, voteCount, action), + [postID, API, vote, voteCount], ); const upvote = React.useCallback(() => { applyVote(vote == 'upvote' ? 'none' : 'upvote'); diff --git a/src/utils/voteStore.js b/src/utils/voteStore.js index c017776..c9464e2 100644 --- a/src/utils/voteStore.js +++ b/src/utils/voteStore.js @@ -25,6 +25,32 @@ export function clearVotes() { votes.clear(); } +const weight = s => (s === 'upvote' ? 1 : s === 'downvote' ? -1 : 0); + +/** + * Cast a vote with an optimistic count update, then reconcile with the + * server. The server's total is only trusted when it agrees with the action: + * removing a comment vote can come back with the old total, which would + * otherwise leave the count stuck. + */ +export async function castVote(API, id, prevStatus, prevTotal, action) { + const delta = weight(action) - weight(prevStatus); + const optimistic = (prevTotal ?? 0) + delta; + publishVote(id, action, optimistic); + try { + const res = await API.setVote(id, action); + const st = res?.post?.vote_status; + const tot = res?.post?.vote_total; + const serverAgrees = st === action || st == null; + const totalMoved = tot !== prevTotal; + if (typeof tot === 'number' && serverAgrees && (delta === 0 || totalMoved)) { + publishVote(id, action, tot); + } + } catch (e) { + publishVote(id, prevStatus, prevTotal); + } +} + function subscribe(id, cb) { if (!listeners.has(id)) listeners.set(id, new Set()); listeners.get(id).add(cb);