mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - implement Feather social app
This commit is contained in:
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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: '6–64 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 3–32 character iFruit address.',
|
||||
invalid_password: 'Password must be 6–64 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 3–30 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',
|
||||
|
||||
@@ -29,6 +29,7 @@ export type PhoneAppId =
|
||||
| 'local-pages'
|
||||
| 'flare'
|
||||
| 'fliptok'
|
||||
| 'feather'
|
||||
|
||||
export type LaunchablePhoneAppId = PhoneAppId
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user