BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { FeedController } from "$/lib/api/feeds";
2import {
3 emitBookmarkChanged,
4 emitPostViewUpdated,
5 type PostViewUpdateDetail,
6 subscribePostViewUpdated,
7} from "$/lib/post-events";
8import type { PostView } from "$/lib/types";
9import { onCleanup, onMount } from "solid-js";
10import { createStore } from "solid-js/store";
11
12type InteractionState = {
13 bookmarkPendingByUri: Record<string, boolean>;
14 likePendingByUri: Record<string, boolean>;
15 likePulseUri: string | null;
16 repostPendingByUri: Record<string, boolean>;
17 repostPulseUri: string | null;
18};
19
20type UsePostInteractionsProps = {
21 onError: (message: string) => void;
22 patchPost: (uri: string, updater: (post: PostView) => PostView) => void;
23};
24
25export function usePostInteractions(props: UsePostInteractionsProps) {
26 const [state, setState] = createStore<InteractionState>({
27 bookmarkPendingByUri: {},
28 likePendingByUri: {},
29 likePulseUri: null,
30 repostPendingByUri: {},
31 repostPulseUri: null,
32 });
33
34 onMount(() => {
35 const dispose = subscribePostViewUpdated((detail) => {
36 props.patchPost(detail.uri, (current) => applyEventPatch(current, detail));
37 });
38 onCleanup(dispose);
39 });
40
41 async function toggleLike(post: PostView) {
42 if (state.likePendingByUri[post.uri]) {
43 return;
44 }
45
46 setState("likePendingByUri", post.uri, true);
47 const previousLike = post.viewer?.like ?? null;
48 const previousLikeCount = post.likeCount ?? 0;
49
50 if (previousLike) {
51 props.patchPost(
52 post.uri,
53 (current) => ({
54 ...current,
55 likeCount: Math.max(0, (current.likeCount ?? 0) - 1),
56 viewer: { ...current.viewer, like: null },
57 }),
58 );
59 } else {
60 props.patchPost(
61 post.uri,
62 (current) => ({
63 ...current,
64 likeCount: (current.likeCount ?? 0) + 1,
65 viewer: { ...current.viewer, like: "optimistic-like" },
66 }),
67 );
68 triggerPulse("likePulseUri", post.uri);
69 }
70
71 try {
72 if (previousLike) {
73 await FeedController.unlikePost(previousLike);
74 emitPostViewUpdated({ likeCount: Math.max(0, previousLikeCount - 1), uri: post.uri, viewer: { like: null } });
75 } else {
76 const result = await FeedController.likePost(post.uri, post.cid);
77 props.patchPost(post.uri, (current) => ({ ...current, viewer: { ...current.viewer, like: result.uri } }));
78 emitPostViewUpdated({ likeCount: previousLikeCount + 1, uri: post.uri, viewer: { like: result.uri } });
79 }
80 } catch (error) {
81 props.patchPost(
82 post.uri,
83 (current) => ({ ...current, likeCount: previousLikeCount, viewer: { ...current.viewer, like: previousLike } }),
84 );
85 props.onError(`Failed to update like: ${String(error)}`);
86 } finally {
87 setState("likePendingByUri", post.uri, false);
88 }
89 }
90
91 async function toggleRepost(post: PostView) {
92 if (state.repostPendingByUri[post.uri]) {
93 return;
94 }
95
96 setState("repostPendingByUri", post.uri, true);
97 const previousRepost = post.viewer?.repost ?? null;
98 const previousRepostCount = post.repostCount ?? 0;
99
100 if (previousRepost) {
101 props.patchPost(
102 post.uri,
103 (current) => ({
104 ...current,
105 repostCount: Math.max(0, (current.repostCount ?? 0) - 1),
106 viewer: { ...current.viewer, repost: null },
107 }),
108 );
109 } else {
110 props.patchPost(
111 post.uri,
112 (current) => ({
113 ...current,
114 repostCount: (current.repostCount ?? 0) + 1,
115 viewer: { ...current.viewer, repost: "optimistic-repost" },
116 }),
117 );
118 triggerPulse("repostPulseUri", post.uri);
119 }
120
121 try {
122 if (previousRepost) {
123 await FeedController.unrepost(previousRepost);
124 emitPostViewUpdated({
125 repostCount: Math.max(0, previousRepostCount - 1),
126 uri: post.uri,
127 viewer: { repost: null },
128 });
129 } else {
130 const result = await FeedController.repost(post.uri, post.cid);
131 props.patchPost(post.uri, (current) => ({ ...current, viewer: { ...current.viewer, repost: result.uri } }));
132 emitPostViewUpdated({ repostCount: previousRepostCount + 1, uri: post.uri, viewer: { repost: result.uri } });
133 }
134 } catch (error) {
135 props.patchPost(
136 post.uri,
137 (current) => ({
138 ...current,
139 repostCount: previousRepostCount,
140 viewer: { ...current.viewer, repost: previousRepost },
141 }),
142 );
143 props.onError(`Failed to update repost: ${String(error)}`);
144 } finally {
145 setState("repostPendingByUri", post.uri, false);
146 }
147 }
148
149 async function toggleBookmark(post: PostView) {
150 if (state.bookmarkPendingByUri[post.uri]) {
151 return;
152 }
153
154 setState("bookmarkPendingByUri", post.uri, true);
155 const previousBookmarked = !!post.viewer?.bookmarked;
156
157 props.patchPost(
158 post.uri,
159 (current) => ({ ...current, viewer: { ...current.viewer, bookmarked: !previousBookmarked } }),
160 );
161
162 try {
163 if (previousBookmarked) {
164 await FeedController.removeBookmark(post.uri);
165 } else {
166 await FeedController.bookmarkPost(post.uri, post.cid);
167 }
168
169 emitPostViewUpdated({ uri: post.uri, viewer: { bookmarked: !previousBookmarked } });
170 emitBookmarkChanged({ bookmarked: !previousBookmarked, cid: post.cid, uri: post.uri });
171 } catch (error) {
172 props.patchPost(
173 post.uri,
174 (current) => ({ ...current, viewer: { ...current.viewer, bookmarked: previousBookmarked } }),
175 );
176 props.onError(`Failed to update save: ${String(error)}`);
177 } finally {
178 setState("bookmarkPendingByUri", post.uri, false);
179 }
180 }
181
182 function triggerPulse(key: "likePulseUri" | "repostPulseUri", uri: string) {
183 setState(key, uri);
184 globalThis.setTimeout(() => setState(key, (current) => (current === uri ? null : current)), 320);
185 }
186
187 return {
188 bookmarkPendingByUri: () => state.bookmarkPendingByUri,
189 likePendingByUri: () => state.likePendingByUri,
190 likePulseUri: () => state.likePulseUri,
191 repostPendingByUri: () => state.repostPendingByUri,
192 repostPulseUri: () => state.repostPulseUri,
193 toggleBookmark,
194 toggleLike,
195 toggleRepost,
196 };
197}
198
199function applyEventPatch(post: PostView, detail: PostViewUpdateDetail): PostView {
200 return {
201 ...post,
202 likeCount: detail.likeCount ?? post.likeCount,
203 repostCount: detail.repostCount ?? post.repostCount,
204 viewer: detail.viewer ? { ...post.viewer, ...detail.viewer } : post.viewer,
205 };
206}