📓 Bookmarks (#8976)

* Add button to controls, respace

* Hook up shadow and mutation

* Add Bookmarks screen

* Build out Bookmarks screen

* Handle removals via shadow

* Use truncateAndInvalidate strategy

* Add empty state

* Add toasts

* Add undo buttons to toasts

* Stage NUX, needs image

* Finesse post controls

* New reply icon

* Use curvier variant of repost icon

* Prevent layout shift with align_start

* Update api pkg

* Swap in new image

* Limit spacing on desktop

* Rm decimals over 10k

* Better optimistic adding/removing

* Add metrics

* Comment

* Remove unused code block

* Remove debug limit

* Fork shadow for web/native

* Tweak alt

* add preventExpansion: true

* Refine hitslop

* Add count to anchor

* Reduce space in compact mode

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Eric Bailey
2025-09-04 17:30:15 -05:00
committed by GitHub
parent 04b869714e
commit 535d4d6cf7
38 changed files with 1251 additions and 163 deletions
@@ -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 -3
View File
@@ -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>
)
+128 -96
View File
@@ -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>
+24
View File
@@ -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>
)
}
+3 -10
View File
@@ -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>
)
}
+16
View File
@@ -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',
})
+11
View File
@@ -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',
})