Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 5 additions & 17 deletions src/components/Comment.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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.', [
Expand Down
25 changes: 11 additions & 14 deletions src/components/Post.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 => {
Expand Down
2 changes: 2 additions & 0 deletions src/screens/HomeScreen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 => {
Expand Down
97 changes: 97 additions & 0 deletions src/utils/voteStore.js
Original file line number Diff line number Diff line change
@@ -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<callback>

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];
}