ADD - implement Feather social app

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

After

Width:  |  Height:  |  Size: 1012 B

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