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"
+11
View File
@@ -2673,6 +2673,17 @@ app.post('/api/:endpoint', (request, response) => {
})
return
}
if (endpoint === 'feather:bookmarks') {
response.json({
success: true,
data: {
items: featherPosts.filter((post) => post.is_bookmarked),
offset: 0,
hasMore: false,
},
})
return
}
if (endpoint === 'feather:thread') {
const post = featherPosts.find((item) => item.id === request.body.id)
response.json(
+1 -1
View File
@@ -211,7 +211,7 @@ Locales["en"] = {
trendKinds = { explore = "Trending in Los Santos", trending = "Popular right now", news = "News · Trending", sports = "Sports · Trending", entertainment = "Entertainment · Trending" },
people = "Who to follow", posts = "Posts", followers = "Followers", followingCount = "Following", removeConnection = "Remove", followerRemoved = "Follower removed.", followingRemoved = "Account unfollowed.", noConnections = "Nobody here yet", noConnectionsBody = "Profiles will appear here as connections are made.", follow = "Follow", unfollow = "Following",
editProfile = "Edit profile", saveProfile = "Save", joined = "Joined Feather", joinedDate = "Joined August 2026", shareProfile = "Share profile", profileCopied = "Profile handle copied.", postCopied = "Post copied.", verified = "Verified profile", noBio = "No bio yet.", showMore = "Show more",
replies = "Replies", noReplies = "No replies yet. Start the conversation.", likes = "Likes", bookmarks = "Bookmarks", delete = "Delete", report = "Report", block = "Block @{handle}",
replies = "Replies", 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}",
reportTitle = "Report feather", reportBody = "Tell us why this feather should be reviewed.", reportSubmit = "Submit report", reported = "Report submitted.", blocked = "Profile blocked.", deleted = "Feather deleted.",
reasons = { spam = "Spam", harassment = "Harassment", dangerous = "Dangerous content", illegal = "Illegal content", other = "Other" },
noActivity = "No activity yet", activityBody = "Likes, replies and new followers will appear here.",
+1
View File
@@ -124,6 +124,7 @@ local server_callbacks = {
"feather:connections",
"feather:remove-connection",
"feather:profile",
"feather:bookmarks",
"feather:thread",
"feather:activities",
"feather:mark-activities",
+13
View File
@@ -552,6 +552,19 @@ Bridge.Callbacks.Register("sky_phone:feather:profile", function(source, data)
} }
end)
Bridge.Callbacks.Register("sky_phone:feather:bookmarks", function(source)
local profile, error_response = require_profile(source)
if not profile then return error_response end
local bookmarked = list_posts(
tonumber(profile.id),
"EXISTS(SELECT 1 FROM `sky_phone_feather_reactions` saved WHERE saved.`profile_id` = ? AND saved.`post_id` = fp.`id` AND saved.`kind` = 'bookmark')",
{ profile.id },
"fp.`created_at` DESC",
0
)
return { success = true, data = bookmarked }
end)
Bridge.Callbacks.Register("sky_phone:feather:thread", function(source, data)
local profile, error_response = require_profile(source)
if not profile then return error_response end