Skip to content

Commit 970c932

Browse files
committed
feat(mobile): enhance entry detail page with star and share actions
- Add star/unstar functionality for entries - Implement share action for entries - Improve header actions with animated visibility - Update collection sync service with transaction handling - Refactor useFeed hook to support optional selectors Signed-off-by: Innei <tukon479@gmail.com>
1 parent d87a92a commit 970c932

File tree

4 files changed

+173
-29
lines changed

4 files changed

+173
-29
lines changed

apps/mobile/src/icons/star_cute_re.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const StarCuteReIcon = ({
1414
}: StarCuteReIconProps) => {
1515
return (
1616
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
17-
<Path fill="#fff" fillOpacity={0.01} d="M24 0v24H0V0z" />
17+
<Path fill={color} fillOpacity={0.01} d="M24 0v24H0V0z" />
1818
<Path
1919
stroke={color}
2020
strokeWidth={2}

apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,22 @@ import { Fragment, useCallback, useContext, useEffect, useState } from "react"
1010
import {
1111
Clipboard,
1212
Pressable,
13+
Share,
1314
Text,
1415
TouchableOpacity,
1516
useWindowDimensions,
1617
View,
1718
} from "react-native"
1819
import PagerView from "react-native-pager-view"
19-
import ReAnimated, { FadeIn, FadeOut, useSharedValue, withTiming } from "react-native-reanimated"
20+
import type { SharedValue } from "react-native-reanimated"
21+
import Animated, {
22+
FadeIn,
23+
FadeOut,
24+
interpolate,
25+
useAnimatedStyle,
26+
useSharedValue,
27+
withTiming,
28+
} from "react-native-reanimated"
2029
import { useSafeAreaInsets } from "react-native-safe-area-context"
2130
import { useColor } from "react-native-uikit-colors"
2231

@@ -29,9 +38,16 @@ import { EntryContentWebView } from "@/src/components/native/webview/EntryConten
2938
import { DropdownMenu } from "@/src/components/ui/context-menu"
3039
import type { MediaModel } from "@/src/database/schemas/types"
3140
import { More1CuteReIcon } from "@/src/icons/more_1_cute_re"
41+
import { Share3CuteReIcon } from "@/src/icons/share_3_cute_re"
42+
import { StarCuteFiIcon } from "@/src/icons/star_cute_fi"
43+
import { StarCuteReIcon } from "@/src/icons/star_cute_re"
3244
import { openLink } from "@/src/lib/native"
45+
import { toast } from "@/src/lib/toast"
46+
import { useIsEntryStarred } from "@/src/store/collection/hooks"
47+
import { collectionSyncService } from "@/src/store/collection/store"
3348
import { useEntry, usePrefetchEntryContent } from "@/src/store/entry/hooks"
3449
import { useFeed } from "@/src/store/feed/hooks"
50+
import { useSubscription } from "@/src/store/subscription/hooks"
3551

3652
function Media({ media }: { media: MediaModel }) {
3753
const isVideo = media.type === "video"
@@ -97,7 +113,7 @@ export default function EntryDetailPage() {
97113
{({ pressed }) => (
98114
<>
99115
{pressed && (
100-
<ReAnimated.View
116+
<Animated.View
101117
entering={FadeIn}
102118
exiting={FadeOut}
103119
className={"bg-system-fill absolute inset-x-1 inset-y-0 rounded-xl"}
@@ -154,12 +170,17 @@ const EntryTitle = ({ title, entryId }: { title: string; entryId: string }) => {
154170
const opacityAnimatedValue = useSharedValue(0)
155171

156172
const headerHeight = useHeaderHeight()
173+
174+
const [isHeaderTitleVisible, setIsHeaderTitleVisible] = useState(true)
175+
157176
useEffect(() => {
158177
const id = scrollY.addListener((value) => {
159178
if (value.value > titleHeight + headerHeight) {
160179
opacityAnimatedValue.value = withTiming(1, { duration: 100 })
180+
setIsHeaderTitleVisible(true)
161181
} else {
162182
opacityAnimatedValue.value = withTiming(0, { duration: 100 })
183+
setIsHeaderTitleVisible(false)
163184
}
164185
})
165186

@@ -174,18 +195,22 @@ const EntryTitle = ({ title, entryId }: { title: string; entryId: string }) => {
174195
headerShown
175196
headerRight={useCallback(
176197
() => (
177-
<HeaderRightActions entryId={entryId} />
198+
<HeaderRightActions
199+
entryId={entryId}
200+
titleOpacityShareValue={opacityAnimatedValue}
201+
isHeaderTitleVisible={isHeaderTitleVisible}
202+
/>
178203
),
179-
[entryId],
204+
[entryId, opacityAnimatedValue, isHeaderTitleVisible],
180205
)}
181206
headerTitle={() => (
182-
<ReAnimated.Text
207+
<Animated.Text
183208
className={"text-label text-[17px] font-semibold"}
184209
numberOfLines={1}
185210
style={{ opacity: opacityAnimatedValue }}
186211
>
187212
{title}
188-
</ReAnimated.Text>
213+
</Animated.Text>
189214
)}
190215
/>
191216
<View
@@ -254,36 +279,103 @@ const MediaSwipe: FC<{ mediaList: MediaModel[]; id: string }> = ({ mediaList, id
254279
)
255280
}
256281

257-
const HeaderRightActions = ({ entryId }: { entryId: string }) => {
258-
return <HeaderRightActionsImpl entryId={entryId} />
282+
const HeaderRightActions = (props: HeaderRightActionsProps) => {
283+
return <HeaderRightActionsImpl {...props} />
259284
}
260285

261286
interface HeaderRightActionsProps {
262287
entryId: string
288+
titleOpacityShareValue: SharedValue<number>
289+
isHeaderTitleVisible: boolean
263290
}
264-
const HeaderRightActionsImpl = ({ entryId }: HeaderRightActionsProps) => {
291+
const HeaderRightActionsImpl = ({
292+
entryId,
293+
titleOpacityShareValue,
294+
isHeaderTitleVisible,
295+
}: HeaderRightActionsProps) => {
265296
const labelColor = useColor("label")
297+
const isStarred = useIsEntryStarred(entryId)
266298

267299
const entry = useEntry(entryId, (entry) => {
268300
if (!entry) return
269301
return {
270302
url: entry.url,
303+
feedId: entry.feedId,
304+
title: entry.title,
305+
}
306+
})
307+
const feed = useFeed(entry?.feedId as string, (feed) => {
308+
return {
309+
feedId: feed.id,
271310
}
272311
})
312+
const subscription = useSubscription(feed?.feedId as string)
313+
314+
const handleToggleStar = () => {
315+
if (!entry) return
316+
if (!feed) return
317+
if (!subscription) return
318+
if (isStarred) collectionSyncService.unstarEntry(entryId)
319+
else
320+
collectionSyncService.starEntry({
321+
entryId,
322+
feedId: feed.feedId,
323+
view: subscription.view,
324+
})
325+
}
326+
327+
const handleShare = () => {
328+
if (!entry) return
329+
Share.share({
330+
title: entry.title!,
331+
url: entry.url!,
332+
})
333+
}
273334
return (
274-
<View>
335+
<View className="relative flex-row gap-4">
336+
<Animated.View
337+
style={useAnimatedStyle(() => {
338+
return {
339+
opacity: interpolate(titleOpacityShareValue.value, [0, 1], [1, 0]),
340+
}
341+
})}
342+
className="absolute right-[32] flex-row gap-4"
343+
>
344+
{!!subscription && (
345+
<TouchableOpacity hitSlop={10} onPress={handleToggleStar}>
346+
{isStarred ? <StarCuteFiIcon color="#facc15" /> : <StarCuteReIcon color={labelColor} />}
347+
</TouchableOpacity>
348+
)}
349+
350+
<TouchableOpacity hitSlop={10} onPress={handleShare}>
351+
<Share3CuteReIcon color={labelColor} />
352+
</TouchableOpacity>
353+
</Animated.View>
354+
275355
<DropdownMenu.Root>
276356
<DropdownMenu.Trigger>
277357
<TouchableOpacity hitSlop={10}>
278358
<More1CuteReIcon color={labelColor} />
279359
</TouchableOpacity>
280360
</DropdownMenu.Trigger>
361+
281362
<DropdownMenu.Content>
363+
{isHeaderTitleVisible && (
364+
<DropdownMenu.Group>
365+
<DropdownMenu.Item key="Star" onSelect={handleToggleStar}>
366+
<DropdownMenu.ItemTitle>{isStarred ? "Unstar" : "Star"}</DropdownMenu.ItemTitle>
367+
</DropdownMenu.Item>
368+
<DropdownMenu.Item key="Share" onSelect={handleShare}>
369+
<DropdownMenu.ItemTitle>Share</DropdownMenu.ItemTitle>
370+
</DropdownMenu.Item>
371+
</DropdownMenu.Group>
372+
)}
282373
<DropdownMenu.Item
283374
key="CopyLink"
284375
onSelect={() => {
285376
if (!entry?.url) return
286377
Clipboard.setString(entry.url)
378+
toast.info("Link copied to clipboard")
287379
}}
288380
>
289381
<DropdownMenu.ItemTitle>Copy Link</DropdownMenu.ItemTitle>

apps/mobile/src/store/collection/store.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,62 @@ export const useCollectionStore = createZustandStore<CollectionState>("collectio
2222
const set = useCollectionStore.setState
2323

2424
class CollectionSyncService {
25-
async starEntry(collection: CollectionSchema, view: FeedViewType) {
26-
await apiClient.collections.$post({
27-
json: {
28-
entryId: collection.entryId,
29-
view,
30-
},
25+
async starEntry({
26+
entryId,
27+
feedId,
28+
view,
29+
}: {
30+
entryId: string
31+
feedId: string
32+
view: FeedViewType
33+
}) {
34+
const tx = createTransaction()
35+
tx.store(async () => {
36+
await collectionActions.upsertMany([
37+
{
38+
createdAt: new Date().toISOString(),
39+
entryId,
40+
feedId,
41+
view,
42+
},
43+
])
44+
})
45+
tx.request(async () => {
46+
await apiClient.collections.$post({
47+
json: {
48+
entryId,
49+
view,
50+
},
51+
})
52+
})
53+
tx.rollback(() => {
54+
collectionActions.delete(entryId)
3155
})
3256

33-
await collectionActions.upsertMany([collection])
34-
return
57+
await tx.run()
3558
}
3659

37-
async unstarEntry(collection: CollectionSchema) {
38-
await apiClient.collections.$delete({
39-
json: {
40-
entryId: collection.entryId,
41-
},
60+
async unstarEntry(entryId: string) {
61+
const tx = createTransaction()
62+
63+
const snapshot = useCollectionStore.getState().collections[entryId]
64+
tx.store(() => {
65+
collectionActions.delete(entryId)
66+
})
67+
tx.request(async () => {
68+
await apiClient.collections.$delete({
69+
json: {
70+
entryId,
71+
},
72+
})
73+
})
74+
75+
tx.rollback(() => {
76+
if (!snapshot) return
77+
collectionActions.upsertMany([snapshot])
4278
})
4379

44-
await collectionActions.delete(collection.entryId)
80+
await tx.run()
4581
}
4682
}
4783

apps/mobile/src/store/feed/hooks.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import { useQuery } from "@tanstack/react-query"
2+
import { useCallback } from "react"
23

34
import { feedSyncServices, useFeedStore } from "./store"
5+
import type { FeedModel } from "./types"
46

5-
export const useFeed = (id: string) => {
6-
return useFeedStore((state) => {
7-
return state.feeds[id]
8-
})
7+
const defaultSelector = (feed: FeedModel) => feed
8+
export function useFeed(id: string): FeedModel | undefined
9+
export function useFeed<T>(id: string, selector: (feed: FeedModel) => T): T | undefined
10+
export function useFeed<T>(
11+
id: string,
12+
// @ts-expect-error
13+
selector: (feed: FeedModel) => T = defaultSelector,
14+
): T | undefined {
15+
return useFeedStore(
16+
useCallback(
17+
(state) => {
18+
const feed = state.feeds[id]
19+
if (!feed) return
20+
return selector(feed)
21+
},
22+
[id],
23+
),
24+
)
925
}
1026

1127
export const usePrefetchFeed = (id: string, options?: { enabled?: boolean }) => {

0 commit comments

Comments
 (0)