Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add bookmarks natively to Bluesky #7383

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
"@lexicon-community/types": "^1.0.0",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
Expand Down
34 changes: 34 additions & 0 deletions src/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {buildStateObject} from '#/lib/routes/helpers'
import {
AllNavigatorParams,
BookmarksTabNavigatorParams,
BottomTabNavigatorParams,
FlatNavigatorParams,
HomeTabNavigatorParams,
Expand All @@ -40,6 +41,7 @@ import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from '#/state/shell/reminders'
import {BookmarksScreen} from '#/view/screens/Bookmarks'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugModScreen} from '#/view/screens/DebugMod'
Expand Down Expand Up @@ -108,6 +110,8 @@ const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
const NotificationsTab =
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const BookmarksTab =
createNativeStackNavigatorWithAuth<BookmarksTabNavigatorParams>()
const MyProfileTab =
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
const MessagesTab =
Expand All @@ -129,6 +133,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => NotFoundScreen}
options={{title: title(msg`Not Found`)}}
/>
<Stack.Screen
name="Bookmarks"
getComponent={() => BookmarksScreen}
options={{title: title(msg`Bookmarks`), requireAuth: true}}
/>
<Stack.Screen
name="Lists"
component={ListsScreen}
Expand Down Expand Up @@ -450,6 +459,10 @@ function TabsNavigator() {
name="NotificationsTab"
getComponent={() => NotificationsTabNavigator}
/>
<Tab.Screen
name="BookmarksTab"
getComponent={() => BookmarksTabNavigator}
/>
<Tab.Screen
name="MyProfileTab"
getComponent={() => MyProfileTabNavigator}
Expand Down Expand Up @@ -519,6 +532,27 @@ function NotificationsTabNavigator() {
)
}

function BookmarksTabNavigator() {
const t = useTheme()
return (
<BookmarksTab.Navigator
screenOptions={{
animationDuration: 285,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
contentStyle: t.atoms.bg,
}}>
<BookmarksTab.Screen
name="Bookmarks"
getComponent={() => BookmarksScreen}
options={{requireAuth: true}}
/>
{commonScreens(BookmarksTab as typeof HomeTab)}
</BookmarksTab.Navigator>
)
}

function MyProfileTabNavigator() {
const t = useTheme()
return (
Expand Down
9 changes: 9 additions & 0 deletions src/components/icons/Bookmark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'

export const Bookmark_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M19.5 22.5c-.2 0-.3 0-.5-.1l-6.5-3.7L6 22.4c-.3.2-.7.2-1 0-.3-.2-.5-.5-.5-.9v-18c0-.6.4-1 1-1h14c.6 0 1 .4 1 1v18c0 .4-.2.7-.5.9-.2.1-.3.1-.5.1zm-7-6c.2 0 .3 0 .5.1l5.5 3.1V4.5h-12v15.3l5.5-3.1c.2-.2.3-.2.5-.2z',
})

export const Bookmark_Filled_Corner0_Rounded = createSinglePathSVG({
path: 'M19 22c-.2 0-.3 0-.5-.1L12 18.2l-6.5 3.7c-.3.2-.7.2-1 0-.3-.2-.5-.5-.5-.9V3c0-.6.4-1 1-1h14c.6 0 1 .4 1 1v18c0 .4-.2.7-.5.9-.2.1-.3.1-.5.1z',
})
1 change: 1 addition & 0 deletions src/lib/hooks/useNavigationTabState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function useNavigationTabState() {
isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
isAtNotifications:
getTabState(state, 'Notifications') !== TabState.Outside,
isAtBookmarks: getTabState(state, 'Bookmarks') !== TabState.Outside,
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
}
Expand Down
6 changes: 6 additions & 0 deletions src/lib/routes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
export type CommonNavigatorParams = {
NotFound: undefined
Lists: undefined
Bookmarks: undefined
Moderation: undefined
ModerationModlists: undefined
ModerationMutedAccounts: undefined
Expand Down Expand Up @@ -63,6 +64,7 @@ export type BottomTabNavigatorParams = CommonNavigatorParams & {
HomeTab: undefined
SearchTab: undefined
NotificationsTab: undefined
BookmarksTab: undefined
MyProfileTab: undefined
MessagesTab: undefined
}
Expand All @@ -79,6 +81,10 @@ export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
Notifications: undefined
}

export type BookmarksTabNavigatorParams = CommonNavigatorParams & {
Bookmarks: undefined
}

export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
MyProfile: undefined
}
Expand Down
6 changes: 6 additions & 0 deletions src/lib/statsig/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export type LogEvents = {
postClout: number | undefined
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:bookmark': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:unbookmark': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:repost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
Expand Down
1 change: 1 addition & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const router = new Router({
Search: '/search',
Feeds: '/feeds',
Notifications: '/notifications',
Bookmarks: '/bookmarks',
NotificationSettings: '/notifications/settings',
Settings: '/settings',
Lists: '/lists',
Expand Down
98 changes: 98 additions & 0 deletions src/state/queries/bookmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {useCallback} from 'react'
import {
AppBskyFeedDefs,
AtUri,
ComAtprotoRepoDeleteRecord,
ComAtprotoRepoPutRecord,
} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'

import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
import {LogEvents} from '#/lib/statsig/events'
import {logEvent} from '#/lib/statsig/statsig'
import {Shadow} from '../cache/types'
import {useAgent} from '../session'
import {getBookmarkUri} from './my-bookmarks'

export function usePostBookmarkMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>,
logContext: LogEvents['post:bookmark']['logContext'],
) {
const initialBookmarkUri = getBookmarkUri(post.uri)
const bookmarkMutation = usePostBookmarkMutation(logContext, post)
const unBookmarkMutation = usePostUnBookmarkMutation(logContext)

const queueToggle = useToggleMutationQueue({
initialState: initialBookmarkUri,
runMutation: async (prevBookmarkUri, shouldBookmark) => {
if (shouldBookmark) {
const {data} = await bookmarkMutation.mutateAsync()
return data.uri
} else {
if (prevBookmarkUri) {
await unBookmarkMutation.mutateAsync({
bookmarkUri: prevBookmarkUri,
})
}
return undefined
}
},
onSuccess() {},
})

const queueBookmark = useCallback(() => {
return queueToggle(true)
}, [queueToggle])

const unQueueBookmark = useCallback(() => {
return queueToggle(false)
}, [queueToggle])
return [queueBookmark, unQueueBookmark]
}

function usePostBookmarkMutation(
logContext: LogEvents['post:bookmark']['logContext'],
post: Shadow<AppBskyFeedDefs.PostView>,
) {
const agent = useAgent()
return useMutation<ComAtprotoRepoPutRecord.Response, Error>({
mutationFn: () => {
logEvent('post:bookmark', {
logContext,
})

const record = {
$type: 'community.lexicon.bookmarks.bookmark',
subject: post.uri,
createdAt: new Date().toISOString(),
}
return agent.com.atproto.repo.createRecord({
repo: agent.assertDid,
collection: 'community.lexicon.bookmarks.bookmark',
record,
validate: false,
})
},
})
}

function usePostUnBookmarkMutation(
logContext: LogEvents['post:unbookmark']['logContext'],
) {
const agent = useAgent()
return useMutation<
ComAtprotoRepoDeleteRecord.Response,
Error,
{bookmarkUri: string}
>({
mutationFn: ({bookmarkUri}) => {
logEvent('post:unbookmark', {logContext})
const bookmarkUrip = new AtUri(bookmarkUri)
return agent.com.atproto.repo.deleteRecord({
repo: agent.assertDid,
collection: 'community.lexicon.bookmarks.bookmark',
rkey: bookmarkUrip.rkey,
})
},
})
}
93 changes: 93 additions & 0 deletions src/state/queries/my-bookmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {ensureValidAtUri} from '@atproto/syntax'
import {CommunityLexiconBookmarksBookmark} from '@lexicon-community/types'
import {QueryClient, useQuery, UseQueryResult} from '@tanstack/react-query'

import {accumulate} from '#/lib/async/accumulate'
import {STALE} from '#/state/queries'
import {useAgent, useSession} from '#/state/session'

const RQKEY_ROOT = 'my-bookmarks'
export const RQKEY = () => [RQKEY_ROOT]

const bookmarkMap = new Map<string, string>()

export function useMyBookmarksQuery(): UseQueryResult<
CommunityLexiconBookmarksBookmark.Record[],
Error
> {
const {currentAccount} = useSession()
const agent = useAgent()
return useQuery<CommunityLexiconBookmarksBookmark.Record[]>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(),
async queryFn() {
let bookmarks: CommunityLexiconBookmarksBookmark.Record[] = []
const promises = [
accumulate(cursor =>
agent.com.atproto.repo
.listRecords({
repo: agent!.did ?? '',
collection: 'community.lexicon.bookmarks.bookmark',
cursor,
})
.then(res => ({
cursor: res.data.cursor,
items: res.data.records,
})),
),
]

const resultset = await Promise.all(promises)
for (const res of resultset) {
for (let bookmark of res) {
const isValid =
CommunityLexiconBookmarksBookmark.isRecord(bookmark.value) &&
CommunityLexiconBookmarksBookmark.validateRecord(bookmark.value)
.success
if (isValid) {
const recordVal =
bookmark.value as CommunityLexiconBookmarksBookmark.Record
if (convertAtUriToBlueskyUrl(recordVal.subject) !== null) {
recordVal.uri = bookmark.uri
bookmarks.push(recordVal)
bookmarkMap.set(recordVal.subject, bookmark.uri)
}
}
}
}
return bookmarks
},
enabled: !!currentAccount,
})
}
export function getBookmarkUri(postUri: string): string | undefined {
return bookmarkMap.get(postUri)
}

export function invalidate(qc: QueryClient) {
qc.invalidateQueries({queryKey: [RQKEY_ROOT]})
}

export function addBookmark(postUri: string, bookmarkUri: string): void {
bookmarkMap.set(postUri, bookmarkUri)
}

// Function to remove a bookmark from the map
export function removeBookmark(postUri: string): void {
bookmarkMap.delete(postUri)
}

export const convertAtUriToBlueskyUrl = (subject: string): string | null => {
try {
ensureValidAtUri(subject)
const uriWithoutPrefix = subject.slice(5)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [handle, collection, id] = uriWithoutPrefix.split('/')
if (collection !== 'app.bsky.feed.post') {
return null
}
return subject
} catch (error) {
return null
}
}
Loading