ADD - implement Feather social app

This commit is contained in:
smx.pusha
2026-08-09 22:09:50 +02:00
parent e7eafb207f
commit 5a4a1beed2
23 changed files with 7642 additions and 10 deletions
+31
View File
@@ -33,6 +33,7 @@ import { useMessagesStore } from '@/stores/messages'
import { useDarkChatStore } from '@/stores/darkchat'
import { useFlareStore } from '@/stores/flare'
import { useFlipTokStore } from '@/stores/fliptok'
import { useFeatherStore } from '@/stores/feather'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppStoreStore } from '@/stores/app-store'
@@ -65,6 +66,7 @@ type AppMessage = {
| FlareEventData
| FlipTokVerificationData
| FlipTokNotificationData
| FeatherNotificationData
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
@@ -143,6 +145,14 @@ type FlipTokNotificationData = {
title?: string
videoId?: string
}
type FeatherNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
kind?: 'like' | 'reply' | 'follow' | 'quote'
postId?: string
text?: string
title?: string
}
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
@@ -160,6 +170,7 @@ const messages = useMessagesStore()
const darkchat = useDarkChatStore()
const flare = useFlareStore()
const fliptok = useFlipTokStore()
const feather = useFeatherStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
const appStore = useAppStoreStore()
@@ -333,6 +344,26 @@ function onMessage(event: MessageEvent<AppMessage>): void {
}
notifications.show(notification)
if (phone.isOpen) void fliptok.loadActivities()
} else if (event.data?.type === 'feather:new' && event.data.data) {
const data = event.data.data as FeatherNotificationData
const notification: PhoneNotificationInput = {
appId: 'feather',
subtitle: data.actor,
text: data.text ?? phone.t('Apps.feather.notifications.default'),
title: data.title ?? phone.t('Apps.feather.name'),
}
if (
data.device &&
(!phone.isOpen || data.device.imei !== phone.device?.imei)
) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
preferences: parsePhonePreferences(data.device.settings ?? null),
}
}
notifications.show(notification)
if (phone.isOpen) void feather.loadActivities()
} else if (
event.data?.type === 'marketplace:new-message' &&
event.data.data
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Feather">
<defs>
<linearGradient id="sky" x1="18" y1="14" x2="108" y2="116" gradientUnits="userSpaceOnUse">
<stop stop-color="#71C8FF"/>
<stop offset="0.48" stop-color="#438CF5"/>
<stop offset="1" stop-color="#2757D8"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="5" stdDeviation="5" flood-color="#0B2F82" flood-opacity=".25"/>
</filter>
</defs>
<rect width="128" height="128" rx="30" fill="url(#sky)"/>
<path d="M96.7 28.3C79.9 28 59.4 38.6 48.2 55.8c-8.7 13.3-9.8 27.7-6 36.7l-10 10.2a5 5 0 0 0 7.1 7l10.1-10.3c9 3.2 21.3 1.3 31.7-6.4 14.8-11 22.9-31.9 20.8-59.9-.2-2.7-2.5-4.8-5.2-4.8Z" fill="#fff" filter="url(#shadow)"/>
<path d="M45.5 97.7c12.7-15.3 25.9-28.6 42.4-42.3M54.5 85.8l21.8-.7M66 72.6l1.4-20" fill="none" stroke="#347BE7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1012 B

@@ -0,0 +1,351 @@
<script setup lang="ts">
import {
Bookmark,
CheckCircle2,
Heart,
MessageCircle,
MoreHorizontal,
Share2,
UserRound,
} from 'lucide-vue-next'
import { kButton, kGlass, kIcon } from 'konsta/vue'
import { computed, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import type { FeatherPost } from '@/types/feather'
const props = defineProps<{ post: FeatherPost }>()
defineEmits<{
follow: [post: FeatherPost]
menu: [post: FeatherPost]
open: [post: FeatherPost]
profile: [profileId: number]
react: [post: FeatherPost, kind: 'like' | 'bookmark']
reply: [post: FeatherPost]
share: [post: FeatherPost]
}>()
const phone = usePhoneStore()
const expanded = ref(false)
const shouldTruncate = computed(() => props.post.body.length > 260)
const visibleBody = computed(() =>
shouldTruncate.value && !expanded.value
? `${props.post.body.slice(0, 260).trimEnd()}`
: props.post.body,
)
const leadingMention = computed(
() => visibleBody.value.match(/^@[a-z0-9_]+/i)?.[0] ?? '',
)
const visibleBodyAfterMention = computed(() =>
leadingMention.value
? visibleBody.value.slice(leadingMention.value.length)
: visibleBody.value,
)
function relativeTime(timestamp: number): string {
const seconds = Math.max(1, Math.floor((Date.now() - timestamp) / 1000))
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
return new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
month: 'short',
}).format(timestamp)
}
</script>
<template>
<kGlass :highlight="false" class="feather-post-glass">
<article class="feather-post" @click="$emit('open', post)">
<button
class="feather-avatar"
type="button"
@click.stop="$emit('profile', post.profile_id)"
>
<img v-if="post.avatar_url" :src="post.avatar_url" alt="" />
<UserRound v-else :size="21" />
</button>
<div class="feather-post__body">
<header class="feather-post__header">
<button
class="feather-post__author"
type="button"
@click.stop="$emit('profile', post.profile_id)"
>
<span class="feather-post__name">
<strong>{{ post.display_name }}</strong>
<CheckCircle2
v-if="post.verified"
:size="13"
class="feather-verified"
:aria-label="phone.t('Apps.feather.verified')"
/>
</span>
<span class="feather-post__meta">
@{{ post.handle }} · {{ relativeTime(post.created_at) }}
</span>
</button>
<kButton
v-if="!post.is_owner && !post.is_following"
outline
inline
small
rounded
class="feather-follow"
@click.stop="$emit('follow', post)"
>
{{ phone.t('Apps.feather.follow') }}
</kButton>
<kButton
clear
rounded
small
class="feather-more"
@click.stop="$emit('menu', post)"
>
<kIcon><MoreHorizontal :size="18" /></kIcon>
</kButton>
</header>
<p v-if="post.body" class="feather-post__text">
<span v-if="leadingMention" class="feather-post__mention">{{
leadingMention
}}</span
>{{ visibleBodyAfterMention }}
<button
v-if="shouldTruncate && !expanded"
type="button"
class="feather-post__more-text"
@click.stop="expanded = true"
>
{{ phone.t('Apps.feather.showMore') }}
</button>
</p>
<div
v-if="post.media.length"
class="feather-media"
:class="`feather-media--${Math.min(post.media.length, 4)}`"
>
<img
v-for="item in post.media"
:key="item.id"
:src="item.url"
alt=""
/>
</div>
<footer class="feather-actions">
<button type="button" @click.stop="$emit('reply', post)">
<MessageCircle :size="17" />
<span v-if="post.reply_count">{{ post.reply_count }}</span>
</button>
<button
type="button"
:class="{ 'is-liked': post.is_liked }"
@click.stop="$emit('react', post, 'like')"
>
<Heart :size="17" :fill="post.is_liked ? 'currentColor' : 'none'" />
<span v-if="post.like_count">{{ post.like_count }}</span>
</button>
<button
type="button"
:class="{ 'is-bookmarked': post.is_bookmarked }"
@click.stop="$emit('react', post, 'bookmark')"
>
<Bookmark
:size="17"
:fill="post.is_bookmarked ? 'currentColor' : 'none'"
/>
</button>
<button type="button" @click.stop="$emit('share', post)">
<Share2 :size="17" />
</button>
</footer>
</div>
</article>
</kGlass>
</template>
<style scoped>
.feather-post {
display: flex;
gap: 11px;
padding: 11px 13px 8px;
cursor: pointer;
}
.feather-post-glass {
display: block;
width: 100%;
overflow: hidden;
border-radius: 17px;
}
.feather-post:active {
background: color-mix(in srgb, currentColor 4%, transparent);
}
.feather-avatar {
display: grid;
place-items: center;
width: 42px;
height: 42px;
flex: 0 0 42px;
overflow: hidden;
border: 0;
border-radius: 50%;
color: #fff;
background: linear-gradient(145deg, #72c9ff, #377be7);
}
.feather-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.feather-post__body {
min-width: 0;
flex: 1;
}
.feather-post__header {
display: flex;
align-items: flex-start;
gap: 3px;
min-height: 32px;
}
.feather-post__author {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
border: 0;
padding: 0;
color: inherit;
background: none;
text-align: left;
}
.feather-post__name {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 3px;
}
.feather-post__name strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13.5px;
letter-spacing: -0.12px;
white-space: nowrap;
}
.feather-post__meta {
display: block;
width: 100%;
overflow: hidden;
color: #71767b;
font-size: 11.5px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.feather-verified {
flex: none;
color: #438cf5;
}
.feather-follow {
--k-button-bg-color: transparent;
--k-button-text-color: var(--feather-blue, #1d9bf0);
width: auto;
min-width: 58px;
max-width: 68px;
flex: 0 0 auto;
margin-left: auto;
height: 25px;
border-color: color-mix(
in srgb,
var(--feather-blue, #1d9bf0) 72%,
transparent
);
padding-inline: 9px;
box-shadow: none;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.1px;
}
.feather-more {
flex: 0 0 auto;
margin-left: 0;
width: 27px;
height: 27px;
color: #71767b;
}
.feather-post__text {
margin: 2px 0 8px;
font-size: 13.5px;
line-height: 1.42;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.feather-post__mention {
color: var(--feather-blue, #1d9bf0);
font-weight: 700;
}
.feather-post__more-text {
border: 0;
padding: 0;
color: #1d9bf0;
background: transparent;
font: inherit;
}
.feather-media {
display: grid;
gap: 2px;
max-height: 250px;
margin: 7px 0 8px;
overflow: hidden;
border: 0.5px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 14px;
}
.feather-media img {
width: 100%;
height: 100%;
min-height: 105px;
object-fit: cover;
}
.feather-media--2,
.feather-media--3,
.feather-media--4 {
grid-template-columns: repeat(2, 1fr);
}
.feather-media--3 img:first-child {
grid-row: span 2;
}
.feather-actions {
display: flex;
justify-content: space-between;
margin-top: 1px;
padding-right: 5px;
color: #71767b;
}
.feather-actions button {
display: flex;
align-items: center;
gap: 4px;
min-width: 34px;
border: 0;
padding: 6px 0 4px;
color: inherit;
background: none;
font-size: 10.5px;
}
.feather-actions button:active {
color: #438cf5;
}
.feather-actions .is-liked {
color: #f04f87;
}
.feather-actions .is-bookmarked {
color: #438cf5;
}
</style>
+6 -5
View File
@@ -124,11 +124,12 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual([
'fliptok',
'flare',
'radio',
'local-pages',
).toEqual([
'feather',
'fliptok',
'flare',
'radio',
'local-pages',
'phone',
'darkchat',
'banking',
+16
View File
@@ -26,6 +26,7 @@ import {
Tag,
MapPinHouse,
Flame,
Feather,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw } from 'vue'
@@ -57,6 +58,7 @@ import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import flareIcon from '@/assets/img/app-icons/flare.svg'
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import featherIcon from '@/assets/img/app-icons/feather.svg'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
@@ -64,6 +66,20 @@ import type {
} from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/FeatherApp.vue')),
),
dockOrder: null,
gridOrder: 25,
icon: markRaw(Feather),
iconClass: 'app-icon--feather',
iconImage: featherIcon,
id: 'feather',
labelKey: 'Apps.feather.name',
route: '/apps/feather',
},
{
category: 'social',
component: markRaw(
+163
View File
@@ -0,0 +1,163 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFeatherStore } from '@/stores/feather'
import type { FeatherPost, FeatherProfile } from '@/types/feather'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const profile: FeatherProfile = {
bio: 'City life',
display_name: 'Nova',
followers: 4,
following: 2,
handle: 'nova',
id: 7,
is_following: false,
is_owner: false,
post_count: 1,
verified: false,
}
const post: FeatherPost = {
body: 'Hello Los Santos',
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'post-1',
is_bookmarked: false,
is_following: false,
is_liked: false,
is_owner: false,
like_count: 2,
media: [],
profile_id: 7,
reply_count: 0,
verified: false,
}
describe('Feather store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(nuiCall).mockReset()
})
it('hydrates the profile and feed from the account-linked bootstrap', async () => {
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: {
feed: { hasMore: false, items: [post], offset: 0 },
onboarded: true,
profile,
suggestions: [],
topics: [{ count: 12, tag: '#LosSantos' }],
},
})
const store = useFeatherStore()
expect(await store.bootstrap()).toBe(true)
expect(store.onboarded).toBe(true)
expect(store.feed).toHaveLength(1)
expect(store.topics).toEqual([{ count: 12, tag: '#LosSantos' }])
})
it('rolls an optimistic reaction back when the server rejects it', async () => {
vi.mocked(nuiCall).mockResolvedValue({
success: false,
error: 'rate_limited',
})
const store = useFeatherStore()
const item = { ...post }
await store.react(item, 'like')
expect(item.is_liked).toBe(false)
expect(item.like_count).toBe(2)
})
it('loads posts matching text or hashtags in Explore', async () => {
const hashtagPost = { ...post, body: 'Meetup tonight #CityLife' }
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: {
posts: [hashtagPost],
profiles: [],
topics: [{ count: 5, tag: '#CityLife' }],
},
})
const store = useFeatherStore()
expect(await store.explore('#CityLife')).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('feather:explore', {
search: '#CityLife',
})
expect(store.explorePosts).toEqual([hashtagPost])
expect(store.exploreLoading).toBe(false)
})
it('loads matching profiles and suggestions for the network tab', async () => {
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: {
results: [profile],
suggestions: [{ ...profile, id: 8 }],
},
})
const store = useFeatherStore()
expect(await store.loadNetwork('@nova')).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('feather:network', {
search: '@nova',
})
expect(store.networkResults).toEqual([profile])
expect(store.networkSuggestions[0]?.id).toBe(8)
})
it('loads a profile connection list', async () => {
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: { items: [profile] },
})
const store = useFeatherStore()
expect(await store.loadConnections(3, 'followers')).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('feather:connections', {
mode: 'followers',
profileId: 3,
})
expect(store.connections).toEqual([profile])
})
it('removes an owned connection and updates the profile count', async () => {
vi.mocked(nuiCall).mockResolvedValue({ success: true })
const store = useFeatherStore()
store.profile = { ...profile, followers: 4, is_owner: true }
store.connections = [{ ...profile }]
expect(
await store.removeConnection(store.connections[0], 'followers'),
).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('feather:remove-connection', {
mode: 'followers',
profileId: 7,
})
expect(store.connections).toEqual([])
expect(store.profile.followers).toBe(3)
})
it('removes blocked profiles from public surfaces', async () => {
vi.mocked(nuiCall).mockResolvedValue({ success: true })
const store = useFeatherStore()
store.feed = [{ ...post }]
store.explorePosts = [{ ...post }]
store.networkResults = [{ ...profile }]
store.networkSuggestions = [{ ...profile }]
expect(await store.blockProfile(7)).toBe(true)
expect(store.feed).toEqual([])
expect(store.explorePosts).toEqual([])
expect(store.networkResults).toEqual([])
expect(store.networkSuggestions).toEqual([])
})
})
+286
View File
@@ -0,0 +1,286 @@
import { defineStore } from 'pinia'
import type {
FeatherActivity,
FeatherBootstrap,
FeatherPost,
FeatherProfile,
FeatherProfilePage,
FeatherThread,
FeatherTopic,
} from '@/types/feather'
import { nuiCall, type NuiResponse } from '@/utils/nui'
type FeedMode = 'for-you' | 'following'
export type FeatherConnectionMode = 'followers' | 'following'
export const useFeatherStore = defineStore('feather', {
state: () => ({
activities: [] as FeatherActivity[],
connectionLoading: false,
connections: [] as FeatherProfile[],
exploreLoading: false,
explorePosts: [] as FeatherPost[],
exploreRequest: 0,
feed: [] as FeatherPost[],
loading: false,
mode: 'for-you' as FeedMode,
networkLoading: false,
networkRequest: 0,
networkResults: [] as FeatherProfile[],
networkSuggestions: [] as FeatherProfile[],
onboarded: false,
profile: null as FeatherProfile | null,
profilePosts: [] as FeatherPost[],
suggestions: [] as FeatherProfile[],
thread: null as FeatherThread | null,
topics: [] as FeatherTopic[],
viewedProfile: null as FeatherProfile | null,
}),
actions: {
async bootstrap(): Promise<boolean> {
this.loading = true
const response = await nuiCall<FeatherBootstrap>('feather:bootstrap')
this.loading = false
if (!response.success || !response.data) return false
this.onboarded = response.data.onboarded
this.profile = response.data.profile ?? null
this.feed = response.data.feed?.items ?? []
this.suggestions = response.data.suggestions ?? []
this.topics = response.data.topics ?? []
return true
},
async createProfile(data: {
avatarId?: number
bio: string
displayName: string
handle: string
}): Promise<NuiResponse> {
const response = await nuiCall('feather:create-profile', data)
if (response.success) await this.bootstrap()
return response
},
async updateProfile(data: {
avatarId?: number
bio: string
displayName: string
}): Promise<NuiResponse> {
const response = await nuiCall('feather:update-profile', data)
if (response.success) await this.bootstrap()
return response
},
async loadFeed(mode?: FeedMode): Promise<boolean> {
mode ??= this.mode
this.mode = mode
this.loading = true
const response = await nuiCall<{ items: FeatherPost[] }>('feather:feed', {
mode,
offset: 0,
})
this.loading = false
if (!response.success || !response.data) return false
this.feed = response.data.items
return true
},
async explore(search = ''): Promise<boolean> {
const request = ++this.exploreRequest
this.exploreLoading = true
const response = await nuiCall<{
posts: FeatherPost[]
profiles: FeatherProfile[]
topics?: FeatherTopic[]
}>('feather:explore', { search })
if (request !== this.exploreRequest) return false
this.exploreLoading = false
if (!response.success || !response.data) return false
this.explorePosts = response.data.posts
this.suggestions = response.data.profiles
if (response.data.topics) this.topics = response.data.topics
return true
},
async loadNetwork(search = ''): Promise<boolean> {
const request = ++this.networkRequest
this.networkLoading = true
const response = await nuiCall<{
results: FeatherProfile[]
suggestions: FeatherProfile[]
}>('feather:network', { search })
if (request !== this.networkRequest) return false
this.networkLoading = false
if (!response.success || !response.data) return false
this.networkResults = response.data.results
this.networkSuggestions = response.data.suggestions
return true
},
async createPost(data: {
body: string
mediaIds: number[]
quoteId?: string
replyToId?: string
}): Promise<NuiResponse<{ id: string }>> {
const response = await nuiCall<{ id: string }>(
'feather:create-post',
data,
)
if (response.success) await this.loadFeed()
return response
},
async react(post: FeatherPost, kind: 'like' | 'bookmark'): Promise<void> {
const key = kind === 'like' ? 'is_liked' : 'is_bookmarked'
const next = !post[key]
post[key] = next
if (kind === 'like') post.like_count += next ? 1 : -1
const response = await nuiCall('feather:react', {
active: next,
id: post.id,
kind,
})
if (response.success) return
post[key] = !next
if (kind === 'like') post.like_count += next ? -1 : 1
},
async follow(profile: FeatherProfile): Promise<void> {
const next = !profile.is_following
const response = await nuiCall('feather:follow', {
active: next,
profileId: profile.id,
})
if (!response.success) return
const profiles = new Set([
profile,
...this.suggestions,
...this.networkResults,
...this.networkSuggestions,
...(this.profile ? [this.profile] : []),
...(this.viewedProfile ? [this.viewedProfile] : []),
])
for (const item of profiles) {
if (item.id !== profile.id) continue
item.is_following = next
item.followers = Math.max(0, item.followers + (next ? 1 : -1))
}
for (const post of [
...this.feed,
...this.explorePosts,
...this.profilePosts,
]) {
if (post.profile_id === profile.id) post.is_following = next
}
},
async followPost(post: FeatherPost): Promise<void> {
const next = !post.is_following
const response = await nuiCall('feather:follow', {
active: next,
profileId: post.profile_id,
})
if (!response.success) return
for (const item of [
...this.feed,
...this.explorePosts,
...this.profilePosts,
]) {
if (item.profile_id === post.profile_id) item.is_following = next
}
},
async loadConnections(
profileId: number,
mode: FeatherConnectionMode,
): Promise<boolean> {
this.connectionLoading = true
const response = await nuiCall<{ items: FeatherProfile[] }>(
'feather:connections',
{ mode, profileId },
)
this.connectionLoading = false
if (!response.success || !response.data) return false
this.connections = response.data.items
return true
},
async removeConnection(
target: FeatherProfile,
mode: FeatherConnectionMode,
): Promise<boolean> {
const response = await nuiCall('feather:remove-connection', {
mode,
profileId: target.id,
})
if (!response.success) return false
this.connections = this.connections.filter(
(item) => item.id !== target.id,
)
if (this.profile) {
const count = mode === 'followers' ? 'followers' : 'following'
this.profile[count] = Math.max(0, this.profile[count] - 1)
}
if (this.viewedProfile?.is_owner) {
const count = mode === 'followers' ? 'followers' : 'following'
this.viewedProfile[count] = Math.max(0, this.viewedProfile[count] - 1)
}
if (mode === 'following') {
target.is_following = false
for (const post of [
...this.feed,
...this.explorePosts,
...this.profilePosts,
]) {
if (post.profile_id === target.id) post.is_following = false
}
}
return true
},
async loadProfile(profileId?: number): Promise<boolean> {
const response = await nuiCall<FeatherProfilePage>('feather:profile', {
profileId,
})
if (!response.success || !response.data) return false
this.viewedProfile = response.data.profile
this.profilePosts = response.data.posts
return true
},
async loadThread(id: string): Promise<boolean> {
const response = await nuiCall<FeatherThread>('feather:thread', { id })
if (!response.success || !response.data) return false
this.thread = response.data
return true
},
async loadActivities(): Promise<void> {
const response = await nuiCall<FeatherActivity[]>('feather:activities')
this.activities = response.success && response.data ? response.data : []
},
async markActivities(): Promise<void> {
const response = await nuiCall('feather:mark-activities')
if (response.success)
this.activities.forEach((item) => (item.read = true))
},
async deletePost(id: string): Promise<boolean> {
const response = await nuiCall('feather:delete', { id })
if (!response.success) return false
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)
return true
},
async blockProfile(profileId: number): Promise<boolean> {
const response = await nuiCall('feather:block', { profileId })
if (!response.success) return false
this.feed = this.feed.filter((post) => post.profile_id !== profileId)
this.explorePosts = this.explorePosts.filter(
(post) => post.profile_id !== profileId,
)
this.networkResults = this.networkResults.filter(
(profile) => profile.id !== profileId,
)
this.networkSuggestions = this.networkSuggestions.filter(
(profile) => profile.id !== profileId,
)
return true
},
async reportPost(
id: string,
reason: string,
details = '',
): Promise<boolean> {
return (await nuiCall('feather:report', { details, id, reason })).success
},
},
})
+198
View File
@@ -191,6 +191,204 @@ const defaultLocales: LocaleTree = {
default: 'Flare could not complete the request.',
},
},
feather: {
name: 'Feather',
loading: 'Loading Feather',
home: 'Home',
explore: 'Explore',
activity: 'Notifications',
activityNav: 'Alerts',
profile: 'Profile',
back: 'Back',
settings: 'Settings',
settingsEyebrow: 'Feather preferences',
compactMode: 'Compact timeline',
compactModeBody: 'Show more conversation on the screen.',
showSuggestions: 'Profile suggestions',
showSuggestionsBody: 'Show people to follow on your profile.',
forYou: 'For you',
following: 'Following',
add: 'Add',
network: 'Network',
networkTitle: 'Find your people',
networkBody:
'Follow voices from across Los Santos and build a timeline that feels like yours.',
networkSearchPlaceholder: 'Search names or @usernames',
postSearchPlaceholder: 'Search posts or #hashtags',
searchResults: 'Search results',
suggestedPeople: 'Suggested for you',
noPeopleFound: 'No people found',
noPeopleFoundBody: 'Try another name or username.',
noSuggestions: 'No suggestions yet',
noSuggestionsBody: 'New Feather accounts will appear here.',
all: 'All',
mentions: 'Mentions',
media: 'Media',
trending: 'Trending',
peopleTab: 'People',
searchPlaceholder: 'Search Feather',
search: 'Search',
cancel: 'Cancel',
done: 'Done',
authEyebrow: 'Feather network',
authWelcome: 'Welcome to Feather',
authBody: 'One iFruit account. Every conversation, wherever you sign in.',
login: 'Log in',
register: 'Register',
loginTitle: 'Good to see you again',
loginBody: 'Log in with your iFruit address to continue to Feather.',
registerTitle: 'Create your iFruit account',
registerBody:
'Choose an address and secure it with a password. Your Feather profile comes next.',
email: 'iFruit address',
emailPlaceholder: 'yourname',
password: 'Password',
passwordPlaceholder: '664 characters',
confirmPassword: 'Confirm password',
confirmPasswordPlaceholder: 'Enter it again',
showPassword: 'Show password',
hidePassword: 'Hide password',
loginAction: 'Continue to Feather',
registerAction: 'Create account',
noAccount: 'New to Feather?',
haveAccount: 'Already registered?',
registerNow: 'Create an account',
loginNow: 'Log in',
authTrust:
'Your credentials are verified by the server and this phone is linked to your iFruit account.',
profileStep: 'Step 2 of 2',
accountConnected: 'iFruit account connected',
authErrors: {
invalid_email: 'Enter a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
password_mismatch: 'The passwords do not match.',
invalid_credentials: 'The iFruit address or password is incorrect.',
email_taken: 'That iFruit address is already registered.',
rate_limited: 'Too many attempts. Try again in a minute.',
default: 'The account request failed. Please try again.',
},
welcome: 'Find your voice',
welcomeBody:
'Join the live conversation in Los Santos. Your Feather profile stays linked to your iFruit account.',
createProfile: 'Create Feather profile',
profileDetailsHint: 'Choose how people will recognize you.',
displayName: 'Display name',
displayNamePlaceholder: 'Your name',
handle: 'Username',
handlePlaceholder: 'your_username',
handleHint: '3-30 letters, numbers or underscores',
bio: 'Bio',
bioPlaceholder: 'What should people know about you?',
start: 'Join Feather',
chooseAvatar: 'Choose profile photo',
changeAvatar: 'Change photo',
newPost: 'New feather',
reply: 'Reply',
replyingTo: 'Replying to @{handle}',
quote: 'Quote',
post: 'Post',
posting: 'Posting...',
composerPlaceholder: "What's happening?",
replyPlaceholder: 'Post your reply',
addPhotos: 'Add photos',
removePhoto: 'Remove photo {number}',
charactersLeft: '{count} left',
composerAudience: 'Everyone can join the conversation',
mediaTitle: 'Add to your feather',
mediaBody: 'Share up to four photos from your phone or take a new one.',
chooseGallery: 'Gallery',
chooseGalleryBody: 'Choose one or more saved photos',
takePhoto: 'Camera',
takePhotoBody: 'Take a new photo now',
selectedPhotos: 'Selected photos',
photoLimit: 'You can add up to four photos.',
emptyFeed: 'Your timeline is quiet',
emptyFeedBody: 'Follow people or post the first feather.',
emptyExplore: 'No results found',
emptyExploreBody: 'Try another search or explore a topic.',
noTrendingHashtags: 'No hashtags yet',
noTrendingHashtagsBody: 'Hashtags from published posts will appear here.',
happeningNow: "What's happening",
trends: 'Trends for you',
trendingIn: 'Trending in Los Santos',
openTopic: 'View conversation',
postsCount: '{count} posts',
exploreTabs: {
explore: 'Explore',
trending: 'Trends',
news: 'News',
},
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}',
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.',
activityKinds: {
like: 'liked your feather',
reply: 'replied to your feather',
follow: 'followed you',
quote: 'quoted your feather',
},
errors: {
accountRequired: 'Sign in to iFruit in Settings first.',
invalidHandle: 'Use 330 letters, numbers or underscores.',
handleTaken: 'That handle is already taken.',
invalidPost: 'Write something or attach a photo.',
generic: 'Something went wrong. Please try again.',
},
notifications: {
default: 'You have new activity.',
like: '{actor} liked your feather.',
reply: '{actor} replied to your feather.',
follow: '{actor} followed you.',
quote: '{actor} quoted your feather.',
},
},
fliptok: {
name: 'FlipTok',
loading: 'Loading FlipTok',
+1
View File
@@ -29,6 +29,7 @@ export type PhoneAppId =
| 'local-pages'
| 'flare'
| 'fliptok'
| 'feather'
export type LaunchablePhoneAppId = PhoneAppId
+81
View File
@@ -0,0 +1,81 @@
export type FeatherMedia = {
id: number
media_type: 'photo'
url: string
}
export type FeatherProfile = {
avatar_url?: string
bio: string
display_name: string
followers: number
following: number
handle: string
id: number
is_following: boolean
is_owner: boolean
post_count: number
verified: boolean
}
export type FeatherPost = {
avatar_url?: string
body: string
created_at: number
display_name: string
handle: string
id: string
is_bookmarked: boolean
is_following: boolean
is_liked: boolean
is_owner: boolean
like_count: number
media: FeatherMedia[]
profile_id: number
quote_id?: string
reply_count: number
reply_to_id?: string
verified: boolean
}
export type FeatherPage = {
hasMore: boolean
items: FeatherPost[]
offset: number
}
export type FeatherActivity = {
avatar_url?: string
created_at: number
display_name: string
handle: string
id: string
kind: 'like' | 'reply' | 'follow' | 'quote'
post_id?: string
profile_id: number
read: boolean
verified: boolean
}
export type FeatherTopic = {
count: number
tag: string
}
export type FeatherBootstrap = {
feed?: FeatherPage
onboarded: boolean
profile?: FeatherProfile
suggestions?: FeatherProfile[]
topics: FeatherTopic[]
}
export type FeatherProfilePage = {
posts: FeatherPost[]
profile: FeatherProfile
}
export type FeatherThread = {
post: FeatherPost
replies: FeatherPost[]
}
+10 -2
View File
@@ -14,12 +14,20 @@ export async function nuiCall<T = unknown>(
'apiPort',
)
const baseUrl = import.meta.env.DEV
? `http://localhost:${developmentPort ?? '3001'}/api`
? `http://localhost:${developmentPort ?? '3002'}/api`
: `https://${resourceName}`
const requestData = import.meta.env.DEV
? {
...data,
_testScenario:
new URLSearchParams(window.location.search).get('testScenario') ??
undefined,
}
: data
try {
const response = await fetch(`${baseUrl}/${endpoint}`, {
body: JSON.stringify(data),
body: JSON.stringify(requestData),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
+1
View File
@@ -83,6 +83,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
citymarkt: { enabled: true, sounds: true },
'local-pages': { enabled: true, sounds: true },
fliptok: { enabled: true, sounds: true },
feather: { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
calendar: { enabled: true, sounds: true },
File diff suppressed because it is too large Load Diff