Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e70e1df79 | |||
| 00efc4da11 | |||
| 0138e8368a | |||
| c23bdac0dc | |||
| 8a8e76714c | |||
| 5e548aed5e | |||
| 8d620b1da7 | |||
| 05a5f3b871 | |||
| 3bd34dc2eb | |||
| 7927943b01 | |||
| df27275353 | |||
| 08d62f3b36 | |||
| 1a13a9734e | |||
| 89b3a1401a | |||
| af5e783d7d | |||
| 1e3cfa6f2e | |||
| a8895a77a3 | |||
| d1195cc3eb | |||
| 813499ad52 | |||
| 81ac0734c5 | |||
| 9ba43051ce | |||
| dd00a3a765 | |||
| 969557d118 | |||
| a639758f5f | |||
| 85a4a006fa | |||
| 8c40895fd5 | |||
| ea29a3509e | |||
| 2950b3b8c0 | |||
| 5782ce7a0e |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9.7 16.895a4 4 0 0 1 4.6 0l3.7 2.6V6.5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v12.995l3.7-2.6Zm10.3 2.6c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12.995Z"/></svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#405168" d="M14.2 2.625c.834 0 1.482 0 2.001.042.523.043.949.131 1.331.326a3.38 3.38 0 0 1 1.475 1.475c.195.382.283.807.326 1.33.042.52.042 1.168.042 2.002v11.09c0 .495 0 .893-.027 1.199-.028.301-.087.585-.26.809-.249.323-.63.518-1.037.533-.282.01-.547-.107-.808-.26-.265-.154-.588-.385-.991-.673l-3.54-2.528c-.36-.258-.461-.322-.559-.347a.6.6 0 0 0-.306 0c-.098.025-.199.09-.559.347l-3.54 2.528c-.403.288-.726.519-.991.674-.261.152-.526.269-.808.259a1.38 1.38 0 0 1-1.038-.534c-.172-.223-.231-.507-.259-.808a7 7 0 0 1-.024-.528l-.003-.67V7.8c0-.834 0-1.482.042-2.001.043-.523.13-.949.325-1.331a3.38 3.38 0 0 1 1.476-1.475c.382-.195.808-.283 1.33-.326.52-.042 1.168-.042 2.002-.042h4.4Zm-4.4.75c-.846 0-1.458 0-1.94.04-.477.039-.792.114-1.051.246A2.63 2.63 0 0 0 5.66 4.81c-.132.259-.208.574-.247 1.051-.04.482-.039 1.094-.039 1.94v11.09l.003.658c.003.186.01.34.021.473.025.267.07.37.106.418a.63.63 0 0 0 .472.243c.059.002.168-.022.4-.158.23-.133.52-.34.935-.636l3.54-2.529c.308-.22.543-.396.81-.464.222-.056.454-.056.676 0 .267.068.5.244.81.464l3.54 2.529c.414.296.704.503.933.636.233.137.343.16.402.158a.63.63 0 0 0 .472-.243c.036-.048.081-.15.106-.419.024-.263.024-.62.024-1.13V7.8c0-.846 0-1.458-.04-1.94-.039-.477-.114-.792-.246-1.051A2.63 2.63 0 0 0 17.19 3.66c-.259-.132-.575-.207-1.051-.246-.482-.04-1.094-.04-1.94-.04H9.8Zm4.056 4.238a.375.375 0 0 1 .53.53L12.53 10l1.857 1.856a.375.375 0 0 1-.53.53L12 10.53l-1.856 1.857a.375.375 0 0 1-.53-.53L11.47 10 9.613 8.144a.375.375 0 0 1 .53-.53L12 9.47l1.856-1.857Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006AFF" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 250 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 233 B |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -331,6 +331,9 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/starter-pack-short/:code", server.WebGeneric)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
// bookmarks
|
||||
e.GET("/saved", server.WebGeneric)
|
||||
|
||||
// ipcc
|
||||
e.GET("/ipcc", server.WebIpCC)
|
||||
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.16.2",
|
||||
"@atproto/api": "^0.16.7",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
|
||||
@@ -71,6 +71,7 @@ import {SupportScreen} from '#/view/screens/Support'
|
||||
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
|
||||
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {BookmarksScreen} from '#/screens/Bookmarks'
|
||||
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {LogScreen} from '#/screens/Log'
|
||||
@@ -600,6 +601,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Bookmarks"
|
||||
getComponent={() => BookmarksScreen}
|
||||
options={{
|
||||
title: title(msg`Saved Posts`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import {memo} from 'react'
|
||||
import {type Insets} from 'react-native'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type React from 'react'
|
||||
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
|
||||
import {useTheme} from '#/alf'
|
||||
import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import * as toast from '#/components/Toast'
|
||||
import {PostControlButton, PostControlButtonIcon} from './PostControlButton'
|
||||
|
||||
export const BookmarkButton = memo(function BookmarkButton({
|
||||
post,
|
||||
big,
|
||||
logContext,
|
||||
hitSlop,
|
||||
}: {
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
big?: boolean
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
hitSlop?: Insets
|
||||
}): React.ReactNode {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {mutateAsync: bookmark} = useBookmarkMutation()
|
||||
const cleanError = useCleanError()
|
||||
|
||||
const {viewer} = post
|
||||
const isBookmarked = !!viewer?.bookmarked
|
||||
|
||||
const undoLabel = _(
|
||||
msg({
|
||||
message: `Undo`,
|
||||
context: `Button label to undo saving/removing a post from saved posts.`,
|
||||
}),
|
||||
)
|
||||
|
||||
const save = async ({disableUndo}: {disableUndo?: boolean} = {}) => {
|
||||
try {
|
||||
await bookmark({
|
||||
action: 'create',
|
||||
post,
|
||||
})
|
||||
|
||||
logger.metric('post:bookmark', {logContext})
|
||||
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
<toast.Icon />
|
||||
<toast.Text>
|
||||
<Trans>Post saved</Trans>
|
||||
</toast.Text>
|
||||
{!disableUndo && (
|
||||
<toast.Action
|
||||
label={undoLabel}
|
||||
onPress={() => remove({disableUndo: true})}>
|
||||
{undoLabel}
|
||||
</toast.Action>
|
||||
)}
|
||||
</toast.Outer>,
|
||||
{
|
||||
type: 'success',
|
||||
},
|
||||
)
|
||||
} catch (e: any) {
|
||||
const {raw, clean} = cleanError(e)
|
||||
toast.show(clean || raw || e, {
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async ({disableUndo}: {disableUndo?: boolean} = {}) => {
|
||||
try {
|
||||
await bookmark({
|
||||
action: 'delete',
|
||||
uri: post.uri,
|
||||
})
|
||||
|
||||
logger.metric('post:unbookmark', {logContext})
|
||||
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
<toast.Icon icon={TrashIcon} />
|
||||
<toast.Text>
|
||||
<Trans>Removed from saved posts</Trans>
|
||||
</toast.Text>
|
||||
{!disableUndo && (
|
||||
<toast.Action
|
||||
label={undoLabel}
|
||||
onPress={() => save({disableUndo: true})}>
|
||||
{undoLabel}
|
||||
</toast.Action>
|
||||
)}
|
||||
</toast.Outer>,
|
||||
)
|
||||
} catch (e: any) {
|
||||
const {raw, clean} = cleanError(e)
|
||||
toast.show(clean || raw || e, {
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onHandlePress = async () => {
|
||||
if (isBookmarked) {
|
||||
await remove()
|
||||
} else {
|
||||
await save()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PostControlButton
|
||||
testID="postBookmarkBtn"
|
||||
big={big}
|
||||
label={
|
||||
isBookmarked
|
||||
? _(msg`Remove from saved posts`)
|
||||
: _(msg`Add to saved posts`)
|
||||
}
|
||||
onPress={onHandlePress}
|
||||
hitSlop={hitSlop}>
|
||||
<PostControlButtonIcon
|
||||
fill={isBookmarked ? t.palette.primary_500 : undefined}
|
||||
icon={isBookmarked ? BookmarkFilled : Bookmark}
|
||||
/>
|
||||
</PostControlButton>
|
||||
)
|
||||
})
|
||||
@@ -1,13 +1,14 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {type GestureResponderEvent, type View} from 'react-native'
|
||||
import {type GestureResponderEvent, type Insets, type View} from 'react-native'
|
||||
|
||||
import {POST_CTRL_HITSLOP} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, type ButtonProps} from '#/components/Button'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
|
||||
export const DEFAULT_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
|
||||
|
||||
const PostControlContext = createContext<{
|
||||
big?: boolean
|
||||
active?: boolean
|
||||
@@ -25,12 +26,13 @@ export function PostControlButton({
|
||||
active,
|
||||
activeColor,
|
||||
...props
|
||||
}: ButtonProps & {
|
||||
}: Omit<ButtonProps, 'hitSlop'> & {
|
||||
ref?: React.Ref<View>
|
||||
active?: boolean
|
||||
big?: boolean
|
||||
color?: string
|
||||
activeColor?: string
|
||||
hitSlop?: Insets
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
@@ -83,8 +85,11 @@ export function PostControlButton({
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
hitSlop={POST_CTRL_HITSLOP}
|
||||
{...props}>
|
||||
{...props}
|
||||
hitSlop={{
|
||||
...DEFAULT_HITSLOP,
|
||||
...(props.hitSlop || {}),
|
||||
}}>
|
||||
{typeof children === 'function' ? (
|
||||
args => (
|
||||
<PostControlContext.Provider value={ctx}>
|
||||
@@ -102,12 +107,20 @@ export function PostControlButton({
|
||||
|
||||
export function PostControlButtonIcon({
|
||||
icon: Comp,
|
||||
}: {
|
||||
style,
|
||||
...rest
|
||||
}: SVGIconProps & {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
}) {
|
||||
const {big, color} = useContext(PostControlContext)
|
||||
|
||||
return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} />
|
||||
return (
|
||||
<Comp
|
||||
style={[color, a.pointer_events_none, style]}
|
||||
{...rest}
|
||||
width={big ? 22 : 18}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostControlButtonText({style, ...props}: TextProps) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {memo, useMemo, useState} from 'react'
|
||||
import {type Insets} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
@@ -28,6 +29,7 @@ let PostMenuButton = ({
|
||||
timestamp,
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
hitSlop,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -39,6 +41,7 @@ let PostMenuButton = ({
|
||||
timestamp: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
hitSlop?: Insets
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -66,7 +69,8 @@ let PostMenuButton = ({
|
||||
testID="postDropdownBtn"
|
||||
big={big}
|
||||
label={props.accessibilityLabel}
|
||||
{...props}>
|
||||
{...props}
|
||||
hitSlop={hitSlop}>
|
||||
<PostControlButtonIcon icon={DotsHorizontal} />
|
||||
</PostControlButton>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useRequireAuth} from '#/state/session'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
|
||||
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||
import {Repost_Stroke2_Corner3_Rounded as Repost} from '#/components/icons/Repost'
|
||||
import {formatPostStatCount} from '#/components/PostControls/util'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
PostControlButton,
|
||||
@@ -25,6 +25,7 @@ interface Props {
|
||||
onQuote: () => void
|
||||
big?: boolean
|
||||
embeddingDisabled: boolean
|
||||
compactCount?: boolean
|
||||
}
|
||||
|
||||
let RepostButton = ({
|
||||
@@ -34,6 +35,7 @@ let RepostButton = ({
|
||||
onQuote,
|
||||
big,
|
||||
embeddingDisabled,
|
||||
compactCount,
|
||||
}: Props): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
@@ -86,7 +88,7 @@ let RepostButton = ({
|
||||
<PostControlButtonIcon icon={Repost} />
|
||||
{typeof repostCount !== 'undefined' && repostCount > 0 && (
|
||||
<PostControlButtonText testID="repostCount">
|
||||
{formatCount(i18n, repostCount)}
|
||||
{formatPostStatCount(i18n, repostCount, {compact: compactCount})}
|
||||
</PostControlButtonText>
|
||||
)}
|
||||
</PostControlButton>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {memo, useMemo, useState} from 'react'
|
||||
import {type Insets} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
@@ -34,6 +35,7 @@ let ShareMenuButton = ({
|
||||
timestamp,
|
||||
threadgateRecord,
|
||||
onShare,
|
||||
hitSlop,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -43,6 +45,7 @@ let ShareMenuButton = ({
|
||||
timestamp: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShare: () => void
|
||||
hitSlop?: Insets
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
@@ -92,7 +95,8 @@ let ShareMenuButton = ({
|
||||
big={big}
|
||||
label={props.accessibilityLabel}
|
||||
{...props}
|
||||
onLongPress={native(onNativeLongPress)}>
|
||||
onLongPress={native(onNativeLongPress)}
|
||||
hitSlop={hitSlop}>
|
||||
<PostControlButtonIcon icon={ShareIcon} />
|
||||
</PostControlButton>
|
||||
)
|
||||
|
||||
@@ -24,10 +24,11 @@ import {
|
||||
ProgressGuideAction,
|
||||
useProgressGuideControls,
|
||||
} from '#/state/shell/progress-guide'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
|
||||
import {atoms as a, flatten, useBreakpoints} from '#/alf'
|
||||
import {Reply as Bubble} from '#/components/icons/Reply'
|
||||
import {formatPostStatCount} from '#/components/PostControls/util'
|
||||
import {BookmarkButton} from './BookmarkButton'
|
||||
import {
|
||||
PostControlButton,
|
||||
PostControlButtonIcon,
|
||||
@@ -51,6 +52,7 @@ let PostControls = ({
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
viaRepost,
|
||||
variant,
|
||||
}: {
|
||||
big?: boolean
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -65,9 +67,9 @@ let PostControls = ({
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
viaRepost?: {uri: string; cid: string}
|
||||
variant?: 'compact' | 'normal' | 'large'
|
||||
}): React.ReactNode => {
|
||||
const {_, i18n} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const {feedDescriptor} = useFeedFeedbackContext()
|
||||
const [queueLike, queueUnlike] = usePostLikeMutationQueue(
|
||||
@@ -92,6 +94,7 @@ let PostControls = ({
|
||||
post.author.viewer?.blockingByList,
|
||||
)
|
||||
const replyDisabled = post.viewer?.replyDisabled
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
|
||||
|
||||
@@ -184,6 +187,12 @@ let PostControls = ({
|
||||
})
|
||||
}
|
||||
|
||||
const secondaryControlSpacingStyles = flatten([
|
||||
{gap: 0}, // default, we want `gap` to be defined on the resulting object
|
||||
variant !== 'compact' && a.gap_xs,
|
||||
(big || gtPhone) && a.gap_sm,
|
||||
])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -191,104 +200,124 @@ let PostControls = ({
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
!big && a.pt_2xs,
|
||||
a.gap_md,
|
||||
style,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
|
||||
replyDisabled ? {opacity: 0.5} : undefined,
|
||||
]}>
|
||||
<PostControlButton
|
||||
testID="replyBtn"
|
||||
onPress={
|
||||
!replyDisabled ? () => requireAuth(() => onPressReply()) : undefined
|
||||
}
|
||||
label={_(
|
||||
msg({
|
||||
message: `Reply (${plural(post.replyCount || 0, {
|
||||
one: '# reply',
|
||||
other: '# replies',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
|
||||
}),
|
||||
)}
|
||||
big={big}>
|
||||
<PostControlButtonIcon icon={Bubble} />
|
||||
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
|
||||
<PostControlButtonText>
|
||||
{formatCount(i18n, post.replyCount)}
|
||||
</PostControlButtonText>
|
||||
)}
|
||||
</PostControlButton>
|
||||
</View>
|
||||
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
|
||||
<RepostButton
|
||||
isReposted={!!post.viewer?.repost}
|
||||
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
|
||||
onRepost={onRepost}
|
||||
onQuote={onQuote}
|
||||
big={big}
|
||||
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
|
||||
/>
|
||||
</View>
|
||||
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
|
||||
<PostControlButton
|
||||
testID="likeBtn"
|
||||
big={big}
|
||||
onPress={() => requireAuth(() => onPressToggleLike())}
|
||||
label={
|
||||
post.viewer?.like
|
||||
? _(
|
||||
msg({
|
||||
message: `Unlike (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: `Like (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
|
||||
}),
|
||||
)
|
||||
}>
|
||||
<AnimatedLikeIcon
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
<View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_start,
|
||||
{marginLeft: big ? -2 : -6},
|
||||
replyDisabled ? {opacity: 0.5} : undefined,
|
||||
]}>
|
||||
<PostControlButton
|
||||
testID="replyBtn"
|
||||
onPress={
|
||||
!replyDisabled
|
||||
? () => requireAuth(() => onPressReply())
|
||||
: undefined
|
||||
}
|
||||
label={_(
|
||||
msg({
|
||||
message: `Reply (${plural(post.replyCount || 0, {
|
||||
one: '# reply',
|
||||
other: '# replies',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
|
||||
}),
|
||||
)}
|
||||
big={big}>
|
||||
<PostControlButtonIcon icon={Bubble} />
|
||||
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
|
||||
<PostControlButtonText>
|
||||
{formatPostStatCount(i18n, post.replyCount, {
|
||||
compact: variant === 'compact',
|
||||
})}
|
||||
</PostControlButtonText>
|
||||
)}
|
||||
</PostControlButton>
|
||||
</View>
|
||||
<View style={[a.flex_1, a.align_start]}>
|
||||
<RepostButton
|
||||
isReposted={!!post.viewer?.repost}
|
||||
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
|
||||
onRepost={onRepost}
|
||||
onQuote={onQuote}
|
||||
big={big}
|
||||
hasBeenToggled={hasLikeIconBeenToggled}
|
||||
/>
|
||||
<CountWheel
|
||||
likeCount={post.likeCount ?? 0}
|
||||
big={big}
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
hasBeenToggled={hasLikeIconBeenToggled}
|
||||
/>
|
||||
</PostControlButton>
|
||||
</View>
|
||||
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
|
||||
<View style={[!big && a.ml_sm]}>
|
||||
<ShareMenuButton
|
||||
testID="postShareBtn"
|
||||
post={post}
|
||||
big={big}
|
||||
record={record}
|
||||
richText={richText}
|
||||
timestamp={post.indexedAt}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShare={onShare}
|
||||
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
|
||||
compactCount={variant === 'compact'}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1, a.align_start]}>
|
||||
<PostControlButton
|
||||
testID="likeBtn"
|
||||
big={big}
|
||||
onPress={() => requireAuth(() => onPressToggleLike())}
|
||||
label={
|
||||
post.viewer?.like
|
||||
? _(
|
||||
msg({
|
||||
message: `Unlike (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: `Like (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
|
||||
}),
|
||||
)
|
||||
}>
|
||||
<AnimatedLikeIcon
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
big={big}
|
||||
hasBeenToggled={hasLikeIconBeenToggled}
|
||||
/>
|
||||
<CountWheel
|
||||
likeCount={post.likeCount ?? 0}
|
||||
big={big}
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
hasBeenToggled={hasLikeIconBeenToggled}
|
||||
compactCount={variant === 'compact'}
|
||||
/>
|
||||
</PostControlButton>
|
||||
</View>
|
||||
{/* Spacer! */}
|
||||
<View />
|
||||
</View>
|
||||
<View
|
||||
style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}>
|
||||
<View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
|
||||
<BookmarkButton
|
||||
post={post}
|
||||
big={big}
|
||||
logContext={logContext}
|
||||
hitSlop={{
|
||||
right: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
/>
|
||||
<ShareMenuButton
|
||||
testID="postShareBtn"
|
||||
post={post}
|
||||
big={big}
|
||||
record={record}
|
||||
richText={richText}
|
||||
timestamp={post.indexedAt}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShare={onShare}
|
||||
hitSlop={{
|
||||
left: secondaryControlSpacingStyles.gap / 2,
|
||||
right: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
/>
|
||||
<PostMenuButton
|
||||
testID="postDropdownBtn"
|
||||
post={post}
|
||||
@@ -300,6 +329,9 @@ let PostControls = ({
|
||||
timestamp={post.indexedAt}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
hitSlop={{
|
||||
left: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {type I18n} from '@lingui/core'
|
||||
|
||||
/**
|
||||
* This matches `formatCount` from `view/com/util/numeric/format.ts`, but has
|
||||
* additional truncation logic for large numbers. `roundingMode` should always
|
||||
* match the original impl, regardless of if we add more formatting here.
|
||||
*/
|
||||
export function formatPostStatCount(
|
||||
i18n: I18n,
|
||||
count: number,
|
||||
{
|
||||
compact = false,
|
||||
}: {
|
||||
compact?: boolean
|
||||
} = {},
|
||||
): string {
|
||||
const isOver10k = count >= 10_000
|
||||
return i18n.number(count, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: isOver10k || compact ? 0 : 1,
|
||||
// @ts-expect-error - roundingMode not in the types
|
||||
roundingMode: 'trunc',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function BookmarksAnnouncement() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
Dialog.useAutoOpen(control)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Introducing saved posts AKA bookmarks`)}
|
||||
style={[web({maxWidth: 440})]}
|
||||
contentContainerStyle={[
|
||||
{
|
||||
paddingTop: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
gap: 16,
|
||||
paddingTop: isWeb ? 24 : 40,
|
||||
borderTopLeftRadius: a.rounded_md.borderRadius,
|
||||
borderTopRightRadius: a.rounded_md.borderRadius,
|
||||
},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={[t.palette.primary_25, t.palette.primary_100]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 0, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SparkleIcon fill={t.palette.primary_800} size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
{
|
||||
color: t.palette.primary_800,
|
||||
},
|
||||
]}>
|
||||
<Trans>New Feature</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.w_full,
|
||||
{
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
borderRadius: 24,
|
||||
aspectRatio: 333 / 104,
|
||||
},
|
||||
isWeb
|
||||
? [
|
||||
{
|
||||
boxShadow: `0px 10px 15px -3px ${transparentifyColor(t.palette.black, 0.2)}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
t.atoms.shadow_md,
|
||||
{
|
||||
shadowOpacity: 0.2,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../../assets/images/bookmarks_announcement_nux.webp')}
|
||||
style={[
|
||||
a.w_full,
|
||||
{
|
||||
aspectRatio: 333 / 104,
|
||||
},
|
||||
]}
|
||||
alt={_(
|
||||
msg`A screenshot of a post with a new button next to the share button that allows you to save the post to your bookmarks. The post is from @jcsalterego.bsky.social and reads "inventing a saturday that immediately follows monday".`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
|
||||
<View style={[a.gap_sm, a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_3xl,
|
||||
a.leading_tight,
|
||||
a.font_heavy,
|
||||
a.text_center,
|
||||
{
|
||||
fontSize: isWeb ? 28 : 32,
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
<Trans>Saved Posts</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
{
|
||||
maxWidth: 340,
|
||||
},
|
||||
]}>
|
||||
<Trans>
|
||||
Finally! Keep track of posts that matter to you. Save them to
|
||||
revisit anytime.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!isWeb && (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -12,12 +12,11 @@ import {
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useOnboardingState} from '#/state/shell'
|
||||
import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions'
|
||||
import {BookmarksAnnouncement} from '#/components/dialogs/nuxs/BookmarksAnnouncement'
|
||||
/*
|
||||
* NUXs
|
||||
*/
|
||||
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||
import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils'
|
||||
|
||||
type Context = {
|
||||
activeNux: Nux | undefined
|
||||
@@ -34,13 +33,7 @@ const queuedNuxs: {
|
||||
}) => boolean
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.ActivitySubscriptions,
|
||||
enabled: ({currentProfile}) => {
|
||||
return isExistingUserAsOf(
|
||||
'2025-07-07T00:00:00.000Z',
|
||||
currentProfile.createdAt,
|
||||
)
|
||||
},
|
||||
id: Nux.BookmarksAnnouncement,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -180,7 +173,7 @@ function Inner({
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||
{activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />}
|
||||
{activeNux === Nux.BookmarksAnnouncement && <BookmarksAnnouncement />}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
// custom, not part of icon library
|
||||
export const Bookmark = createSinglePathSVG({
|
||||
path: 'M9.7 16.895a4 4 0 0 1 4.6 0l3.7 2.6V6.5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v12.995l3.7-2.6Zm10.3 2.6c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2.001 2.001 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12.995Z',
|
||||
})
|
||||
|
||||
// custom, not part of icon library
|
||||
export const BookmarkFilled = createSinglePathSVG({
|
||||
path: 'M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2.001 2.001 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z',
|
||||
})
|
||||
|
||||
// custom, not part of icon library, for LARGE (64px) size
|
||||
export const BookmarkDeleteLarge = createSinglePathSVG({
|
||||
path: 'M14.2 2.625c.834 0 1.482 0 2.001.042.523.043.949.131 1.331.326.635.324 1.151.84 1.475 1.475.195.382.283.807.326 1.33.042.52.042 1.168.042 2.002v11.09c0 .495 0 .893-.027 1.199-.028.301-.087.585-.26.809-.249.323-.63.518-1.037.533-.282.01-.547-.107-.808-.26-.265-.154-.588-.385-.991-.673l-3.54-2.528c-.36-.258-.461-.322-.559-.347a.626.626 0 0 0-.306 0c-.098.025-.199.09-.559.347l-3.54 2.528c-.403.288-.726.519-.991.674-.261.152-.526.269-.808.259a1.376 1.376 0 0 1-1.038-.534c-.172-.223-.231-.507-.259-.808a7.31 7.31 0 0 1-.024-.528l-.003-.67V7.8c0-.834 0-1.482.042-2.001.043-.523.13-.949.325-1.331a3.376 3.376 0 0 1 1.476-1.475c.382-.195.808-.283 1.33-.326.52-.042 1.168-.042 2.002-.042h4.4Zm-4.4.75c-.846 0-1.458 0-1.94.04-.477.039-.792.114-1.051.246A2.626 2.626 0 0 0 5.66 4.81c-.132.259-.208.574-.247 1.051-.04.482-.039 1.094-.039 1.94v11.09l.003.658c.003.186.01.34.021.473.025.267.07.37.106.418a.626.626 0 0 0 .472.243c.059.002.168-.022.4-.158.23-.133.52-.34.935-.636l3.54-2.529c.308-.22.543-.396.81-.464.222-.056.454-.056.676 0 .267.068.5.244.81.464l3.54 2.529c.414.296.704.503.933.636.233.137.343.16.402.158a.626.626 0 0 0 .472-.243c.036-.048.081-.15.106-.419.024-.263.024-.62.024-1.13V7.8c0-.846 0-1.458-.04-1.94-.039-.477-.114-.792-.246-1.051A2.627 2.627 0 0 0 17.19 3.66c-.259-.132-.575-.207-1.051-.246-.482-.04-1.094-.04-1.94-.04H9.8Zm4.056 4.238a.375.375 0 0 1 .53.53L12.53 10l1.857 1.856a.375.375 0 0 1-.53.53L12 10.53l-1.856 1.857a.375.375 0 0 1-.53-.53L11.47 10 9.613 8.144a.375.375 0 0 1 .53-.53L12 9.47l1.856-1.857Z',
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
// custom, off spec
|
||||
export const Reply = createSinglePathSVG({
|
||||
path: 'M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z',
|
||||
})
|
||||
|
||||
// custom, off spec
|
||||
export const ReplyFilled = createSinglePathSVG({
|
||||
path: 'M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z',
|
||||
})
|
||||
@@ -124,7 +124,6 @@ export const createHitslop = (size: number): Insets => ({
|
||||
export const HITSLOP_10 = createHitslop(10)
|
||||
export const HITSLOP_20 = createHitslop(20)
|
||||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
|
||||
export const LANG_DROPDOWN_HITSLOP = {top: 10, bottom: 10, left: 4, right: 4}
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
@@ -10,9 +10,9 @@ import {i18n} from '@lingui/core'
|
||||
|
||||
import {decideShouldRoll} from '#/lib/custom-animations/util'
|
||||
import {s} from '#/lib/styles'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {formatPostStatCount} from '#/components/PostControls/util'
|
||||
|
||||
const animationConfig = {
|
||||
duration: 400,
|
||||
@@ -92,11 +92,13 @@ export function CountWheel({
|
||||
big,
|
||||
isLiked,
|
||||
hasBeenToggled,
|
||||
compactCount,
|
||||
}: {
|
||||
likeCount: number
|
||||
big?: boolean
|
||||
isLiked: boolean
|
||||
hasBeenToggled: boolean
|
||||
compactCount?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const shouldAnimate = !useReducedMotion() && hasBeenToggled
|
||||
@@ -109,8 +111,12 @@ export function CountWheel({
|
||||
const [key, setKey] = React.useState(0)
|
||||
const [prevCount, setPrevCount] = React.useState(likeCount)
|
||||
const prevIsLiked = React.useRef(isLiked)
|
||||
const formattedCount = formatCount(i18n, likeCount)
|
||||
const formattedPrevCount = formatCount(i18n, prevCount)
|
||||
const formattedCount = formatPostStatCount(i18n, likeCount, {
|
||||
compact: compactCount,
|
||||
})
|
||||
const formattedPrevCount = formatPostStatCount(i18n, prevCount, {
|
||||
compact: compactCount,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLiked === prevIsLiked.current) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useNavigationTabState() {
|
||||
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
|
||||
// FeedsTab no longer exists, but this check works for `Feeds` screen as well
|
||||
isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
|
||||
isAtBookmarks: getTabState(state, 'Bookmarks') !== TabState.Outside,
|
||||
isAtNotifications:
|
||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
||||
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
|
||||
|
||||
@@ -86,6 +86,7 @@ export type CommonNavigatorParams = {
|
||||
}
|
||||
StarterPackEdit: {rkey?: string}
|
||||
VideoFeed: VideoFeedSourceContext
|
||||
Bookmarks: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
||||
@@ -238,6 +238,14 @@ export type MetricEvents = {
|
||||
'post:unmute': {}
|
||||
'post:pin': {}
|
||||
'post:unpin': {}
|
||||
'post:bookmark': {
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
}
|
||||
'post:unbookmark': {
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
}
|
||||
'bookmarks:view': {}
|
||||
'bookmarks:post-clicked': {}
|
||||
'profile:follow': {
|
||||
didBecomeMutual: boolean | undefined
|
||||
followeeClout: number | undefined
|
||||
|
||||
@@ -90,4 +90,5 @@ export const router = new Router<AllNavigatableRoutes>({
|
||||
StarterPackShort: '/starter-pack-short/:code',
|
||||
StarterPackWizard: '/starter-pack/create',
|
||||
VideoFeed: '/video-feed',
|
||||
Bookmarks: '/saved',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ButtonText} from '#/components/Button'
|
||||
import {BookmarkDeleteLarge} from '#/components/icons/Bookmark'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function EmptyState() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
{
|
||||
paddingVertical: 64,
|
||||
},
|
||||
]}>
|
||||
<BookmarkDeleteLarge
|
||||
width={64}
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
<View style={[a.pt_sm]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.font_medium,
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Nothing saved yet</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.pt_2xl]}>
|
||||
<Link
|
||||
to="/"
|
||||
action="navigate"
|
||||
label={_(
|
||||
msg({
|
||||
message: `Go home`,
|
||||
context: `Button to go back to the home timeline`,
|
||||
}),
|
||||
)}
|
||||
size="small"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans context="Button to go back to the home timeline">
|
||||
Go home
|
||||
</Trans>
|
||||
</ButtonText>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyBookmarkDefs,
|
||||
AppBskyFeedDefs,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {
|
||||
type CommonNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
|
||||
import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {Post} from '#/view/com/post/Post'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {EmptyState} from '#/screens/Bookmarks/components/EmptyState'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {BookmarkFilled} from '#/components/icons/Bookmark'
|
||||
import {CircleQuestion_Stroke2_Corner2_Rounded as QuestionIcon} from '#/components/icons/CircleQuestion'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
import * as toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'>
|
||||
|
||||
export function BookmarksScreen({}: Props) {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
logger.metric('bookmarks:view', {})
|
||||
}, [setMinimalShellMode]),
|
||||
)
|
||||
|
||||
return (
|
||||
<Layout.Screen testID="bookmarksScreen">
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Saved Posts</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<BookmarksInner />
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
type ListItem =
|
||||
| {
|
||||
type: 'loading'
|
||||
key: 'loading'
|
||||
}
|
||||
| {
|
||||
type: 'empty'
|
||||
key: 'empty'
|
||||
}
|
||||
| {
|
||||
type: 'bookmark'
|
||||
key: string
|
||||
bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & {
|
||||
item: $Typed<AppBskyFeedDefs.PostView>
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'bookmarkNotFound'
|
||||
key: string
|
||||
bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & {
|
||||
item: $Typed<AppBskyFeedDefs.NotFoundPost>
|
||||
}
|
||||
}
|
||||
|
||||
function BookmarksInner() {
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const cleanError = useCleanError()
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = useBookmarksQuery()
|
||||
const cleanedError = useMemo(() => {
|
||||
const {raw, clean} = cleanError(error)
|
||||
return clean || raw
|
||||
}, [error, cleanError])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await refetch()
|
||||
} finally {
|
||||
setIsPTRing(false)
|
||||
}
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetchingNextPage || !hasNextPage || error) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch {}
|
||||
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||
|
||||
const items = useMemo(() => {
|
||||
const i: ListItem[] = []
|
||||
|
||||
if (isLoading) {
|
||||
i.push({type: 'loading', key: 'loading'})
|
||||
} else if (error || !data) {
|
||||
// handled in Footer
|
||||
} else {
|
||||
const bookmarks = data.pages.flatMap(p => p.bookmarks)
|
||||
|
||||
if (bookmarks.length > 0) {
|
||||
for (const bookmark of bookmarks) {
|
||||
if (AppBskyFeedDefs.isNotFoundPost(bookmark.item)) {
|
||||
i.push({
|
||||
type: 'bookmarkNotFound',
|
||||
key: bookmark.item.uri,
|
||||
bookmark: {
|
||||
...bookmark,
|
||||
item: bookmark.item as $Typed<AppBskyFeedDefs.NotFoundPost>,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (AppBskyFeedDefs.isPostView(bookmark.item)) {
|
||||
i.push({
|
||||
type: 'bookmark',
|
||||
key: bookmark.item.uri,
|
||||
bookmark: {
|
||||
...bookmark,
|
||||
item: bookmark.item as $Typed<AppBskyFeedDefs.PostView>,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i.push({type: 'empty', key: 'empty'})
|
||||
}
|
||||
}
|
||||
|
||||
return i
|
||||
}, [isLoading, error, data])
|
||||
|
||||
const isEmpty = items.length === 1 && items[0]?.type === 'empty'
|
||||
|
||||
return (
|
||||
<List
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanedError}
|
||||
onRetry={fetchNextPage}
|
||||
style={[isEmpty && a.border_t_0]}
|
||||
/>
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={9}
|
||||
maxToRenderPerBatch={isIOS ? 5 : 1}
|
||||
updateCellsBatchingPeriod={40}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkNotFound({
|
||||
hideTopBorder,
|
||||
post,
|
||||
}: {
|
||||
hideTopBorder: boolean
|
||||
post: $Typed<AppBskyFeedDefs.NotFoundPost>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {mutateAsync: bookmark} = useBookmarkMutation()
|
||||
const cleanError = useCleanError()
|
||||
|
||||
const remove = async () => {
|
||||
try {
|
||||
await bookmark({action: 'delete', uri: post.uri})
|
||||
toast.show(_(msg`Removed from saved posts`), {
|
||||
type: 'info',
|
||||
})
|
||||
} catch (e: any) {
|
||||
const {raw, clean} = cleanError(e)
|
||||
toast.show(clean || raw || e, {
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_start,
|
||||
a.px_xl,
|
||||
a.py_lg,
|
||||
a.gap_sm,
|
||||
!hideTopBorder && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Skele.Circle size={42}>
|
||||
<QuestionIcon size="lg" fill={t.atoms.text_contrast_low.color} />
|
||||
</Skele.Circle>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<View style={[a.flex_row, a.gap_xs]}>
|
||||
<Skele.Text style={[a.text_md, {width: 80}]} />
|
||||
<Skele.Text style={[a.text_md, {width: 100}]} />
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.italic,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>This post was deleted by its author</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Remove from saved posts`)}
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
onPress={remove}>
|
||||
<ButtonIcon icon={BookmarkFilled} />
|
||||
<ButtonText>
|
||||
<Trans>Remove</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function renderItem({item, index}: {item: ListItem; index: number}) {
|
||||
switch (item.type) {
|
||||
case 'loading': {
|
||||
return <PostFeedLoadingPlaceholder />
|
||||
}
|
||||
case 'empty': {
|
||||
return <EmptyState />
|
||||
}
|
||||
case 'bookmark': {
|
||||
return (
|
||||
<Post
|
||||
post={item.bookmark.item}
|
||||
hideTopBorder={index === 0}
|
||||
onBeforePress={() => {
|
||||
logger.metric('bookmarks:post-clicked', {})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'bookmarkNotFound': {
|
||||
return (
|
||||
<BookmarkNotFound
|
||||
post={item.bookmark.item}
|
||||
hideTopBorder={index === 0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const keyExtractor = (item: ListItem) => item.key
|
||||
@@ -32,7 +32,6 @@ import {useSession} from '#/state/session'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {type PostSource} from '#/state/unstable-post-source'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
|
||||
import {
|
||||
@@ -53,6 +52,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {PostControls} from '#/components/PostControls'
|
||||
import {formatPostStatCount} from '#/components/PostControls/util'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -415,13 +415,18 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
/>
|
||||
{post.repostCount !== 0 ||
|
||||
post.likeCount !== 0 ||
|
||||
post.quoteCount !== 0 ? (
|
||||
post.quoteCount !== 0 ||
|
||||
post.bookmarkCount !== 0 ? (
|
||||
// Show this section unless we're *sure* it has no engagement.
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.flex_wrap,
|
||||
a.align_center,
|
||||
a.gap_lg,
|
||||
{
|
||||
rowGap: a.gap_sm.gap,
|
||||
columnGap: a.gap_lg.gap,
|
||||
},
|
||||
a.border_t,
|
||||
a.border_b,
|
||||
a.mt_md,
|
||||
@@ -434,7 +439,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
testID="repostCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{formatCount(i18n, post.repostCount)}
|
||||
{formatPostStatCount(i18n, post.repostCount)}
|
||||
</Text>{' '}
|
||||
<Plural
|
||||
value={post.repostCount}
|
||||
@@ -452,7 +457,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
testID="quoteCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{formatCount(i18n, post.quoteCount)}
|
||||
{formatPostStatCount(i18n, post.quoteCount)}
|
||||
</Text>{' '}
|
||||
<Plural
|
||||
value={post.quoteCount}
|
||||
@@ -468,12 +473,28 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
testID="likeCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{formatCount(i18n, post.likeCount)}
|
||||
{formatPostStatCount(i18n, post.likeCount)}
|
||||
</Text>{' '}
|
||||
<Plural value={post.likeCount} one="like" other="likes" />
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
|
||||
<Link to={likesHref} label={_(msg`Saves of this post`)}>
|
||||
<Text
|
||||
testID="bookmarkCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{formatPostStatCount(i18n, post.bookmarkCount)}
|
||||
</Text>{' '}
|
||||
<Plural
|
||||
value={post.bookmarkCount}
|
||||
one="save"
|
||||
other="saves"
|
||||
/>
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View
|
||||
|
||||
@@ -368,6 +368,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
||||
</View>
|
||||
)}
|
||||
<PostControls
|
||||
variant="compact"
|
||||
post={postShadow}
|
||||
record={record}
|
||||
richText={richText}
|
||||
|
||||
Vendored
+16
@@ -25,6 +25,7 @@ export interface PostShadow {
|
||||
embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
|
||||
pinned: boolean
|
||||
optimisticReplyCount: number | undefined
|
||||
bookmarked: boolean | undefined
|
||||
}
|
||||
|
||||
export const POST_TOMBSTONE = Symbol('PostTombstone')
|
||||
@@ -92,6 +93,18 @@ function mergeShadow(
|
||||
likeCount = Math.max(0, likeCount)
|
||||
}
|
||||
|
||||
let bookmarkCount = post.bookmarkCount ?? 0
|
||||
if ('bookmarked' in shadow) {
|
||||
const wasBookmarked = !!post.viewer?.bookmarked
|
||||
const isBookmarked = !!shadow.bookmarked
|
||||
if (wasBookmarked && !isBookmarked) {
|
||||
bookmarkCount--
|
||||
} else if (!wasBookmarked && isBookmarked) {
|
||||
bookmarkCount++
|
||||
}
|
||||
bookmarkCount = Math.max(0, bookmarkCount)
|
||||
}
|
||||
|
||||
let repostCount = post.repostCount ?? 0
|
||||
if ('repostUri' in shadow) {
|
||||
const wasReposted = !!post.viewer?.repost
|
||||
@@ -127,11 +140,14 @@ function mergeShadow(
|
||||
likeCount: likeCount,
|
||||
repostCount: repostCount,
|
||||
replyCount: replyCount,
|
||||
bookmarkCount: bookmarkCount,
|
||||
viewer: {
|
||||
...(post.viewer || {}),
|
||||
like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
|
||||
repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost,
|
||||
pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned,
|
||||
bookmarked:
|
||||
'bookmarked' in shadow ? shadow.bookmarked : post.viewer?.bookmarked,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {updatePostShadow} from '#/state/cache/post-shadow'
|
||||
import {
|
||||
optimisticallyDeleteBookmark,
|
||||
optimisticallySaveBookmark,
|
||||
} from '#/state/queries/bookmarks/useBookmarksQuery'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
type MutationArgs =
|
||||
| {action: 'create'; post: AppBskyFeedDefs.PostView}
|
||||
| {
|
||||
action: 'delete'
|
||||
/**
|
||||
* For deletions, we only need to URI. Plus, in some cases we only know the
|
||||
* URI, such as when a post was deleted by the author.
|
||||
*/
|
||||
uri: string
|
||||
}
|
||||
|
||||
export function useBookmarkMutation() {
|
||||
const qc = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
async mutationFn(args: MutationArgs) {
|
||||
if (args.action === 'create') {
|
||||
updatePostShadow(qc, args.post.uri, {bookmarked: true})
|
||||
await agent.app.bsky.bookmark.createBookmark({
|
||||
uri: args.post.uri,
|
||||
cid: args.post.cid,
|
||||
})
|
||||
} else if (args.action === 'delete') {
|
||||
updatePostShadow(qc, args.uri, {bookmarked: false})
|
||||
await agent.app.bsky.bookmark.deleteBookmark({
|
||||
uri: args.uri,
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess(_, args) {
|
||||
if (args.action === 'create') {
|
||||
optimisticallySaveBookmark(qc, args.post)
|
||||
} else if (args.action === 'delete') {
|
||||
optimisticallyDeleteBookmark(qc, {uri: args.uri})
|
||||
}
|
||||
},
|
||||
onError(e, args) {
|
||||
if (args.action === 'create') {
|
||||
updatePostShadow(qc, args.post.uri, {bookmarked: false})
|
||||
} else if (args.action === 'delete') {
|
||||
updatePostShadow(qc, args.uri, {bookmarked: true})
|
||||
}
|
||||
|
||||
if (!isNetworkError(e)) {
|
||||
logger.error('bookmark mutation failed', {
|
||||
bookmarkAction: args.action,
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyBookmarkGetBookmarks,
|
||||
type AppBskyFeedDefs,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
export const bookmarksQueryKeyRoot = 'bookmarks'
|
||||
export const createBookmarksQueryKey = () => [bookmarksQueryKeyRoot]
|
||||
|
||||
export function useBookmarksQuery() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery<
|
||||
AppBskyBookmarkGetBookmarks.OutputSchema,
|
||||
Error,
|
||||
InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>,
|
||||
QueryKey,
|
||||
string | undefined
|
||||
>({
|
||||
queryKey: createBookmarksQueryKey(),
|
||||
async queryFn({pageParam}) {
|
||||
const res = await agent.app.bsky.bookmark.getBookmarks({
|
||||
cursor: pageParam,
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: lastPage => lastPage.cursor,
|
||||
})
|
||||
}
|
||||
|
||||
export async function truncateAndInvalidate(qc: QueryClient) {
|
||||
qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>(
|
||||
{queryKey: [bookmarksQueryKeyRoot]},
|
||||
data => {
|
||||
if (data) {
|
||||
return {
|
||||
pageParams: data.pageParams.slice(0, 1),
|
||||
pages: data.pages.slice(0, 1),
|
||||
}
|
||||
}
|
||||
return data
|
||||
},
|
||||
)
|
||||
return qc.invalidateQueries({queryKey: [bookmarksQueryKeyRoot]})
|
||||
}
|
||||
|
||||
export async function optimisticallySaveBookmark(
|
||||
qc: QueryClient,
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
) {
|
||||
qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>(
|
||||
{
|
||||
queryKey: [bookmarksQueryKeyRoot],
|
||||
},
|
||||
data => {
|
||||
if (!data) return data
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page, index) => {
|
||||
if (index === 0) {
|
||||
post.$type = 'app.bsky.feed.defs#postView'
|
||||
return {
|
||||
...page,
|
||||
bookmarks: [
|
||||
{
|
||||
createdAt: new Date().toISOString(),
|
||||
subject: {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
},
|
||||
item: post as $Typed<AppBskyFeedDefs.PostView>,
|
||||
},
|
||||
...page.bookmarks,
|
||||
],
|
||||
}
|
||||
}
|
||||
return page
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function optimisticallyDeleteBookmark(
|
||||
qc: QueryClient,
|
||||
{uri}: {uri: string},
|
||||
) {
|
||||
qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>(
|
||||
{
|
||||
queryKey: [bookmarksQueryKeyRoot],
|
||||
},
|
||||
data => {
|
||||
if (!data) return data
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map(page => {
|
||||
return {
|
||||
...page,
|
||||
bookmarks: page.bookmarks.filter(b => b.subject.uri !== uri),
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export enum Nux {
|
||||
ActivitySubscriptions = 'ActivitySubscriptions',
|
||||
AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
|
||||
AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
|
||||
BookmarksAnnouncement = 'BookmarksAnnouncement',
|
||||
|
||||
/*
|
||||
* Blocking announcements. New IDs are required for each new announcement.
|
||||
@@ -47,6 +48,10 @@ export type AppNux = BaseNux<
|
||||
id: Nux.PolicyUpdate202508
|
||||
data: undefined
|
||||
}
|
||||
| {
|
||||
id: Nux.BookmarksAnnouncement
|
||||
data: undefined
|
||||
}
|
||||
>
|
||||
|
||||
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
||||
@@ -57,4 +62,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
||||
[Nux.AgeAssuranceDismissibleNotice]: undefined,
|
||||
[Nux.AgeAssuranceDismissibleFeedBanner]: undefined,
|
||||
[Nux.PolicyUpdate202508]: undefined,
|
||||
[Nux.BookmarksAnnouncement]: undefined,
|
||||
}
|
||||
|
||||
@@ -43,11 +43,13 @@ export function Post({
|
||||
showReplyLine,
|
||||
hideTopBorder,
|
||||
style,
|
||||
onBeforePress,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
showReplyLine?: boolean
|
||||
hideTopBorder?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
onBeforePress?: () => void
|
||||
}) {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const record = useMemo<AppBskyFeedPost.Record | undefined>(
|
||||
@@ -85,6 +87,7 @@ export function Post({
|
||||
showReplyLine={showReplyLine}
|
||||
hideTopBorder={hideTopBorder}
|
||||
style={style}
|
||||
onBeforePress={onBeforePress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -99,6 +102,7 @@ function PostInner({
|
||||
showReplyLine,
|
||||
hideTopBorder,
|
||||
style,
|
||||
onBeforePress: outerOnBeforePress,
|
||||
}: {
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
record: AppBskyFeedPost.Record
|
||||
@@ -107,6 +111,7 @@ function PostInner({
|
||||
showReplyLine?: boolean
|
||||
hideTopBorder?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
onBeforePress?: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const pal = usePalette('default')
|
||||
@@ -142,7 +147,8 @@ function PostInner({
|
||||
|
||||
const onBeforePress = useCallback(() => {
|
||||
unstableCacheProfileView(queryClient, post.author)
|
||||
}, [queryClient, post.author])
|
||||
outerOnBeforePress?.()
|
||||
}, [queryClient, post.author, outerOnBeforePress])
|
||||
|
||||
const [hover, setHover] = useState(false)
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Bell_Filled_Corner0_Rounded as BellFilled,
|
||||
Bell_Stroke2_Corner0_Rounded as Bell,
|
||||
} from '#/components/icons/Bell'
|
||||
import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
|
||||
import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList'
|
||||
import {
|
||||
Hashtag_Filled_Corner0_Rounded as HashtagFilled,
|
||||
@@ -150,6 +151,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
||||
isAtHome,
|
||||
isAtSearch,
|
||||
isAtFeeds,
|
||||
isAtBookmarks,
|
||||
isAtNotifications,
|
||||
isAtMyProfile,
|
||||
isAtMessages,
|
||||
@@ -231,6 +233,11 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
||||
setDrawerOpen(false)
|
||||
}, [navigation, setDrawerOpen])
|
||||
|
||||
const onPressBookmarks = React.useCallback(() => {
|
||||
navigation.navigate('Bookmarks')
|
||||
setDrawerOpen(false)
|
||||
}, [navigation, setDrawerOpen])
|
||||
|
||||
const onPressSettings = React.useCallback(() => {
|
||||
navigation.navigate('Settings')
|
||||
setDrawerOpen(false)
|
||||
@@ -292,6 +299,10 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
||||
/>
|
||||
<FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} />
|
||||
<ListsMenuItem onPress={onPressLists} />
|
||||
<BookmarksMenuItem
|
||||
isActive={isAtBookmarks}
|
||||
onPress={onPressBookmarks}
|
||||
/>
|
||||
<ProfileMenuItem
|
||||
isActive={isAtMyProfile}
|
||||
onPress={onPressProfile}
|
||||
@@ -538,6 +549,32 @@ let ListsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => {
|
||||
}
|
||||
ListsMenuItem = React.memo(ListsMenuItem)
|
||||
|
||||
let BookmarksMenuItem = ({
|
||||
isActive,
|
||||
onPress,
|
||||
}: {
|
||||
isActive: boolean
|
||||
onPress: () => void
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
icon={
|
||||
isActive ? (
|
||||
<BookmarkFilled style={[t.atoms.text]} width={iconWidth} />
|
||||
) : (
|
||||
<Bookmark style={[t.atoms.text]} width={iconWidth} />
|
||||
)
|
||||
}
|
||||
label={_(msg`Saved`)}
|
||||
onPress={onPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
BookmarksMenuItem = React.memo(BookmarksMenuItem)
|
||||
|
||||
let ProfileMenuItem = ({
|
||||
isActive,
|
||||
onPress,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Bell_Filled_Corner0_Rounded as BellFilled,
|
||||
Bell_Stroke2_Corner0_Rounded as Bell,
|
||||
} from '#/components/icons/Bell'
|
||||
import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
|
||||
import {
|
||||
BulletList_Filled_Corner0_Rounded as ListFilled,
|
||||
BulletList_Stroke2_Corner0_Rounded as List,
|
||||
@@ -743,6 +744,24 @@ export function DesktopLeftNav() {
|
||||
}
|
||||
label={_(msg`Lists`)}
|
||||
/>
|
||||
<NavItem
|
||||
href="/saved"
|
||||
icon={
|
||||
<Bookmark
|
||||
style={pal.text}
|
||||
aria-hidden={true}
|
||||
width={NAV_ICON_WIDTH}
|
||||
/>
|
||||
}
|
||||
iconFilled={
|
||||
<BookmarkFilled
|
||||
style={pal.text}
|
||||
aria-hidden={true}
|
||||
width={NAV_ICON_WIDTH}
|
||||
/>
|
||||
}
|
||||
label={_(msg`Saved`)}
|
||||
/>
|
||||
<NavItem
|
||||
href={currentAccount ? makeProfileLink(currentAccount) : '/'}
|
||||
icon={
|
||||
|
||||
@@ -63,20 +63,6 @@
|
||||
"@atproto/xrpc" "^0.7.3"
|
||||
"@atproto/xrpc-server" "^0.9.3"
|
||||
|
||||
"@atproto/api@^0.16.2":
|
||||
version "0.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.2.tgz#1b2870e9a03d88f00a27602281755fa82ec824dd"
|
||||
integrity sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.2"
|
||||
"@atproto/lexicon" "^0.4.12"
|
||||
"@atproto/syntax" "^0.4.0"
|
||||
"@atproto/xrpc" "^0.7.1"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/api@^0.16.4":
|
||||
version "0.16.4"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.4.tgz#952071aca39a731b1664dc3ea4385fa2fb8e4c62"
|
||||
@@ -91,6 +77,20 @@
|
||||
tlds "^1.234.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/api@^0.16.7":
|
||||
version "0.16.7"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.7.tgz#eb0c520dbdaf74ba6f5ad7f9c6afe2d1389b8a0a"
|
||||
integrity sha512-EdVWkEgaEQm1LEiiP1fW/XXXpMNmtvT5c9+cZVRiwYc4rTB66WIJJWqmaMT/tB7nccMkFjr6FtwObq5LewWfgw==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.2"
|
||||
"@atproto/lexicon" "^0.5.0"
|
||||
"@atproto/syntax" "^0.4.1"
|
||||
"@atproto/xrpc" "^0.7.4"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/aws@^0.2.27":
|
||||
version "0.2.27"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.27.tgz#bcec6a55ec616952ecfd8863c799cc03f0a879a2"
|
||||
@@ -293,17 +293,6 @@
|
||||
multiformats "^9.9.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/lexicon@^0.4.12":
|
||||
version "0.4.12"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.12.tgz#89a704789d983f8405a52095769b5b58d87f5af7"
|
||||
integrity sha512-fcEvEQ1GpQYF5igZ4IZjPWEoWVpsEF22L9RexxLS3ptfySXLflEyH384e7HITzO/73McDeaJx3lqHIuqn9ulnw==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.2"
|
||||
"@atproto/syntax" "^0.4.0"
|
||||
iso-datestring-validator "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/lexicon@^0.4.14":
|
||||
version "0.4.14"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.14.tgz#a2b5f2bb950d41e78d18f276a01d71b5d89183d8"
|
||||
@@ -315,6 +304,17 @@
|
||||
multiformats "^9.9.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/lexicon@^0.5.0":
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.5.0.tgz#4d2be425361f9ac7f9754b8a1ccba29ddf0b9460"
|
||||
integrity sha512-3aAzEAy9EAPs3CxznzMhEcqDd7m3vz1eze/ya9/ThbB7yleqJIhz5GY2q76tCCwHPhn5qDDMhlA9kKV6fG23gA==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.2"
|
||||
"@atproto/syntax" "^0.4.1"
|
||||
iso-datestring-validator "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/oauth-provider-api@0.2.1":
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.2.1.tgz#27f4a20fb844a6e2686942b8e3f0c80df4008622"
|
||||
@@ -495,6 +495,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2"
|
||||
integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA==
|
||||
|
||||
"@atproto/syntax@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.1.tgz#f77bc610ae0914449ff3f4731861e3da429915f5"
|
||||
integrity sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==
|
||||
|
||||
"@atproto/xrpc-server@^0.9.3":
|
||||
version "0.9.3"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.9.3.tgz#45877ca9432c61294b8b7b1ba7a2430add327f82"
|
||||
@@ -513,14 +518,6 @@
|
||||
ws "^8.12.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/xrpc@^0.7.1":
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.1.tgz#51a8fc131eb21bd1229129d0a46384accc50ad65"
|
||||
integrity sha512-ANHEzlskYlMEdH18m+Itp3a8d0pEJao2qoDybDoMupTnoeNkya4VKIaOgAi6ERQnqatBBZyn9asW+7rJmSt/8g==
|
||||
dependencies:
|
||||
"@atproto/lexicon" "^0.4.12"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/xrpc@^0.7.3":
|
||||
version "0.7.3"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.3.tgz#e93692326b765426e1e2cca811a668fb7d67303c"
|
||||
@@ -529,6 +526,14 @@
|
||||
"@atproto/lexicon" "^0.4.14"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/xrpc@^0.7.4":
|
||||
version "0.7.4"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.4.tgz#030342548797c1f344968c457a8659dbb60a2d60"
|
||||
integrity sha512-sDi68+QE1XHegTaNAndlX41Gp827pouSzSs8CyAwhrqZdsJUxE3P7TMtrA0z+zAjvxVyvzscRc0TsN/fGUGrhw==
|
||||
dependencies:
|
||||
"@atproto/lexicon" "^0.5.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@aws-crypto/crc32@3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"
|
||||
|
||||
Reference in New Issue
Block a user