mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:01:31 +00:00
ADD - implement Feather social app
This commit is contained in:
@@ -124,6 +124,13 @@ The homescreen is an original implementation inspired by the interaction and lay
|
||||
|
||||
## Development
|
||||
|
||||
From `frontend/`, run `pnpm dev` for browser development. The phone opens automatically and NUI callbacks are mocked. Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` before packaging.
|
||||
From `frontend/`, run `pnpm dev` for browser development. The phone opens automatically and NUI callbacks are mocked. Feather can be opened directly with the following browser scenarios:
|
||||
|
||||
- Full data: `http://localhost:5174/?apiPort=3002#/apps/feather`
|
||||
- Login and registration: `http://localhost:5174/?apiPort=3002&testScenario=feather-login#/apps/feather`
|
||||
- Profile onboarding: `http://localhost:5174/?apiPort=3002&testScenario=feather-onboarding#/apps/feather`
|
||||
- Empty states: `http://localhost:5174/?apiPort=3002&testScenario=feather-empty#/apps/feather`
|
||||
|
||||
The full-data scenario includes posts, replies, quotes, media grids, profiles, ranked hashtags, network search results, and every notification type. Run `pnpm test`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` before packaging.
|
||||
|
||||
`pnpm build` uses `build.cjs` to replace `sky_phone/source/html` deterministically with the Vite output. Production assets use relative paths so they work through the FiveM NUI protocol.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite\" \"node testserver/index.cjs\"",
|
||||
"dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite --port 5174 --strictPort\" \"node testserver/index.cjs 3002\"",
|
||||
"build": "pnpm typecheck && pnpm build-only",
|
||||
"build-only": "vite build && node build.cjs",
|
||||
"preview": "vite preview",
|
||||
|
||||
@@ -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
@@ -950,6 +950,24 @@ let mockMedia = [
|
||||
mediaType: 'video',
|
||||
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 180_000,
|
||||
id: 3,
|
||||
mediaType: 'photo',
|
||||
url: 'https://picsum.photos/seed/sky-phone-3/800/600',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 240_000,
|
||||
id: 4,
|
||||
mediaType: 'photo',
|
||||
url: 'https://picsum.photos/seed/sky-phone-4/800/600',
|
||||
},
|
||||
{
|
||||
createdAt: Date.now() - 300_000,
|
||||
id: 5,
|
||||
mediaType: 'photo',
|
||||
url: 'https://picsum.photos/seed/sky-phone-5/800/600',
|
||||
},
|
||||
]
|
||||
const marketplaceInquiries = [
|
||||
{
|
||||
@@ -1321,9 +1339,852 @@ function flareBootstrap() {
|
||||
}
|
||||
}
|
||||
|
||||
const featherProfiles = [
|
||||
{
|
||||
id: 1,
|
||||
handle: 'alexmorgan',
|
||||
display_name: 'Alex Morgan',
|
||||
bio: 'City stories, night drives and good coffee.',
|
||||
avatar_url: 'https://picsum.photos/seed/feather-alex/160/160',
|
||||
verified: false,
|
||||
is_owner: true,
|
||||
is_following: false,
|
||||
followers: 128,
|
||||
following: 84,
|
||||
post_count: 2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
handle: 'lsdaily',
|
||||
display_name: 'Los Santos Daily',
|
||||
bio: 'Independent updates from across the city.',
|
||||
avatar_url: 'https://picsum.photos/seed/feather-daily/160/160',
|
||||
verified: true,
|
||||
is_owner: false,
|
||||
is_following: true,
|
||||
followers: 12400,
|
||||
following: 92,
|
||||
post_count: 842,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
handle: 'miasantos',
|
||||
display_name: 'Mia Santos',
|
||||
bio: 'Music, art and the west side.',
|
||||
avatar_url: 'https://picsum.photos/seed/feather-mia/160/160',
|
||||
verified: false,
|
||||
is_owner: false,
|
||||
is_following: false,
|
||||
followers: 834,
|
||||
following: 310,
|
||||
post_count: 67,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
handle: 'bennysmotorworks',
|
||||
display_name: "Benny's Motor Works",
|
||||
bio: 'Custom builds, workshop stories and open appointments.',
|
||||
avatar_url: 'https://picsum.photos/seed/feather-bennys/160/160',
|
||||
verified: true,
|
||||
is_owner: false,
|
||||
is_following: true,
|
||||
followers: 4821,
|
||||
following: 146,
|
||||
post_count: 318,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
handle: 'lspd',
|
||||
display_name: 'Los Santos Police Department',
|
||||
bio: 'Official public safety updates for Los Santos.',
|
||||
avatar_url: 'https://picsum.photos/seed/feather-lspd/160/160',
|
||||
verified: true,
|
||||
is_owner: false,
|
||||
is_following: false,
|
||||
followers: 31700,
|
||||
following: 41,
|
||||
post_count: 1294,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
handle: 'nightshiftls',
|
||||
display_name: 'Night Shift LS',
|
||||
bio: 'Neon, street photography and stories after midnight.',
|
||||
avatar_url: 'https://picsum.photos/seed/feather-night/160/160',
|
||||
verified: false,
|
||||
is_owner: false,
|
||||
is_following: false,
|
||||
followers: 2140,
|
||||
following: 502,
|
||||
post_count: 154,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
handle: 'downtowneats',
|
||||
display_name: 'Downtown Eats',
|
||||
bio: 'Finding the best food trucks, diners and late-night menus.',
|
||||
avatar_url: 'https://picsum.photos/seed/feather-food/160/160',
|
||||
verified: false,
|
||||
is_owner: false,
|
||||
is_following: true,
|
||||
followers: 7602,
|
||||
following: 288,
|
||||
post_count: 604,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
handle: 'vinewoodsocial',
|
||||
display_name: 'Vinewood Social',
|
||||
bio: 'Events, premieres and everything happening in Vinewood.',
|
||||
avatar_url: 'https://picsum.photos/seed/feather-vinewood/160/160',
|
||||
verified: true,
|
||||
is_owner: false,
|
||||
is_following: false,
|
||||
followers: 18900,
|
||||
following: 73,
|
||||
post_count: 911,
|
||||
},
|
||||
]
|
||||
let featherFollowingIds = [2, 4, 7]
|
||||
let featherFollowerIds = [3, 6, 8]
|
||||
let featherBlockedProfileIds = []
|
||||
const featherReports = []
|
||||
let featherPosts = [
|
||||
{
|
||||
id: 'feather-post-1',
|
||||
profile_id: 2,
|
||||
handle: 'lsdaily',
|
||||
display_name: 'Los Santos Daily',
|
||||
verified: true,
|
||||
avatar_url: featherProfiles[1].avatar_url,
|
||||
body: 'Golden hour over Downtown. Traffic is building near Legion Square — take the eastern route if you can.',
|
||||
created_at: Date.now() - 12 * 60 * 1000,
|
||||
reply_to_id: null,
|
||||
quote_id: null,
|
||||
is_owner: false,
|
||||
is_following: true,
|
||||
is_liked: true,
|
||||
is_bookmarked: false,
|
||||
like_count: 241,
|
||||
reply_count: 18,
|
||||
media: [
|
||||
{
|
||||
id: 901,
|
||||
media_type: 'photo',
|
||||
url: 'https://picsum.photos/seed/feather-city/900/600',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feather-post-2',
|
||||
profile_id: 3,
|
||||
handle: 'miasantos',
|
||||
display_name: 'Mia Santos',
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[2].avatar_url,
|
||||
body: 'Small reminder: the best corners of this city are the ones you find by accident. What is your favorite late-night spot?',
|
||||
created_at: Date.now() - 48 * 60 * 1000,
|
||||
reply_to_id: null,
|
||||
quote_id: null,
|
||||
is_owner: false,
|
||||
is_following: false,
|
||||
is_liked: false,
|
||||
is_bookmarked: true,
|
||||
like_count: 73,
|
||||
reply_count: 9,
|
||||
media: [],
|
||||
},
|
||||
{
|
||||
id: 'feather-post-3',
|
||||
profile_id: 1,
|
||||
handle: 'alexmorgan',
|
||||
display_name: 'Alex Morgan',
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[0].avatar_url,
|
||||
body: 'First feather. The city feels unusually calm tonight.',
|
||||
created_at: Date.now() - 3 * 60 * 60 * 1000,
|
||||
reply_to_id: null,
|
||||
quote_id: null,
|
||||
is_owner: true,
|
||||
is_following: false,
|
||||
is_liked: false,
|
||||
is_bookmarked: false,
|
||||
like_count: 12,
|
||||
reply_count: 2,
|
||||
media: [],
|
||||
},
|
||||
{
|
||||
id: 'feather-post-4',
|
||||
profile_id: 4,
|
||||
handle: 'bennysmotorworks',
|
||||
display_name: "Benny's Motor Works",
|
||||
verified: true,
|
||||
avatar_url: featherProfiles[3].avatar_url,
|
||||
body: 'The Elegy is finally ready. New paint, cleaner fitment and a setup made for city nights. #Cars #Bennys',
|
||||
created_at: Date.now() - 5 * 60 * 60 * 1000,
|
||||
reply_to_id: null,
|
||||
quote_id: null,
|
||||
is_owner: false,
|
||||
is_following: true,
|
||||
is_liked: false,
|
||||
is_bookmarked: false,
|
||||
like_count: 812,
|
||||
reply_count: 46,
|
||||
media: [
|
||||
{
|
||||
id: 904,
|
||||
media_type: 'photo',
|
||||
url: 'https://picsum.photos/seed/feather-car/900/600',
|
||||
},
|
||||
{
|
||||
id: 905,
|
||||
media_type: 'photo',
|
||||
url: 'https://picsum.photos/seed/feather-garage/900/600',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feather-post-5',
|
||||
profile_id: 5,
|
||||
handle: 'lspd',
|
||||
display_name: 'Los Santos Police Department',
|
||||
verified: true,
|
||||
avatar_url: featherProfiles[4].avatar_url,
|
||||
body: 'Road closure: Power Street between Vespucci Boulevard and San Andreas Avenue. Please use an alternate route. #LosSantos #Traffic',
|
||||
created_at: Date.now() - 7 * 60 * 60 * 1000,
|
||||
reply_to_id: null,
|
||||
quote_id: null,
|
||||
is_owner: false,
|
||||
is_following: false,
|
||||
is_liked: false,
|
||||
is_bookmarked: true,
|
||||
like_count: 391,
|
||||
reply_count: 61,
|
||||
media: [],
|
||||
},
|
||||
{
|
||||
id: 'feather-post-6',
|
||||
profile_id: 6,
|
||||
handle: 'nightshiftls',
|
||||
display_name: 'Night Shift LS',
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[5].avatar_url,
|
||||
body: 'Three quiet frames from tonight. Downtown really changes after the rain. #Photography #NightLife',
|
||||
created_at: Date.now() - 11 * 60 * 60 * 1000,
|
||||
reply_to_id: null,
|
||||
quote_id: null,
|
||||
is_owner: false,
|
||||
is_following: false,
|
||||
is_liked: true,
|
||||
is_bookmarked: true,
|
||||
like_count: 1540,
|
||||
reply_count: 83,
|
||||
media: [
|
||||
{
|
||||
id: 906,
|
||||
media_type: 'photo',
|
||||
url: 'https://picsum.photos/seed/feather-neon/900/600',
|
||||
},
|
||||
{
|
||||
id: 907,
|
||||
media_type: 'photo',
|
||||
url: 'https://picsum.photos/seed/feather-rain/900/600',
|
||||
},
|
||||
{
|
||||
id: 908,
|
||||
media_type: 'photo',
|
||||
url: 'https://picsum.photos/seed/feather-downtown/900/600',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feather-post-7',
|
||||
profile_id: 7,
|
||||
handle: 'downtowneats',
|
||||
display_name: 'Downtown Eats',
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[6].avatar_url,
|
||||
body: 'New taco truck at Legion Square until 2 AM. The hot sauce is not joking around. #Food #LosSantos',
|
||||
created_at: Date.now() - 16 * 60 * 60 * 1000,
|
||||
reply_to_id: null,
|
||||
quote_id: 'feather-post-2',
|
||||
is_owner: false,
|
||||
is_following: true,
|
||||
is_liked: false,
|
||||
is_bookmarked: false,
|
||||
like_count: 628,
|
||||
reply_count: 37,
|
||||
media: [
|
||||
{
|
||||
id: 909,
|
||||
media_type: 'photo',
|
||||
url: 'https://picsum.photos/seed/feather-tacos/900/600',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feather-post-8',
|
||||
profile_id: 8,
|
||||
handle: 'vinewoodsocial',
|
||||
display_name: 'Vinewood Social',
|
||||
verified: true,
|
||||
avatar_url: featherProfiles[7].avatar_url,
|
||||
body: 'Outdoor cinema returns to the Vinewood Bowl this weekend. Gates open at 8 PM. #Events #Vinewood',
|
||||
created_at: Date.now() - 22 * 60 * 60 * 1000,
|
||||
reply_to_id: null,
|
||||
quote_id: null,
|
||||
is_owner: false,
|
||||
is_following: false,
|
||||
is_liked: false,
|
||||
is_bookmarked: false,
|
||||
like_count: 2203,
|
||||
reply_count: 104,
|
||||
media: [
|
||||
{
|
||||
id: 910,
|
||||
media_type: 'photo',
|
||||
url: 'https://picsum.photos/seed/feather-cinema/900/600',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feather-post-9',
|
||||
profile_id: 1,
|
||||
handle: 'alexmorgan',
|
||||
display_name: 'Alex Morgan',
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[0].avatar_url,
|
||||
body: 'Traffic update for everyone heading downtown later. Drive safe.',
|
||||
created_at: Date.now() - 26 * 60 * 60 * 1000,
|
||||
reply_to_id: null,
|
||||
quote_id: 'feather-post-5',
|
||||
is_owner: true,
|
||||
is_following: false,
|
||||
is_liked: true,
|
||||
is_bookmarked: false,
|
||||
like_count: 34,
|
||||
reply_count: 4,
|
||||
media: [],
|
||||
},
|
||||
{
|
||||
id: 'feather-reply-1',
|
||||
profile_id: 1,
|
||||
handle: 'alexmorgan',
|
||||
display_name: 'Alex Morgan',
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[0].avatar_url,
|
||||
body: 'Thanks for the warning. The eastern route was much faster.',
|
||||
created_at: Date.now() - 8 * 60 * 1000,
|
||||
reply_to_id: 'feather-post-1',
|
||||
quote_id: null,
|
||||
is_owner: true,
|
||||
is_following: false,
|
||||
is_liked: false,
|
||||
is_bookmarked: false,
|
||||
like_count: 5,
|
||||
reply_count: 0,
|
||||
media: [],
|
||||
},
|
||||
{
|
||||
id: 'feather-reply-2',
|
||||
profile_id: 3,
|
||||
handle: 'miasantos',
|
||||
display_name: 'Mia Santos',
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[2].avatar_url,
|
||||
body: 'That skyline photo is perfect.',
|
||||
created_at: Date.now() - 5 * 60 * 1000,
|
||||
reply_to_id: 'feather-post-1',
|
||||
quote_id: null,
|
||||
is_owner: false,
|
||||
is_following: false,
|
||||
is_liked: true,
|
||||
is_bookmarked: false,
|
||||
like_count: 12,
|
||||
reply_count: 1,
|
||||
media: [],
|
||||
},
|
||||
]
|
||||
const featherActivities = [
|
||||
{
|
||||
id: 'feather-activity-1',
|
||||
kind: 'like',
|
||||
post_id: 'feather-post-3',
|
||||
profile_id: 3,
|
||||
handle: 'miasantos',
|
||||
display_name: 'Mia Santos',
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[2].avatar_url,
|
||||
read: false,
|
||||
created_at: Date.now() - 15 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
id: 'feather-activity-2',
|
||||
kind: 'follow',
|
||||
post_id: null,
|
||||
profile_id: 2,
|
||||
handle: 'lsdaily',
|
||||
display_name: 'Los Santos Daily',
|
||||
verified: true,
|
||||
avatar_url: featherProfiles[1].avatar_url,
|
||||
read: true,
|
||||
created_at: Date.now() - 90 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
id: 'feather-activity-3',
|
||||
kind: 'reply',
|
||||
post_id: 'feather-post-3',
|
||||
profile_id: 4,
|
||||
handle: 'bennysmotorworks',
|
||||
display_name: "Benny's Motor Works",
|
||||
verified: true,
|
||||
avatar_url: featherProfiles[3].avatar_url,
|
||||
read: false,
|
||||
created_at: Date.now() - 2 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
id: 'feather-activity-5',
|
||||
kind: 'quote',
|
||||
post_id: 'feather-post-3',
|
||||
profile_id: 6,
|
||||
handle: 'nightshiftls',
|
||||
display_name: 'Night Shift LS',
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[5].avatar_url,
|
||||
read: false,
|
||||
created_at: Date.now() - 9 * 60 * 60 * 1000,
|
||||
},
|
||||
]
|
||||
const featherTopics = [
|
||||
{ tag: '#LosSantos', count: 8421 },
|
||||
{ tag: '#Cars', count: 5398 },
|
||||
{ tag: '#News', count: 4127 },
|
||||
{ tag: '#NightLife', count: 2944 },
|
||||
{ tag: '#Food', count: 2186 },
|
||||
{ tag: '#Vinewood', count: 1773 },
|
||||
{ tag: '#Photography', count: 1328 },
|
||||
{ tag: '#Events', count: 986 },
|
||||
]
|
||||
let featherOnboarded = true
|
||||
|
||||
app.post('/api/:endpoint', (request, response) => {
|
||||
console.log(`[NUI] ${request.params.endpoint}`, request.body)
|
||||
const endpoint = request.params.endpoint
|
||||
const testScenario = String(request.body._testScenario ?? '')
|
||||
if (endpoint === 'feather:bootstrap') {
|
||||
const empty = testScenario === 'feather-empty'
|
||||
const visibleProfiles = featherProfiles.filter(
|
||||
(profile) => !featherBlockedProfileIds.includes(profile.id),
|
||||
)
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
onboarded: featherOnboarded,
|
||||
profile: featherProfiles[0],
|
||||
feed: {
|
||||
items: empty
|
||||
? []
|
||||
: featherPosts.filter(
|
||||
(post) =>
|
||||
!post.reply_to_id &&
|
||||
!featherBlockedProfileIds.includes(post.profile_id),
|
||||
),
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
suggestions: empty
|
||||
? []
|
||||
: visibleProfiles.filter((profile) => !profile.is_owner),
|
||||
topics: empty ? [] : featherTopics,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:create-profile') {
|
||||
const displayName = String(request.body.displayName ?? '').trim()
|
||||
const handle = String(request.body.handle ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const bio = String(request.body.bio ?? '').trim()
|
||||
if (
|
||||
!displayName ||
|
||||
displayName.length > 50 ||
|
||||
!/^[a-z0-9][a-z0-9_]{1,28}[a-z0-9]$/.test(handle) ||
|
||||
bio.length > 160
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_handle' })
|
||||
return
|
||||
}
|
||||
if (
|
||||
featherProfiles.some(
|
||||
(profile) => profile.id !== 1 && profile.handle === handle,
|
||||
)
|
||||
) {
|
||||
response.json({ success: false, error: 'handle_taken' })
|
||||
return
|
||||
}
|
||||
featherProfiles[0].display_name = displayName
|
||||
featherProfiles[0].handle = handle
|
||||
featherProfiles[0].bio = bio
|
||||
featherOnboarded = true
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:update-profile') {
|
||||
const displayName = String(request.body.displayName ?? '').trim()
|
||||
const bio = String(request.body.bio ?? '').trim()
|
||||
if (!displayName || displayName.length > 50 || bio.length > 160) {
|
||||
response.json({ success: false, error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
featherProfiles[0].display_name = displayName
|
||||
featherProfiles[0].bio = bio
|
||||
featherPosts
|
||||
.filter((post) => post.profile_id === featherProfiles[0].id)
|
||||
.forEach((post) => {
|
||||
post.display_name = displayName
|
||||
})
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:feed') {
|
||||
const visiblePosts = featherPosts.filter(
|
||||
(post) => !featherBlockedProfileIds.includes(post.profile_id),
|
||||
)
|
||||
const items =
|
||||
request.body.mode === 'following'
|
||||
? visiblePosts.filter(
|
||||
(post) => !post.reply_to_id && (post.is_following || post.is_owner),
|
||||
)
|
||||
: visiblePosts.filter((post) => !post.reply_to_id)
|
||||
response.json({ success: true, data: { items, offset: 0, hasMore: false } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:explore') {
|
||||
const search = String(request.body.search ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const normalizedSearch = search.replace(/^#/, '')
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
posts: featherPosts.filter((post) => {
|
||||
if (post.reply_to_id) return false
|
||||
if (featherBlockedProfileIds.includes(post.profile_id)) return false
|
||||
if (!normalizedSearch) return true
|
||||
return `${post.body} ${post.display_name} ${post.handle}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedSearch)
|
||||
}),
|
||||
profiles: featherProfiles
|
||||
.slice(1)
|
||||
.filter((profile) => !featherBlockedProfileIds.includes(profile.id))
|
||||
.filter((profile) =>
|
||||
normalizedSearch
|
||||
? `${profile.display_name} ${profile.handle}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedSearch)
|
||||
: true,
|
||||
),
|
||||
topics: featherTopics.filter((topic) =>
|
||||
normalizedSearch
|
||||
? topic.tag.toLowerCase().includes(normalizedSearch)
|
||||
: true,
|
||||
),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:network') {
|
||||
const search = String(request.body.search ?? '')
|
||||
.trim()
|
||||
.replace(/^@/, '')
|
||||
.toLowerCase()
|
||||
const profiles = featherProfiles.filter(
|
||||
(profile) =>
|
||||
!profile.is_owner && !featherBlockedProfileIds.includes(profile.id),
|
||||
)
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
results: search
|
||||
? profiles.filter((profile) =>
|
||||
`${profile.display_name} ${profile.handle}`
|
||||
.toLowerCase()
|
||||
.includes(search),
|
||||
)
|
||||
: [],
|
||||
suggestions: profiles.filter((profile) => !profile.is_following),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:create-post') {
|
||||
const body = String(request.body.body ?? '').trim()
|
||||
const mediaIds = Array.isArray(request.body.mediaIds)
|
||||
? request.body.mediaIds.slice(0, 4)
|
||||
: []
|
||||
const id = `feather-post-${Date.now()}`
|
||||
const selectedMedia = mediaIds
|
||||
.map((mediaId) => mockMedia.find((item) => item.id === Number(mediaId)))
|
||||
.filter((item) => item?.mediaType === 'photo')
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
media_type: 'photo',
|
||||
url: item.url,
|
||||
}))
|
||||
if ((!body && selectedMedia.length === 0) || body.length > 360) {
|
||||
response.json({ success: false, error: 'invalid_post' })
|
||||
return
|
||||
}
|
||||
const replyToId = request.body.replyToId
|
||||
? String(request.body.replyToId)
|
||||
: null
|
||||
const parent = replyToId
|
||||
? featherPosts.find((post) => post.id === replyToId)
|
||||
: null
|
||||
if (replyToId && !parent) {
|
||||
response.json({ success: false, error: 'post_not_found' })
|
||||
return
|
||||
}
|
||||
featherPosts.unshift({
|
||||
id,
|
||||
profile_id: 1,
|
||||
handle: featherProfiles[0].handle,
|
||||
display_name: featherProfiles[0].display_name,
|
||||
verified: false,
|
||||
avatar_url: featherProfiles[0].avatar_url,
|
||||
body,
|
||||
created_at: Date.now(),
|
||||
reply_to_id: replyToId,
|
||||
quote_id: request.body.quoteId ?? null,
|
||||
is_owner: true,
|
||||
is_following: false,
|
||||
is_liked: false,
|
||||
is_bookmarked: false,
|
||||
like_count: 0,
|
||||
reply_count: 0,
|
||||
media: selectedMedia,
|
||||
})
|
||||
if (!replyToId) featherProfiles[0].post_count += 1
|
||||
if (parent) parent.reply_count += 1
|
||||
response.json({ success: true, data: { id } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:react') {
|
||||
if (!['like', 'bookmark'].includes(request.body.kind)) {
|
||||
response.json({ success: false, error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
const post = featherPosts.find((item) => item.id === request.body.id)
|
||||
if (!post) {
|
||||
response.json({ success: false, error: 'post_not_found' })
|
||||
return
|
||||
}
|
||||
const key = request.body.kind === 'like' ? 'is_liked' : 'is_bookmarked'
|
||||
const active = request.body.active === true
|
||||
if (request.body.kind === 'like' && post.is_liked !== active)
|
||||
post.like_count = Math.max(0, post.like_count + (active ? 1 : -1))
|
||||
post[key] = active
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:follow') {
|
||||
const profileId = Number(request.body.profileId)
|
||||
const profile = featherProfiles.find((item) => item.id === profileId)
|
||||
if (!profile || profile.is_owner) {
|
||||
response.json({ success: false, error: 'profile_not_found' })
|
||||
return
|
||||
}
|
||||
const wasFollowing = featherFollowingIds.includes(profileId)
|
||||
const active = request.body.active === true
|
||||
if (active) {
|
||||
if (!wasFollowing) featherFollowingIds.push(profileId)
|
||||
} else {
|
||||
featherFollowingIds = featherFollowingIds.filter((id) => id !== profileId)
|
||||
}
|
||||
if (wasFollowing !== active) {
|
||||
profile.followers = Math.max(0, profile.followers + (active ? 1 : -1))
|
||||
featherProfiles[0].following = Math.max(
|
||||
0,
|
||||
featherProfiles[0].following + (active ? 1 : -1),
|
||||
)
|
||||
}
|
||||
featherProfiles
|
||||
.filter((item) => item.id === profileId)
|
||||
.forEach((item) => {
|
||||
item.is_following = active
|
||||
})
|
||||
featherPosts
|
||||
.filter((item) => item.profile_id === profileId)
|
||||
.forEach((item) => {
|
||||
item.is_following = active
|
||||
})
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:connections') {
|
||||
const profileId = Number(request.body.profileId ?? 1)
|
||||
const mode = request.body.mode
|
||||
let ids = []
|
||||
if (profileId === 1) {
|
||||
ids = mode === 'followers' ? featherFollowerIds : featherFollowingIds
|
||||
} else {
|
||||
ids = featherProfiles
|
||||
.filter((profile) => profile.id !== profileId && !profile.is_owner)
|
||||
.slice(0, 4)
|
||||
.map((profile) => profile.id)
|
||||
}
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
items: ids
|
||||
.map((id) => featherProfiles.find((profile) => profile.id === id))
|
||||
.filter(Boolean),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:remove-connection') {
|
||||
const targetId = Number(request.body.profileId)
|
||||
if (request.body.mode === 'followers') {
|
||||
featherFollowerIds = featherFollowerIds.filter((id) => id !== targetId)
|
||||
featherProfiles[0].followers = Math.max(
|
||||
0,
|
||||
featherProfiles[0].followers - 1,
|
||||
)
|
||||
} else {
|
||||
featherFollowingIds = featherFollowingIds.filter((id) => id !== targetId)
|
||||
featherProfiles[0].following = Math.max(
|
||||
0,
|
||||
featherProfiles[0].following - 1,
|
||||
)
|
||||
const target = featherProfiles.find((profile) => profile.id === targetId)
|
||||
if (target) target.is_following = false
|
||||
featherPosts
|
||||
.filter((post) => post.profile_id === targetId)
|
||||
.forEach((post) => {
|
||||
post.is_following = false
|
||||
})
|
||||
}
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:profile') {
|
||||
const profile =
|
||||
featherProfiles.find(
|
||||
(item) => item.id === Number(request.body.profileId),
|
||||
) ?? featherProfiles[0]
|
||||
if (featherBlockedProfileIds.includes(profile.id)) {
|
||||
response.json({ success: false, error: 'profile_not_found' })
|
||||
return
|
||||
}
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
profile,
|
||||
posts: featherPosts.filter((post) => post.profile_id === profile.id),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:thread') {
|
||||
const post = featherPosts.find((item) => item.id === request.body.id)
|
||||
response.json(
|
||||
post
|
||||
? {
|
||||
success: true,
|
||||
data: {
|
||||
post,
|
||||
replies: featherPosts.filter(
|
||||
(item) =>
|
||||
item.reply_to_id === post.id &&
|
||||
!featherBlockedProfileIds.includes(item.profile_id),
|
||||
),
|
||||
},
|
||||
}
|
||||
: { success: false, error: 'post_not_found' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:activities') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: testScenario === 'feather-empty' ? [] : featherActivities,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:mark-activities') {
|
||||
featherActivities.forEach((item) => {
|
||||
item.read = true
|
||||
})
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:delete') {
|
||||
const post = featherPosts.find((item) => item.id === request.body.id)
|
||||
if (!post || !post.is_owner) {
|
||||
response.json({ success: false, error: 'post_not_found' })
|
||||
return
|
||||
}
|
||||
if (post.reply_to_id) {
|
||||
const parent = featherPosts.find((item) => item.id === post.reply_to_id)
|
||||
if (parent) parent.reply_count = Math.max(0, parent.reply_count - 1)
|
||||
}
|
||||
const deletedIds = new Set(
|
||||
featherPosts
|
||||
.filter((item) => item.id === post.id || item.reply_to_id === post.id)
|
||||
.map((item) => item.id),
|
||||
)
|
||||
featherPosts = featherPosts.filter((item) => !deletedIds.has(item.id))
|
||||
if (!post.reply_to_id)
|
||||
featherProfiles[0].post_count = Math.max(
|
||||
0,
|
||||
featherProfiles[0].post_count - 1,
|
||||
)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:block') {
|
||||
const profileId = Number(request.body.profileId)
|
||||
const profile = featherProfiles.find((item) => item.id === profileId)
|
||||
if (!profile || profile.is_owner) {
|
||||
response.json({ success: false, error: 'profile_not_found' })
|
||||
return
|
||||
}
|
||||
if (!featherBlockedProfileIds.includes(profileId))
|
||||
featherBlockedProfileIds.push(profileId)
|
||||
if (featherFollowingIds.includes(profileId)) {
|
||||
featherFollowingIds = featherFollowingIds.filter((id) => id !== profileId)
|
||||
featherProfiles[0].following = Math.max(
|
||||
0,
|
||||
featherProfiles[0].following - 1,
|
||||
)
|
||||
}
|
||||
profile.is_following = false
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'feather:report') {
|
||||
const post = featherPosts.find((item) => item.id === request.body.id)
|
||||
const reason = String(request.body.reason ?? '')
|
||||
if (
|
||||
!post ||
|
||||
!['spam', 'harassment', 'dangerous', 'illegal', 'other'].includes(reason)
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
featherReports.push({
|
||||
id: `feather-report-${Date.now()}`,
|
||||
post_id: post.id,
|
||||
reason,
|
||||
details: String(request.body.details ?? '').slice(0, 500),
|
||||
created_at: Date.now(),
|
||||
})
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'radio:get') {
|
||||
response.json({ success: true, data: radioData })
|
||||
return
|
||||
@@ -2190,10 +3051,12 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'development:bootstrap') {
|
||||
if (testScenario === 'feather-onboarding') featherOnboarded = false
|
||||
else featherOnboarded = true
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
account: linkedAccount,
|
||||
account: testScenario === 'feather-login' ? null : linkedAccount,
|
||||
device: {
|
||||
data: deviceData,
|
||||
imei: '356938035643809',
|
||||
|
||||
@@ -283,6 +283,22 @@ Config.FlipTok = {
|
||||
ReportAdminGroups = { "admin" },
|
||||
}
|
||||
|
||||
Config.Feather = {
|
||||
PageSize = 20,
|
||||
TextMaxLength = 360,
|
||||
BioMaxLength = 160,
|
||||
DisplayNameMaxLength = 50,
|
||||
HandleMaxLength = 30,
|
||||
SearchMaxLength = 80,
|
||||
HashtagMaxLength = 64,
|
||||
TrendingTopicLimit = 10,
|
||||
ReportDetailsMaxLength = 500,
|
||||
MaxImages = 4,
|
||||
PostsPerMinute = 8,
|
||||
ActionsPerMinute = 90,
|
||||
ReportsPerDay = 10,
|
||||
}
|
||||
|
||||
Config.MapMarkers = {
|
||||
MaximumMarkers = 50,
|
||||
LabelMaxLength = 40,
|
||||
|
||||
@@ -102,6 +102,43 @@ Locales["en"] = {
|
||||
match_not_found = "This match is no longer available.", invalid_message = "Write a message before sending.", invalid_attachment = "This attachment is unavailable.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", rate_limited = "Slow down for a moment and try again.", 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", following = "Following", forYou = "For You", verified = "Verified account",
|
||||
originalSound = "original sound", save = "Save", home = "Home", discover = "Discover", create = "Create", activity = "Activity", profile = "Profile",
|
||||
|
||||
@@ -59,6 +59,7 @@ server_scripts {
|
||||
'source/server/marketplace.lua',
|
||||
'source/server/pages.lua',
|
||||
'source/server/fliptok.lua',
|
||||
'source/server/feather.lua',
|
||||
'source/server/map.lua',
|
||||
'source/server/calendar.lua',
|
||||
'source/server/radio.lua',
|
||||
|
||||
@@ -82,6 +82,24 @@ local server_callbacks = {
|
||||
"fliptok:admin-resolve-report",
|
||||
"fliptok:block",
|
||||
"fliptok:delete",
|
||||
"feather:bootstrap",
|
||||
"feather:create-profile",
|
||||
"feather:update-profile",
|
||||
"feather:feed",
|
||||
"feather:explore",
|
||||
"feather:network",
|
||||
"feather:create-post",
|
||||
"feather:react",
|
||||
"feather:follow",
|
||||
"feather:connections",
|
||||
"feather:remove-connection",
|
||||
"feather:profile",
|
||||
"feather:thread",
|
||||
"feather:activities",
|
||||
"feather:mark-activities",
|
||||
"feather:delete",
|
||||
"feather:block",
|
||||
"feather:report",
|
||||
"calendar:list",
|
||||
"calendar:create",
|
||||
"calendar:update",
|
||||
@@ -450,6 +468,14 @@ RegisterNetEvent("sky_phone:fliptok:new", function(data)
|
||||
SendNUIMessage({ type = "fliptok:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:feather:new", function(data)
|
||||
local feather_locale = get_locale().Nui.Apps.feather
|
||||
local notification_text = feather_locale.notifications[data.kind] or feather_locale.notifications.default
|
||||
data.title = feather_locale.name
|
||||
data.text = notification_text:gsub("{actor}", tostring(data.actor or ""))
|
||||
SendNUIMessage({ type = "feather:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:marketplace:new-message", function(data)
|
||||
local marketplace_locale = get_locale().Nui.Apps.citymarkt
|
||||
data.title = marketplace_locale.name
|
||||
|
||||
@@ -1224,9 +1224,190 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_feather_profiles",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "handle", type = "VARCHAR(30) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ name = "display_name", type = "VARCHAR(50) NOT NULL" },
|
||||
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "verified", type = "TINYINT(1) NOT NULL DEFAULT 0" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_feather_account", columns = "(`account_id`)" },
|
||||
{ name = "uniq_sky_phone_feather_handle", columns = "(`handle`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_feather_posts",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "body", type = "VARCHAR(360) NOT NULL DEFAULT ''" },
|
||||
{ name = "reply_to_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "quote_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "status", type = "ENUM('published', 'removed') NOT NULL DEFAULT 'published'" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_feather_feed", columns = "(`status`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_feather_profile", columns = "(`profile_id`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_feather_replies", columns = "(`reply_to_id`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "profile_id", references = "`sky_phone_feather_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "reply_to_id", references = "`sky_phone_feather_posts` (`id`) ON DELETE SET NULL" },
|
||||
{ column = "quote_id", references = "`sky_phone_feather_posts` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_feather_hashtags",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "tag", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_feather_hashtag", columns = "(`post_id`, `tag`)" }},
|
||||
indexes = {{ name = "idx_sky_phone_feather_hashtag_rank", columns = "(`tag`, `post_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "post_id", references = "`sky_phone_feather_posts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_feather_post_media",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_feather_media_order", columns = "(`post_id`, `sort_order`)" },
|
||||
{ name = "uniq_sky_phone_feather_media", columns = "(`post_id`, `media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "post_id", references = "`sky_phone_feather_posts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_feather_reactions",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "kind", type = "ENUM('like', 'bookmark') NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_feather_reaction", columns = "(`post_id`, `profile_id`, `kind`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "post_id", references = "`sky_phone_feather_posts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "profile_id", references = "`sky_phone_feather_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_feather_follows",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "follower_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "following_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_feather_follow", columns = "(`follower_id`, `following_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "follower_id", references = "`sky_phone_feather_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "following_id", references = "`sky_phone_feather_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_feather_notifications",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "recipient_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "actor_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "post_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "kind", type = "ENUM('like', 'reply', 'follow', 'quote') NOT NULL" },
|
||||
{ name = "read_at", type = "DATETIME NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {{ name = "idx_sky_phone_feather_activity", columns = "(`recipient_id`, `created_at`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "recipient_id", references = "`sky_phone_feather_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "actor_id", references = "`sky_phone_feather_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "post_id", references = "`sky_phone_feather_posts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_feather_blocks",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "blocker_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "blocked_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_feather_block", columns = "(`blocker_id`, `blocked_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "blocker_id", references = "`sky_phone_feather_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "blocked_id", references = "`sky_phone_feather_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_feather_reports",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reporter_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reason", type = "ENUM('spam', 'harassment', 'dangerous', 'illegal', 'other') NOT NULL" },
|
||||
{ name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
|
||||
{ name = "status", type = "ENUM('open', 'reviewed', 'dismissed') NOT NULL DEFAULT 'open'" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_feather_report", columns = "(`reporter_id`, `post_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "reporter_id", references = "`sky_phone_feather_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "post_id", references = "`sky_phone_feather_posts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_feather_notifications` WHERE `kind` = 'repost'", {})
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_feather_reactions` WHERE `kind` = 'repost'", {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_feather_reactions`
|
||||
MODIFY COLUMN `kind` ENUM('like', 'bookmark') NOT NULL
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_feather_notifications`
|
||||
MODIFY COLUMN `kind` ENUM('like', 'reply', 'follow', 'quote') NOT NULL
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_flare_swipes`
|
||||
MODIFY COLUMN `choice` ENUM('like', 'pass', 'superlike') NOT NULL
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local reaction_kinds = { like = true, bookmark = true }
|
||||
local report_reasons = { spam = true, harassment = true, dangerous = true, illegal = true, other = true }
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then return nil end
|
||||
return value:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function valid_text(value, minimum, maximum)
|
||||
local length = type(value) == "string" and utf8.len(value) or nil
|
||||
return length and length >= minimum and length <= maximum
|
||||
end
|
||||
|
||||
local function new_id()
|
||||
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
if not rows[1] or type(rows[1].id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a Feather id.")
|
||||
end
|
||||
return rows[1].id
|
||||
end
|
||||
|
||||
local function normalize_handle(value)
|
||||
local handle = trim(value)
|
||||
if not handle then return nil end
|
||||
handle = handle:lower():gsub("^@", "")
|
||||
if #handle < 3 or #handle > Config.Feather.HandleMaxLength
|
||||
or not handle:match("^[a-z0-9][a-z0-9_]*[a-z0-9]$")
|
||||
then
|
||||
return nil
|
||||
end
|
||||
return handle
|
||||
end
|
||||
|
||||
local function require_profile(source)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return nil, error_response end
|
||||
local rows = Bridge.Database.Query(
|
||||
"SELECT * FROM `sky_phone_feather_profiles` WHERE `account_id` = ? LIMIT 1",
|
||||
{ account.id }
|
||||
)
|
||||
if not rows[1] then return nil, { success = false, error = "feather_profile_required" } end
|
||||
return rows[1], nil
|
||||
end
|
||||
|
||||
local function are_blocked(first_id, second_id)
|
||||
return Bridge.Database.Query([[SELECT `id` FROM `sky_phone_feather_blocks` WHERE
|
||||
(`blocker_id` = ? AND `blocked_id` = ?) OR (`blocker_id` = ? AND `blocked_id` = ?) LIMIT 1]], {
|
||||
first_id, second_id, second_id, first_id,
|
||||
})[1] ~= nil
|
||||
end
|
||||
|
||||
local function hydrate_profile(profile, viewer_id)
|
||||
profile.id = tonumber(profile.id)
|
||||
profile.verified = tonumber(profile.verified) == 1
|
||||
profile.is_owner = profile.id == viewer_id
|
||||
profile.is_following = tonumber(profile.is_following) == 1
|
||||
profile.followers = tonumber(profile.followers) or 0
|
||||
profile.following = tonumber(profile.following) or 0
|
||||
profile.post_count = tonumber(profile.post_count) or 0
|
||||
return profile
|
||||
end
|
||||
|
||||
local function load_profile(profile_id, viewer_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`verified`, m.`url` AS `avatar_url`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = p.`id`) AS `is_following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`following_id` = p.`id`) AS `followers`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = p.`id`) AS `following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_posts` fp WHERE fp.`profile_id` = p.`id` AND fp.`status` = 'published') AS `post_count`
|
||||
FROM `sky_phone_feather_profiles` p
|
||||
LEFT JOIN `sky_phone_media` m ON m.`id` = p.`avatar_media_id`
|
||||
WHERE p.`id` = ? LIMIT 1
|
||||
]], { viewer_id, profile_id })
|
||||
return rows[1] and hydrate_profile(rows[1], viewer_id) or nil
|
||||
end
|
||||
|
||||
local function hydrate_posts(rows, viewer_id)
|
||||
local ids = {}
|
||||
for _, post in ipairs(rows) do
|
||||
post.profile_id = tonumber(post.profile_id)
|
||||
post.verified = tonumber(post.verified) == 1
|
||||
post.is_owner = post.profile_id == viewer_id
|
||||
post.is_following = tonumber(post.is_following) == 1
|
||||
post.is_liked = tonumber(post.is_liked) == 1
|
||||
post.is_bookmarked = tonumber(post.is_bookmarked) == 1
|
||||
post.like_count = tonumber(post.like_count) or 0
|
||||
post.reply_count = tonumber(post.reply_count) or 0
|
||||
post.created_at = (tonumber(post.created_at_unix) or 0) * 1000
|
||||
post.created_at_unix = nil
|
||||
post.media = {}
|
||||
ids[#ids + 1] = post.id
|
||||
end
|
||||
if #ids == 0 then return rows end
|
||||
|
||||
local placeholders = {}
|
||||
for index = 1, #ids do placeholders[index] = "?" end
|
||||
local media_rows = Bridge.Database.Query(([[
|
||||
SELECT pm.`post_id`, pm.`media_id` AS `id`, m.`url`, m.`media_type`
|
||||
FROM `sky_phone_feather_post_media` pm
|
||||
JOIN `sky_phone_media` m ON m.`id` = pm.`media_id`
|
||||
WHERE pm.`post_id` IN (%s) ORDER BY pm.`post_id`, pm.`sort_order`
|
||||
]]):format(table.concat(placeholders, ",")), ids)
|
||||
local posts = {}
|
||||
for _, post in ipairs(rows) do posts[post.id] = post end
|
||||
for _, media in ipairs(media_rows) do
|
||||
media.id = tonumber(media.id)
|
||||
posts[media.post_id].media[#posts[media.post_id].media + 1] = media
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function list_posts(viewer_id, condition, values, ranking, offset)
|
||||
local parameters = { viewer_id, viewer_id, viewer_id, viewer_id }
|
||||
for _, value in ipairs(values) do parameters[#parameters + 1] = value end
|
||||
parameters[#parameters + 1] = viewer_id
|
||||
parameters[#parameters + 1] = viewer_id
|
||||
parameters[#parameters + 1] = Config.Feather.PageSize + 1
|
||||
parameters[#parameters + 1] = offset
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT fp.`id`, fp.`profile_id`, fp.`body`, fp.`reply_to_id`, fp.`quote_id`,
|
||||
UNIX_TIMESTAMP(fp.`created_at`) AS `created_at_unix`, p.`handle`, p.`display_name`, p.`verified`, avatar.`url` AS `avatar_url`,
|
||||
(fp.`profile_id` = ?) AS `is_owner`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = fp.`profile_id`) AS `is_following`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_feather_reactions` r WHERE r.`profile_id` = ? AND r.`post_id` = fp.`id` AND r.`kind` = 'like') AS `is_liked`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_feather_reactions` r WHERE r.`profile_id` = ? AND r.`post_id` = fp.`id` AND r.`kind` = 'bookmark') AS `is_bookmarked`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_reactions` r WHERE r.`post_id` = fp.`id` AND r.`kind` = 'like') AS `like_count`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_posts` reply WHERE reply.`reply_to_id` = fp.`id` AND reply.`status` = 'published') AS `reply_count`
|
||||
FROM `sky_phone_feather_posts` fp
|
||||
JOIN `sky_phone_feather_profiles` p ON p.`id` = fp.`profile_id`
|
||||
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
|
||||
WHERE fp.`status` = 'published' AND %s
|
||||
AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_blocks` b WHERE
|
||||
(b.`blocker_id` = ? AND b.`blocked_id` = fp.`profile_id`) OR
|
||||
(b.`blocked_id` = ? AND b.`blocker_id` = fp.`profile_id`))
|
||||
ORDER BY %s LIMIT ? OFFSET ?
|
||||
]]):format(condition, ranking), parameters)
|
||||
local has_more = #rows > Config.Feather.PageSize
|
||||
if has_more then rows[#rows] = nil end
|
||||
return { items = hydrate_posts(rows, viewer_id), offset = offset, hasMore = has_more }
|
||||
end
|
||||
|
||||
local function notify_profile(recipient_id, actor, kind, post_id)
|
||||
if recipient_id == tonumber(actor.id) then return end
|
||||
local recipients = Bridge.Database.Query(
|
||||
"SELECT `account_id` FROM `sky_phone_feather_profiles` WHERE `id` = ? LIMIT 1",
|
||||
{ recipient_id }
|
||||
)
|
||||
if not recipients[1] then return end
|
||||
Bridge.Database.Query([[INSERT INTO `sky_phone_feather_notifications`
|
||||
(`id`, `recipient_id`, `actor_id`, `post_id`, `kind`) VALUES (?, ?, ?, ?, ?)]], {
|
||||
new_id(), recipient_id, actor.id, post_id, kind,
|
||||
})
|
||||
SkyPhone.NotifyAccountDevices(tonumber(recipients[1].account_id), "sky_phone:feather:new", {
|
||||
actor = actor.display_name,
|
||||
kind = kind,
|
||||
postId = post_id,
|
||||
})
|
||||
end
|
||||
|
||||
local function suggestions(profile_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`verified`, m.`url` AS `avatar_url`,
|
||||
0 AS `is_following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`following_id` = p.`id`) AS `followers`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = p.`id`) AS `following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_posts` fp WHERE fp.`profile_id` = p.`id` AND fp.`status` = 'published') AS `post_count`
|
||||
FROM `sky_phone_feather_profiles` p
|
||||
LEFT JOIN `sky_phone_media` m ON m.`id` = p.`avatar_media_id`
|
||||
WHERE p.`id` <> ? AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = p.`id`)
|
||||
AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_blocks` b WHERE
|
||||
(b.`blocker_id` = ? AND b.`blocked_id` = p.`id`) OR
|
||||
(b.`blocked_id` = ? AND b.`blocker_id` = p.`id`))
|
||||
ORDER BY `followers` DESC, p.`created_at` DESC LIMIT 5
|
||||
]], { profile_id, profile_id, profile_id, profile_id })
|
||||
for _, row in ipairs(rows) do hydrate_profile(row, profile_id) end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function search_profiles(profile_id, search)
|
||||
local pattern = "%" .. search:gsub("[%%_]", "\\%0") .. "%"
|
||||
local prefix = search:gsub("[%%_]", "\\%0") .. "%"
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`verified`, m.`url` AS `avatar_url`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = p.`id`) AS `is_following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`following_id` = p.`id`) AS `followers`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = p.`id`) AS `following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_posts` fp WHERE fp.`profile_id` = p.`id` AND fp.`status` = 'published') AS `post_count`
|
||||
FROM `sky_phone_feather_profiles` p
|
||||
LEFT JOIN `sky_phone_media` m ON m.`id` = p.`avatar_media_id`
|
||||
WHERE p.`id` <> ? AND (p.`handle` LIKE ? OR p.`display_name` LIKE ?)
|
||||
AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_blocks` b WHERE
|
||||
(b.`blocker_id` = ? AND b.`blocked_id` = p.`id`) OR
|
||||
(b.`blocked_id` = ? AND b.`blocker_id` = p.`id`))
|
||||
ORDER BY (p.`handle` = ?) DESC, (p.`handle` LIKE ?) DESC, `followers` DESC, p.`created_at` DESC
|
||||
LIMIT ?
|
||||
]], {
|
||||
profile_id, profile_id, pattern, pattern, profile_id, profile_id,
|
||||
search, prefix, Config.Feather.PageSize,
|
||||
})
|
||||
for _, row in ipairs(rows) do hydrate_profile(row, profile_id) end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function list_connections(profile_id, viewer_id, mode)
|
||||
local join_condition = mode == "followers"
|
||||
and "connection.`follower_id` = p.`id`"
|
||||
or "connection.`following_id` = p.`id`"
|
||||
local owner_condition = mode == "followers"
|
||||
and "connection.`following_id` = ?"
|
||||
or "connection.`follower_id` = ?"
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`verified`, avatar.`url` AS `avatar_url`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_feather_follows` viewer_follow WHERE viewer_follow.`follower_id` = ? AND viewer_follow.`following_id` = p.`id`) AS `is_following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`following_id` = p.`id`) AS `followers`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_follows` f WHERE f.`follower_id` = p.`id`) AS `following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_feather_posts` fp WHERE fp.`profile_id` = p.`id` AND fp.`status` = 'published') AS `post_count`
|
||||
FROM `sky_phone_feather_follows` connection
|
||||
JOIN `sky_phone_feather_profiles` p ON %s
|
||||
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
|
||||
WHERE %s AND NOT EXISTS(SELECT 1 FROM `sky_phone_feather_blocks` b WHERE
|
||||
(b.`blocker_id` = ? AND b.`blocked_id` = p.`id`) OR
|
||||
(b.`blocked_id` = ? AND b.`blocker_id` = p.`id`))
|
||||
ORDER BY p.`display_name`, p.`handle`
|
||||
]]):format(join_condition, owner_condition), { viewer_id, profile_id, viewer_id, viewer_id })
|
||||
for _, row in ipairs(rows) do hydrate_profile(row, viewer_id) end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function extract_hashtags(body)
|
||||
local hashtags = {}
|
||||
local seen = {}
|
||||
for tag in body:gmatch("#([%w_]+)") do
|
||||
local normalized = tag:lower()
|
||||
if #normalized <= Config.Feather.HashtagMaxLength and not seen[normalized] then
|
||||
seen[normalized] = true
|
||||
hashtags[#hashtags + 1] = normalized
|
||||
end
|
||||
end
|
||||
return hashtags
|
||||
end
|
||||
|
||||
local function trending_hashtags()
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT h.`tag`, COUNT(*) AS `post_count`
|
||||
FROM `sky_phone_feather_hashtags` h
|
||||
JOIN `sky_phone_feather_posts` fp ON fp.`id` = h.`post_id` AND fp.`status` = 'published'
|
||||
GROUP BY h.`tag`
|
||||
ORDER BY `post_count` DESC, h.`tag` ASC
|
||||
LIMIT ?
|
||||
]], { Config.Feather.TrendingTopicLimit })
|
||||
local topics = {}
|
||||
for _, row in ipairs(rows) do
|
||||
topics[#topics + 1] = { tag = "#" .. row.tag, count = tonumber(row.post_count) or 0 }
|
||||
end
|
||||
return topics
|
||||
end
|
||||
|
||||
local function backfill_hashtags()
|
||||
local posts = Bridge.Database.Query([[
|
||||
SELECT fp.`id`, fp.`body` FROM `sky_phone_feather_posts` fp
|
||||
WHERE fp.`status` = 'published' AND fp.`body` LIKE '%#%'
|
||||
]], {})
|
||||
local statements = {}
|
||||
for _, post in ipairs(posts) do
|
||||
for _, tag in ipairs(extract_hashtags(post.body)) do
|
||||
statements[#statements + 1] = {
|
||||
query = "INSERT IGNORE INTO `sky_phone_feather_hashtags` (`post_id`, `tag`) VALUES (?, ?)",
|
||||
params = { post.id, tag },
|
||||
}
|
||||
if #statements == 500 then
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
error("[sky_phone] Failed to backfill Feather hashtags.")
|
||||
end
|
||||
statements = {}
|
||||
end
|
||||
end
|
||||
end
|
||||
if #statements > 0 and not Bridge.Database.Transaction(statements) then
|
||||
error("[sky_phone] Failed to backfill Feather hashtags.")
|
||||
end
|
||||
end
|
||||
|
||||
backfill_hashtags()
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:bootstrap", function(source)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
local rows = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
|
||||
if not rows[1] then
|
||||
return { success = true, data = { onboarded = false, topics = trending_hashtags() } }
|
||||
end
|
||||
local profile_id = tonumber(rows[1].id)
|
||||
return { success = true, data = {
|
||||
onboarded = true,
|
||||
profile = load_profile(profile_id, profile_id),
|
||||
feed = list_posts(profile_id, "fp.`reply_to_id` IS NULL", {}, "fp.`created_at` DESC", 0),
|
||||
suggestions = suggestions(profile_id),
|
||||
topics = trending_hashtags(),
|
||||
} }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:create-profile", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "feather:create-profile", 5, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local handle = normalize_handle(data.handle)
|
||||
local display_name = trim(data.displayName)
|
||||
local bio = trim(data.bio) or ""
|
||||
if not handle then return { success = false, error = "invalid_handle" } end
|
||||
if not valid_text(display_name, 1, Config.Feather.DisplayNameMaxLength) then return { success = false, error = "invalid_display_name" } end
|
||||
if not valid_text(bio, 0, Config.Feather.BioMaxLength) then return { success = false, error = "invalid_bio" } end
|
||||
local avatar_id = nil
|
||||
if data.avatarId then
|
||||
local media, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.avatarId, "photo")
|
||||
if not media then return { success = false, error = media_error } end
|
||||
avatar_id = math.floor(tonumber(data.avatarId))
|
||||
end
|
||||
local existing = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
|
||||
if existing[1] then return { success = false, error = "already_registered" } end
|
||||
local duplicate = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `handle` = ? LIMIT 1", { handle })
|
||||
if duplicate[1] then return { success = false, error = "handle_taken" } end
|
||||
Bridge.Database.Query([[INSERT INTO `sky_phone_feather_profiles`
|
||||
(`account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`) VALUES (?, ?, ?, ?, ?)]], {
|
||||
account.id, handle, display_name, bio, avatar_id,
|
||||
})
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:update-profile", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local display_name = trim(data.displayName)
|
||||
local bio = trim(data.bio) or ""
|
||||
if not valid_text(display_name, 1, Config.Feather.DisplayNameMaxLength) then return { success = false, error = "invalid_display_name" } end
|
||||
if not valid_text(bio, 0, Config.Feather.BioMaxLength) then return { success = false, error = "invalid_bio" } end
|
||||
local avatar_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil
|
||||
if data.avatarId then
|
||||
local media, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.avatarId, "photo")
|
||||
if not media then return { success = false, error = media_error } end
|
||||
avatar_id = math.floor(tonumber(data.avatarId))
|
||||
end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_feather_profiles` SET `display_name` = ?, `bio` = ?, `avatar_media_id` = ? WHERE `id` = ?", {
|
||||
display_name, bio, avatar_id, profile.id,
|
||||
})
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:feed", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local offset = math.max(0, math.floor(tonumber(data.offset) or 0))
|
||||
local condition = "fp.`reply_to_id` IS NULL"
|
||||
local values = {}
|
||||
local ranking = "(SELECT COUNT(*) FROM `sky_phone_feather_reactions` rank_reactions WHERE rank_reactions.`post_id` = fp.`id`) DESC, fp.`created_at` DESC"
|
||||
if data.mode == "following" then
|
||||
condition = "fp.`reply_to_id` IS NULL AND (fp.`profile_id` = ? OR EXISTS(SELECT 1 FROM `sky_phone_feather_follows` follow_filter WHERE follow_filter.`follower_id` = ? AND follow_filter.`following_id` = fp.`profile_id`))"
|
||||
values = { profile.id, profile.id }
|
||||
ranking = "fp.`created_at` DESC"
|
||||
end
|
||||
return { success = true, data = list_posts(tonumber(profile.id), condition, values, ranking, offset) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:explore", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "feather:explore", Config.Feather.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local search = trim(data.search) or ""
|
||||
if not valid_text(search, 0, Config.Feather.SearchMaxLength) then return { success = false, error = "invalid_search" } end
|
||||
local condition = "fp.`reply_to_id` IS NULL"
|
||||
local values = {}
|
||||
if search ~= "" then
|
||||
condition = "fp.`body` LIKE ?"
|
||||
local pattern = "%" .. search:gsub("[%%_]", "\\%0") .. "%"
|
||||
values = { pattern }
|
||||
end
|
||||
return { success = true, data = {
|
||||
posts = list_posts(tonumber(profile.id), condition, values, "fp.`created_at` DESC", 0).items,
|
||||
profiles = suggestions(tonumber(profile.id)),
|
||||
topics = search == "" and trending_hashtags() or nil,
|
||||
} }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:network", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "feather:network", Config.Feather.ActionsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local search = trim(data.search) or ""
|
||||
if not valid_text(search, 0, Config.Feather.SearchMaxLength) then
|
||||
return { success = false, error = "invalid_search" }
|
||||
end
|
||||
search = search:gsub("^@", "")
|
||||
local profile_id = tonumber(profile.id)
|
||||
return { success = true, data = {
|
||||
results = search ~= "" and search_profiles(profile_id, search) or {},
|
||||
suggestions = suggestions(profile_id),
|
||||
} }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:create-post", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "feather:create-post", Config.Feather.PostsPerMinute, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local body = trim(data.body) or ""
|
||||
local media_ids = type(data.mediaIds) == "table" and data.mediaIds or {}
|
||||
if not valid_text(body, #media_ids > 0 and 0 or 1, Config.Feather.TextMaxLength) then return { success = false, error = "invalid_post" } end
|
||||
if #media_ids > Config.Feather.MaxImages then return { success = false, error = "too_many_images" } end
|
||||
local reply_to_id = type(data.replyToId) == "string" and data.replyToId or nil
|
||||
local quote_id = type(data.quoteId) == "string" and data.quoteId or nil
|
||||
local target_id = reply_to_id or quote_id
|
||||
local target = nil
|
||||
if target_id then
|
||||
local targets = Bridge.Database.Query([[SELECT fp.`id`, fp.`profile_id` FROM `sky_phone_feather_posts` fp
|
||||
WHERE fp.`id` = ? AND fp.`status` = 'published' LIMIT 1]], { target_id })
|
||||
target = targets[1]
|
||||
if not target then return { success = false, error = "post_not_found" } end
|
||||
if are_blocked(tonumber(profile.id), tonumber(target.profile_id)) then return { success = false, error = "profile_blocked" } end
|
||||
end
|
||||
local normalized_media = {}
|
||||
local seen = {}
|
||||
for _, value in ipairs(media_ids) do
|
||||
local id = tonumber(value)
|
||||
if not id or seen[id] then return { success = false, error = "invalid_attachment" } end
|
||||
local media, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, id, "photo")
|
||||
if not media then return { success = false, error = media_error } end
|
||||
seen[id] = true
|
||||
normalized_media[#normalized_media + 1] = math.floor(id)
|
||||
end
|
||||
local post_id = new_id()
|
||||
local statements = {{
|
||||
query = "INSERT INTO `sky_phone_feather_posts` (`id`, `profile_id`, `body`, `reply_to_id`, `quote_id`) VALUES (?, ?, ?, ?, ?)",
|
||||
params = { post_id, profile.id, body, reply_to_id, quote_id },
|
||||
}}
|
||||
for index, media_id in ipairs(normalized_media) do
|
||||
statements[#statements + 1] = {
|
||||
query = "INSERT INTO `sky_phone_feather_post_media` (`post_id`, `media_id`, `sort_order`) VALUES (?, ?, ?)",
|
||||
params = { post_id, media_id, index - 1 },
|
||||
}
|
||||
end
|
||||
for _, tag in ipairs(extract_hashtags(body)) do
|
||||
statements[#statements + 1] = {
|
||||
query = "INSERT INTO `sky_phone_feather_hashtags` (`post_id`, `tag`) VALUES (?, ?)",
|
||||
params = { post_id, tag },
|
||||
}
|
||||
end
|
||||
if not Bridge.Database.Transaction(statements) then return { success = false, error = "request_failed" } end
|
||||
if target then notify_profile(tonumber(target.profile_id), profile, reply_to_id and "reply" or "quote", post_id) end
|
||||
return { success = true, data = { id = post_id } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:react", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "feather:react", Config.Feather.ActionsPerMinute, 60) then return { success = false, error = "rate_limited" } end
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local kind = data.kind
|
||||
if not reaction_kinds[kind] or type(data.id) ~= "string" or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end
|
||||
local posts = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_feather_posts` WHERE `id` = ? AND `status` = 'published' LIMIT 1", { data.id })
|
||||
if not posts[1] then return { success = false, error = "post_not_found" } end
|
||||
if are_blocked(tonumber(profile.id), tonumber(posts[1].profile_id)) then return { success = false, error = "profile_blocked" } end
|
||||
if data.active then
|
||||
Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_feather_reactions` (`post_id`, `profile_id`, `kind`) VALUES (?, ?, ?)", { data.id, profile.id, kind })
|
||||
if kind ~= "bookmark" then notify_profile(tonumber(posts[1].profile_id), profile, kind, data.id) end
|
||||
else
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_feather_reactions` WHERE `post_id` = ? AND `profile_id` = ? AND `kind` = ?", { data.id, profile.id, kind })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:follow", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local target_id = math.floor(tonumber(data.profileId) or 0)
|
||||
if target_id < 1 or target_id == tonumber(profile.id) or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end
|
||||
local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `id` = ? LIMIT 1", { target_id })
|
||||
if not targets[1] then return { success = false, error = "profile_not_found" } end
|
||||
if are_blocked(tonumber(profile.id), target_id) then return { success = false, error = "profile_blocked" } end
|
||||
if data.active then
|
||||
Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_feather_follows` (`follower_id`, `following_id`) VALUES (?, ?)", { profile.id, target_id })
|
||||
notify_profile(target_id, profile, "follow", nil)
|
||||
else
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_feather_follows` WHERE `follower_id` = ? AND `following_id` = ?", { profile.id, target_id })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:connections", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local target_id = math.floor(tonumber(data.profileId) or tonumber(profile.id))
|
||||
local mode = data.mode
|
||||
if target_id < 1 or (mode ~= "followers" and mode ~= "following") then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local target = load_profile(target_id, tonumber(profile.id))
|
||||
if not target then return { success = false, error = "profile_not_found" } end
|
||||
if are_blocked(tonumber(profile.id), target_id) then return { success = false, error = "profile_blocked" } end
|
||||
return { success = true, data = {
|
||||
items = list_connections(target_id, tonumber(profile.id), mode),
|
||||
} }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:remove-connection", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local target_id = math.floor(tonumber(data.profileId) or 0)
|
||||
local mode = data.mode
|
||||
if target_id < 1 or target_id == tonumber(profile.id) or (mode ~= "followers" and mode ~= "following") then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
if mode == "followers" then
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_feather_follows` WHERE `follower_id` = ? AND `following_id` = ?", { target_id, profile.id })
|
||||
else
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_feather_follows` WHERE `follower_id` = ? AND `following_id` = ?", { profile.id, target_id })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:profile", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local target_id = tonumber(data.profileId) or tonumber(profile.id)
|
||||
if data.handle then
|
||||
local handle = normalize_handle(data.handle)
|
||||
local rows = handle and Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `handle` = ? LIMIT 1", { handle }) or {}
|
||||
target_id = rows[1] and tonumber(rows[1].id) or 0
|
||||
end
|
||||
local target = load_profile(target_id, tonumber(profile.id))
|
||||
if not target then return { success = false, error = "profile_not_found" } end
|
||||
if are_blocked(tonumber(profile.id), target_id) then return { success = false, error = "profile_blocked" } end
|
||||
return { success = true, data = {
|
||||
profile = target,
|
||||
posts = list_posts(tonumber(profile.id), "fp.`profile_id` = ?", { target_id }, "fp.`created_at` DESC", 0).items,
|
||||
} }
|
||||
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
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end
|
||||
local parent = list_posts(tonumber(profile.id), "fp.`id` = ?", { data.id }, "fp.`created_at` DESC", 0).items
|
||||
if not parent[1] then return { success = false, error = "post_not_found" } end
|
||||
local replies = list_posts(tonumber(profile.id), "fp.`reply_to_id` = ?", { data.id }, "fp.`created_at` ASC", 0).items
|
||||
return { success = true, data = { post = parent[1], replies = replies } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:activities", function(source)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT n.`id`, n.`kind`, n.`post_id`, UNIX_TIMESTAMP(n.`created_at`) AS `created_at_unix`, n.`read_at`,
|
||||
actor.`id` AS `profile_id`, actor.`handle`, actor.`display_name`, actor.`verified`, avatar.`url` AS `avatar_url`
|
||||
FROM `sky_phone_feather_notifications` n
|
||||
JOIN `sky_phone_feather_profiles` actor ON actor.`id` = n.`actor_id`
|
||||
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = actor.`avatar_media_id`
|
||||
WHERE n.`recipient_id` = ? ORDER BY n.`created_at` DESC LIMIT 100
|
||||
]], { profile.id })
|
||||
for _, row in ipairs(rows) do
|
||||
row.profile_id = tonumber(row.profile_id)
|
||||
row.verified = tonumber(row.verified) == 1
|
||||
row.read = row.read_at ~= nil
|
||||
row.read_at = nil
|
||||
row.created_at = (tonumber(row.created_at_unix) or 0) * 1000
|
||||
row.created_at_unix = nil
|
||||
end
|
||||
return { success = true, data = rows }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:mark-activities", function(source)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_feather_notifications` SET `read_at` = COALESCE(`read_at`, NOW()) WHERE `recipient_id` = ?", { profile.id })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:delete", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_feather_posts` SET `status` = 'removed' WHERE `id` = ? AND `profile_id` = ?", { data.id, profile.id })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:block", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local target_id = type(data) == "table" and math.floor(tonumber(data.profileId) or 0) or 0
|
||||
if target_id < 1 or target_id == tonumber(profile.id) then return { success = false, error = "invalid_request" } end
|
||||
local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_profiles` WHERE `id` = ? LIMIT 1", { target_id })
|
||||
if not targets[1] then return { success = false, error = "profile_not_found" } end
|
||||
if not Bridge.Database.Transaction({
|
||||
{ query = "INSERT IGNORE INTO `sky_phone_feather_blocks` (`blocker_id`, `blocked_id`) VALUES (?, ?)", params = { profile.id, target_id } },
|
||||
{ query = "DELETE FROM `sky_phone_feather_follows` WHERE (`follower_id` = ? AND `following_id` = ?) OR (`follower_id` = ? AND `following_id` = ?)", params = { profile.id, target_id, target_id, profile.id } },
|
||||
}) then return { success = false, error = "request_failed" } end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:feather:report", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "feather:report", Config.Feather.ReportsPerDay, 86400) then return { success = false, error = "rate_limited" } end
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local reason = data.reason
|
||||
local details = trim(data.details) or ""
|
||||
if type(data.id) ~= "string" or not report_reasons[reason] or not valid_text(details, 0, Config.Feather.ReportDetailsMaxLength) then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local posts = Bridge.Database.Query("SELECT `id` FROM `sky_phone_feather_posts` WHERE `id` = ? AND `status` = 'published' LIMIT 1", { data.id })
|
||||
if not posts[1] then return { success = false, error = "post_not_found" } end
|
||||
Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_feather_reports`
|
||||
(`id`, `reporter_id`, `post_id`, `reason`, `details`) VALUES (?, ?, ?, ?, ?)]], {
|
||||
new_id(), profile.id, data.id, reason, details,
|
||||
})
|
||||
return { success = true }
|
||||
end)
|
||||
end)
|
||||
@@ -423,3 +423,81 @@ CREATE TABLE IF NOT EXISTS `sky_phone_flare_messages` (
|
||||
FOREIGN KEY (`match_id`) REFERENCES `sky_phone_flare_matches` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`sender_account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_feather_profiles` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`handle` VARCHAR(30) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, `display_name` VARCHAR(50) NOT NULL,
|
||||
`bio` VARCHAR(160) NOT NULL DEFAULT '', `avatar_media_id` BIGINT UNSIGNED NULL, `verified` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_feather_account` (`account_id`), UNIQUE KEY `uniq_sky_phone_feather_handle` (`handle`),
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_feather_posts` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `profile_id` BIGINT UNSIGNED NOT NULL, `body` VARCHAR(360) NOT NULL DEFAULT '',
|
||||
`reply_to_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, `quote_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`status` ENUM('published','removed') NOT NULL DEFAULT 'published', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), KEY `idx_sky_phone_feather_feed` (`status`,`created_at`), KEY `idx_sky_phone_feather_profile` (`profile_id`,`created_at`),
|
||||
KEY `idx_sky_phone_feather_replies` (`reply_to_id`,`created_at`),
|
||||
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`reply_to_id`) REFERENCES `sky_phone_feather_posts` (`id`) ON DELETE SET NULL,
|
||||
FOREIGN KEY (`quote_id`) REFERENCES `sky_phone_feather_posts` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_feather_hashtags` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`tag` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_feather_hashtag` (`post_id`,`tag`),
|
||||
KEY `idx_sky_phone_feather_hashtag_rank` (`tag`,`post_id`),
|
||||
FOREIGN KEY (`post_id`) REFERENCES `sky_phone_feather_posts` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_feather_post_media` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`media_id` BIGINT UNSIGNED NOT NULL, `sort_order` TINYINT UNSIGNED NOT NULL, PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_feather_media_order` (`post_id`,`sort_order`), UNIQUE KEY `uniq_sky_phone_feather_media` (`post_id`,`media_id`),
|
||||
FOREIGN KEY (`post_id`) REFERENCES `sky_phone_feather_posts` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_feather_reactions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` BIGINT UNSIGNED NOT NULL, `kind` ENUM('like','bookmark') NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_feather_reaction` (`post_id`,`profile_id`,`kind`),
|
||||
FOREIGN KEY (`post_id`) REFERENCES `sky_phone_feather_posts` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_feather_follows` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `follower_id` BIGINT UNSIGNED NOT NULL, `following_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_feather_follow` (`follower_id`,`following_id`),
|
||||
FOREIGN KEY (`follower_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`following_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_feather_notifications` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `recipient_id` BIGINT UNSIGNED NOT NULL, `actor_id` BIGINT UNSIGNED NOT NULL,
|
||||
`post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, `kind` ENUM('like','reply','follow','quote') NOT NULL,
|
||||
`read_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_feather_activity` (`recipient_id`,`created_at`),
|
||||
FOREIGN KEY (`recipient_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`actor_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`post_id`) REFERENCES `sky_phone_feather_posts` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_feather_blocks` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `blocker_id` BIGINT UNSIGNED NOT NULL, `blocked_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_feather_block` (`blocker_id`,`blocked_id`),
|
||||
FOREIGN KEY (`blocker_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`blocked_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_feather_reports` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `reporter_id` BIGINT UNSIGNED NOT NULL,
|
||||
`post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `reason` ENUM('spam','harassment','dangerous','illegal','other') NOT NULL,
|
||||
`details` VARCHAR(500) NOT NULL DEFAULT '', `status` ENUM('open','reviewed','dismissed') NOT NULL DEFAULT 'open',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_feather_report` (`reporter_id`,`post_id`),
|
||||
FOREIGN KEY (`reporter_id`) REFERENCES `sky_phone_feather_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`post_id`) REFERENCES `sky_phone_feather_posts` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
Reference in New Issue
Block a user