BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
at main 206 lines 6.6 kB view raw
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}