ENH - expose Feather bookmarks on profiles

This commit is contained in:
smx.pusha
2026-08-10 07:33:19 +02:00
parent 79e821632c
commit 88deacb892
8 changed files with 131 additions and 7 deletions
+30
View File
@@ -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()
+29 -1
View File
@@ -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<boolean> {
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<boolean> {
const response = await nuiCall<FeatherThread>('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<boolean> {
@@ -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,
)
+2
View File
@@ -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}',
+44 -5
View File
@@ -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<void> {
await feather.markActivities()
}
if (next === 'profile' && feather.profile) {
profileView.value = 'posts'
await feather.loadProfile(feather.profile.id)
}
}
async function selectProfileView(next: ProfileView): Promise<void> {
profileView.value = next
if (next === 'bookmarks') await feather.loadBookmarks()
}
async function setFeedMode(mode: 'for-you' | 'following'): Promise<void> {
await feather.loadFeed(mode)
}
@@ -518,7 +526,10 @@ async function publishThreadReply(): Promise<void> {
}
async function openProfile(profileId: number): Promise<void> {
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<void> {
@@ -1391,7 +1402,7 @@ onMounted(async () => {
type="button"
:active="profileView === 'posts'"
:class="{ 'is-active': profileView === 'posts' }"
@click="profileView = 'posts'"
@click="selectProfileView('posts')"
>
<AlignLeft :size="16" /> {{ t('posts') }}
</kSegmentedButton>
@@ -1399,7 +1410,7 @@ onMounted(async () => {
type="button"
:active="profileView === 'replies'"
:class="{ 'is-active': profileView === 'replies' }"
@click="profileView = 'replies'"
@click="selectProfileView('replies')"
>
<AtSign :size="17" />
</kSegmentedButton>
@@ -1407,13 +1418,41 @@ onMounted(async () => {
type="button"
:active="profileView === 'media'"
:class="{ 'is-active': profileView === 'media' }"
@click="profileView = 'media'"
@click="selectProfileView('media')"
>
<Video :size="17" />
</kSegmentedButton>
<kSegmentedButton
v-if="activeProfile.is_owner"
type="button"
:active="profileView === 'bookmarks'"
:class="{ 'is-active': profileView === 'bookmarks' }"
:aria-label="t('bookmarks')"
:title="t('bookmarks')"
@click="selectProfileView('bookmarks')"
>
<Bookmark :size="17" />
</kSegmentedButton>
</kSegmented>
<div
v-if="profileView === 'bookmarks' && feather.bookmarksLoading"
class="feather-network-loading"
>
<kPreloader />
</div>
<div
v-else-if="
profileView === 'bookmarks' && !displayedProfilePosts.length
"
class="feather-empty"
>
<Bookmark :size="34" />
<h2>{{ t('noBookmarks') }}</h2>
<p>{{ t('noBookmarksBody') }}</p>
</div>
<FeatherPostCard
v-for="post in displayedProfilePosts"
v-show="profileView !== 'bookmarks' || !feather.bookmarksLoading"
:key="post.id"
:post="post"
@follow="feather.followPost"