diff --git a/src/components/Comment.jsx b/src/components/Comment.jsx index f353a99..5e2db16 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, castVote } from '../utils/voteStore'; const BORDER_RADIUS = 10; /** @@ -18,25 +19,12 @@ 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] = useSharedVote(comment.id, comment.vote_status, comment.vote_total); const [width, setWidth] = React.useState(); - const upvote = () => { - const action = vote == 'upvote' ? 'none' : 'upvote'; - 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); - }); - }; + const applyVote = action => castVote(API, comment.id, vote, voteCount, action); + 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..8ee85d0 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, castVote } from '../utils/voteStore'; const BORDER_RADIUS = 12; @@ -32,28 +33,24 @@ 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] = 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 => castVote(API, postID, vote, voteCount, action), + [postID, API, vote, 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..c9464e2 --- /dev/null +++ b/src/utils/voteStore.js @@ -0,0 +1,97 @@ +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(); +} + +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); + 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]; +}