diff --git a/frontend/src/stores/feather.test.ts b/frontend/src/stores/feather.test.ts index 939e017..d6613d5 100644 --- a/frontend/src/stores/feather.test.ts +++ b/frontend/src/stores/feather.test.ts @@ -129,6 +129,36 @@ describe('Feather store', () => { expect(store.connections).toEqual([profile]) }) + it('loads bookmarked posts for the owner profile', async () => { + const bookmarkedPost = { ...post, is_bookmarked: true } + vi.mocked(nuiCall).mockResolvedValue({ + success: true, + data: { items: [bookmarkedPost] }, + }) + const store = useFeatherStore() + + expect(await store.loadBookmarks()).toBe(true) + expect(nuiCall).toHaveBeenCalledWith('feather:bookmarks') + expect(store.bookmarkedPosts).toEqual([bookmarkedPost]) + expect(store.bookmarksLoading).toBe(false) + }) + + it('removes an unbookmarked post from the saved profile tab', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true }) + const store = useFeatherStore() + const bookmarkedPost = { ...post, is_bookmarked: true } + store.bookmarkedPosts = [bookmarkedPost] + + await store.react(bookmarkedPost, 'bookmark') + + expect(store.bookmarkedPosts).toEqual([]) + expect(nuiCall).toHaveBeenCalledWith('feather:react', { + active: false, + id: bookmarkedPost.id, + kind: 'bookmark', + }) + }) + it('removes an owned connection and updates the profile count', async () => { vi.mocked(nuiCall).mockResolvedValue({ success: true }) const store = useFeatherStore() diff --git a/frontend/src/stores/feather.ts b/frontend/src/stores/feather.ts index 8e16f99..fd8fdc2 100644 --- a/frontend/src/stores/feather.ts +++ b/frontend/src/stores/feather.ts @@ -17,6 +17,8 @@ export type FeatherConnectionMode = 'followers' | 'following' export const useFeatherStore = defineStore('feather', { state: () => ({ activities: [] as FeatherActivity[], + bookmarkedPosts: [] as FeatherPost[], + bookmarksLoading: false, connectionLoading: false, connections: [] as FeatherProfile[], exploreLoading: false, @@ -135,7 +137,14 @@ export const useFeatherStore = defineStore('feather', { id: post.id, kind, }) - if (response.success) return + if (response.success) { + if (kind === 'bookmark') { + this.bookmarkedPosts = next + ? [post, ...this.bookmarkedPosts.filter((item) => item.id !== post.id)] + : this.bookmarkedPosts.filter((item) => item.id !== post.id) + } + return + } post[key] = !next if (kind === 'like') post.like_count += next ? -1 : 1 }, @@ -163,6 +172,7 @@ export const useFeatherStore = defineStore('feather', { ...this.feed, ...this.explorePosts, ...this.profilePosts, + ...this.bookmarkedPosts, ]) { if (post.profile_id === profile.id) post.is_following = next } @@ -178,6 +188,7 @@ export const useFeatherStore = defineStore('feather', { ...this.feed, ...this.explorePosts, ...this.profilePosts, + ...this.bookmarkedPosts, ]) { if (item.profile_id === post.profile_id) item.is_following = next } @@ -222,6 +233,7 @@ export const useFeatherStore = defineStore('feather', { ...this.feed, ...this.explorePosts, ...this.profilePosts, + ...this.bookmarkedPosts, ]) { if (post.profile_id === target.id) post.is_following = false } @@ -237,6 +249,16 @@ export const useFeatherStore = defineStore('feather', { this.profilePosts = response.data.posts return true }, + async loadBookmarks(): Promise { + this.bookmarksLoading = true + const response = await nuiCall<{ items: FeatherPost[] }>( + 'feather:bookmarks', + ) + this.bookmarksLoading = false + if (!response.success || !response.data) return false + this.bookmarkedPosts = response.data.items + return true + }, async loadThread(id: string): Promise { const response = await nuiCall('feather:thread', { id }) if (!response.success || !response.data) return false @@ -258,6 +280,9 @@ export const useFeatherStore = defineStore('feather', { this.feed = this.feed.filter((post) => post.id !== id) this.explorePosts = this.explorePosts.filter((post) => post.id !== id) this.profilePosts = this.profilePosts.filter((post) => post.id !== id) + this.bookmarkedPosts = this.bookmarkedPosts.filter( + (post) => post.id !== id, + ) return true }, async blockProfile(profileId: number): Promise { @@ -267,6 +292,9 @@ export const useFeatherStore = defineStore('feather', { this.explorePosts = this.explorePosts.filter( (post) => post.profile_id !== profileId, ) + this.bookmarkedPosts = this.bookmarkedPosts.filter( + (post) => post.profile_id !== profileId, + ) this.networkResults = this.networkResults.filter( (profile) => profile.id !== profileId, ) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index f41ef59..99e68e9 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -548,6 +548,8 @@ const defaultLocales: LocaleTree = { noReplies: 'No replies yet. Start the conversation.', likes: 'Likes', bookmarks: 'Bookmarks', + noBookmarks: 'No saved posts yet', + noBookmarksBody: 'Posts you bookmark will appear here.', delete: 'Delete', report: 'Report', block: 'Block @{handle}', diff --git a/frontend/src/views/apps/FeatherApp.vue b/frontend/src/views/apps/FeatherApp.vue index 2d0869a..fc58204 100644 --- a/frontend/src/views/apps/FeatherApp.vue +++ b/frontend/src/views/apps/FeatherApp.vue @@ -3,6 +3,7 @@ import { AlignLeft, AtSign, Bell, + Bookmark, Camera, CalendarDays, CheckCircle2, @@ -78,7 +79,7 @@ type Screen = | 'connections' type ExploreView = 'explore' | 'trending' | 'news' type ActivityView = 'all' | 'mentions' -type ProfileView = 'posts' | 'replies' | 'media' +type ProfileView = 'posts' | 'replies' | 'media' | 'bookmarks' type SelectedPhoto = { id: number; url: string } type ComposerContext = { body: string @@ -141,6 +142,7 @@ const displayedActivities = computed(() => : feather.activities, ) const displayedProfilePosts = computed(() => { + if (profileView.value === 'bookmarks') return feather.bookmarkedPosts if (profileView.value === 'replies') return feather.profilePosts.filter((post) => Boolean(post.reply_to_id)) if (profileView.value === 'media') @@ -354,10 +356,16 @@ async function selectTab(next: Tab): Promise { await feather.markActivities() } if (next === 'profile' && feather.profile) { + profileView.value = 'posts' await feather.loadProfile(feather.profile.id) } } +async function selectProfileView(next: ProfileView): Promise { + profileView.value = next + if (next === 'bookmarks') await feather.loadBookmarks() +} + async function setFeedMode(mode: 'for-you' | 'following'): Promise { await feather.loadFeed(mode) } @@ -518,7 +526,10 @@ async function publishThreadReply(): Promise { } async function openProfile(profileId: number): Promise { - if (await feather.loadProfile(profileId)) screen.value = 'profile' + if (await feather.loadProfile(profileId)) { + profileView.value = 'posts' + screen.value = 'profile' + } } async function followProfile(profile: FeatherProfile): Promise { @@ -1391,7 +1402,7 @@ onMounted(async () => { type="button" :active="profileView === 'posts'" :class="{ 'is-active': profileView === 'posts' }" - @click="profileView = 'posts'" + @click="selectProfileView('posts')" > {{ t('posts') }} @@ -1399,7 +1410,7 @@ onMounted(async () => { type="button" :active="profileView === 'replies'" :class="{ 'is-active': profileView === 'replies' }" - @click="profileView = 'replies'" + @click="selectProfileView('replies')" > @@ -1407,13 +1418,41 @@ onMounted(async () => { type="button" :active="profileView === 'media'" :class="{ 'is-active': profileView === 'media' }" - @click="profileView = 'media'" + @click="selectProfileView('media')" >