diff --git a/README.md b/README.md index 836763f..17faf5f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/frontend/package.json b/frontend/package.json index 7c86e8f..69b2228 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a21e397..d63a2b6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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): 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 diff --git a/frontend/src/assets/img/app-icons/feather.svg b/frontend/src/assets/img/app-icons/feather.svg new file mode 100644 index 0000000..6f3a090 --- /dev/null +++ b/frontend/src/assets/img/app-icons/feather.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/components/feather/FeatherPostCard.vue b/frontend/src/components/feather/FeatherPostCard.vue new file mode 100644 index 0000000..338806a --- /dev/null +++ b/frontend/src/components/feather/FeatherPostCard.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 6cde0b3..a313a6d 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -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', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index a029391..9bc418b 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -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( diff --git a/frontend/src/stores/feather.test.ts b/frontend/src/stores/feather.test.ts new file mode 100644 index 0000000..939e017 --- /dev/null +++ b/frontend/src/stores/feather.test.ts @@ -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([]) + }) +}) diff --git a/frontend/src/stores/feather.ts b/frontend/src/stores/feather.ts new file mode 100644 index 0000000..8e16f99 --- /dev/null +++ b/frontend/src/stores/feather.ts @@ -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 { + this.loading = true + const response = await nuiCall('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 { + 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 { + const response = await nuiCall('feather:update-profile', data) + if (response.success) await this.bootstrap() + return response + }, + async loadFeed(mode?: FeedMode): Promise { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const response = await nuiCall('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 { + const response = await nuiCall('feather:thread', { id }) + if (!response.success || !response.data) return false + this.thread = response.data + return true + }, + async loadActivities(): Promise { + const response = await nuiCall('feather:activities') + this.activities = response.success && response.data ? response.data : [] + }, + async markActivities(): Promise { + const response = await nuiCall('feather:mark-activities') + if (response.success) + this.activities.forEach((item) => (item.read = true)) + }, + async deletePost(id: string): Promise { + 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 { + 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 { + return (await nuiCall('feather:report', { details, id, reason })).success + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index e14bbc1..e915c58 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -191,6 +191,204 @@ const defaultLocales: LocaleTree = { default: 'Flare could not complete the request.', }, }, + feather: { + name: 'Feather', + loading: 'Loading Feather', + home: 'Home', + explore: 'Explore', + activity: 'Notifications', + activityNav: 'Alerts', + profile: 'Profile', + back: 'Back', + settings: 'Settings', + settingsEyebrow: 'Feather preferences', + compactMode: 'Compact timeline', + compactModeBody: 'Show more conversation on the screen.', + showSuggestions: 'Profile suggestions', + showSuggestionsBody: 'Show people to follow on your profile.', + forYou: 'For you', + following: 'Following', + add: 'Add', + network: 'Network', + networkTitle: 'Find your people', + networkBody: + 'Follow voices from across Los Santos and build a timeline that feels like yours.', + networkSearchPlaceholder: 'Search names or @usernames', + postSearchPlaceholder: 'Search posts or #hashtags', + searchResults: 'Search results', + suggestedPeople: 'Suggested for you', + noPeopleFound: 'No people found', + noPeopleFoundBody: 'Try another name or username.', + noSuggestions: 'No suggestions yet', + noSuggestionsBody: 'New Feather accounts will appear here.', + all: 'All', + mentions: 'Mentions', + media: 'Media', + trending: 'Trending', + peopleTab: 'People', + searchPlaceholder: 'Search Feather', + search: 'Search', + cancel: 'Cancel', + done: 'Done', + authEyebrow: 'Feather network', + authWelcome: 'Welcome to Feather', + authBody: 'One iFruit account. Every conversation, wherever you sign in.', + login: 'Log in', + register: 'Register', + loginTitle: 'Good to see you again', + loginBody: 'Log in with your iFruit address to continue to Feather.', + registerTitle: 'Create your iFruit account', + registerBody: + 'Choose an address and secure it with a password. Your Feather profile comes next.', + email: 'iFruit address', + emailPlaceholder: 'yourname', + password: 'Password', + passwordPlaceholder: '6–64 characters', + confirmPassword: 'Confirm password', + confirmPasswordPlaceholder: 'Enter it again', + showPassword: 'Show password', + hidePassword: 'Hide password', + loginAction: 'Continue to Feather', + registerAction: 'Create account', + noAccount: 'New to Feather?', + haveAccount: 'Already registered?', + registerNow: 'Create an account', + loginNow: 'Log in', + authTrust: + 'Your credentials are verified by the server and this phone is linked to your iFruit account.', + profileStep: 'Step 2 of 2', + accountConnected: 'iFruit account connected', + authErrors: { + invalid_email: 'Enter a valid 3–32 character iFruit address.', + invalid_password: 'Password must be 6–64 characters.', + password_mismatch: 'The passwords do not match.', + invalid_credentials: 'The iFruit address or password is incorrect.', + email_taken: 'That iFruit address is already registered.', + rate_limited: 'Too many attempts. Try again in a minute.', + default: 'The account request failed. Please try again.', + }, + welcome: 'Find your voice', + welcomeBody: + 'Join the live conversation in Los Santos. Your Feather profile stays linked to your iFruit account.', + createProfile: 'Create Feather profile', + profileDetailsHint: 'Choose how people will recognize you.', + displayName: 'Display name', + displayNamePlaceholder: 'Your name', + handle: 'Username', + handlePlaceholder: 'your_username', + handleHint: '3-30 letters, numbers or underscores', + bio: 'Bio', + bioPlaceholder: 'What should people know about you?', + start: 'Join Feather', + chooseAvatar: 'Choose profile photo', + changeAvatar: 'Change photo', + newPost: 'New feather', + reply: 'Reply', + replyingTo: 'Replying to @{handle}', + quote: 'Quote', + post: 'Post', + posting: 'Posting...', + composerPlaceholder: "What's happening?", + replyPlaceholder: 'Post your reply', + addPhotos: 'Add photos', + removePhoto: 'Remove photo {number}', + charactersLeft: '{count} left', + composerAudience: 'Everyone can join the conversation', + mediaTitle: 'Add to your feather', + mediaBody: 'Share up to four photos from your phone or take a new one.', + chooseGallery: 'Gallery', + chooseGalleryBody: 'Choose one or more saved photos', + takePhoto: 'Camera', + takePhotoBody: 'Take a new photo now', + selectedPhotos: 'Selected photos', + photoLimit: 'You can add up to four photos.', + emptyFeed: 'Your timeline is quiet', + emptyFeedBody: 'Follow people or post the first feather.', + emptyExplore: 'No results found', + emptyExploreBody: 'Try another search or explore a topic.', + noTrendingHashtags: 'No hashtags yet', + noTrendingHashtagsBody: 'Hashtags from published posts will appear here.', + happeningNow: "What's happening", + trends: 'Trends for you', + trendingIn: 'Trending in Los Santos', + openTopic: 'View conversation', + postsCount: '{count} posts', + exploreTabs: { + explore: 'Explore', + trending: 'Trends', + news: 'News', + }, + trendKinds: { + explore: 'Trending in Los Santos', + trending: 'Popular right now', + news: 'News · Trending', + sports: 'Sports · Trending', + entertainment: 'Entertainment · Trending', + }, + people: 'Who to follow', + posts: 'Posts', + followers: 'Followers', + followingCount: 'Following', + removeConnection: 'Remove', + followerRemoved: 'Follower removed.', + followingRemoved: 'Account unfollowed.', + noConnections: 'Nobody here yet', + noConnectionsBody: 'Profiles will appear here as connections are made.', + follow: 'Follow', + unfollow: 'Following', + editProfile: 'Edit profile', + saveProfile: 'Save', + joined: 'Joined Feather', + joinedDate: 'Joined August 2026', + shareProfile: 'Share profile', + profileCopied: 'Profile handle copied.', + postCopied: 'Post copied.', + verified: 'Verified profile', + noBio: 'No bio yet.', + showMore: 'Show more', + replies: 'Replies', + noReplies: 'No replies yet. Start the conversation.', + likes: 'Likes', + bookmarks: 'Bookmarks', + delete: 'Delete', + report: 'Report', + block: 'Block @{handle}', + reportTitle: 'Report feather', + reportBody: 'Tell us why this feather should be reviewed.', + reportSubmit: 'Submit report', + reported: 'Report submitted.', + blocked: 'Profile blocked.', + deleted: 'Feather deleted.', + reasons: { + spam: 'Spam', + harassment: 'Harassment', + dangerous: 'Dangerous content', + illegal: 'Illegal content', + other: 'Other', + }, + noActivity: 'No activity yet', + activityBody: 'Likes, replies and new followers will appear here.', + activityKinds: { + like: 'liked your feather', + reply: 'replied to your feather', + follow: 'followed you', + quote: 'quoted your feather', + }, + errors: { + accountRequired: 'Sign in to iFruit in Settings first.', + invalidHandle: 'Use 3–30 letters, numbers or underscores.', + handleTaken: 'That handle is already taken.', + invalidPost: 'Write something or attach a photo.', + generic: 'Something went wrong. Please try again.', + }, + notifications: { + default: 'You have new activity.', + like: '{actor} liked your feather.', + reply: '{actor} replied to your feather.', + follow: '{actor} followed you.', + quote: '{actor} quoted your feather.', + }, + }, fliptok: { name: 'FlipTok', loading: 'Loading FlipTok', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 0a0c31d..04662c9 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -29,6 +29,7 @@ export type PhoneAppId = | 'local-pages' | 'flare' | 'fliptok' + | 'feather' export type LaunchablePhoneAppId = PhoneAppId diff --git a/frontend/src/types/feather.ts b/frontend/src/types/feather.ts new file mode 100644 index 0000000..e83097d --- /dev/null +++ b/frontend/src/types/feather.ts @@ -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[] +} diff --git a/frontend/src/utils/nui.ts b/frontend/src/utils/nui.ts index d189ddd..670160f 100644 --- a/frontend/src/utils/nui.ts +++ b/frontend/src/utils/nui.ts @@ -14,12 +14,20 @@ export async function nuiCall( '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', }) diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 39ab24f..24045bb 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -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 }, diff --git a/frontend/src/views/apps/FeatherApp.vue b/frontend/src/views/apps/FeatherApp.vue new file mode 100644 index 0000000..2d0869a --- /dev/null +++ b/frontend/src/views/apps/FeatherApp.vue @@ -0,0 +1,4637 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index cad4cc7..0a2e7ed 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -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', diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index d9f9f43..2afb997 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -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, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 244c088..3056c88 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -102,6 +102,43 @@ Locales["en"] = { match_not_found = "This match is no longer available.", invalid_message = "Write a message before sending.", invalid_attachment = "This attachment is unavailable.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", rate_limited = "Slow down for a moment and try again.", default = "Flare could not complete the request.", }, }, + feather = { + name = "Feather", loading = "Loading Feather", home = "Home", explore = "Explore", activity = "Notifications", activityNav = "Alerts", profile = "Profile", back = "Back", + settings = "Settings", settingsEyebrow = "Feather preferences", compactMode = "Compact timeline", compactModeBody = "Show more conversation on the screen.", showSuggestions = "Profile suggestions", showSuggestionsBody = "Show people to follow on your profile.", + forYou = "For you", following = "Following", add = "Add", network = "Network", networkTitle = "Find your people", networkBody = "Follow voices from across Los Santos and build a timeline that feels like yours.", networkSearchPlaceholder = "Search names or @usernames", postSearchPlaceholder = "Search posts or #hashtags", searchResults = "Search results", suggestedPeople = "Suggested for you", noPeopleFound = "No people found", noPeopleFoundBody = "Try another name or username.", noSuggestions = "No suggestions yet", noSuggestionsBody = "New Feather accounts will appear here.", all = "All", mentions = "Mentions", media = "Media", trending = "Trending", peopleTab = "People", searchPlaceholder = "Search Feather", search = "Search", cancel = "Cancel", done = "Done", + authEyebrow = "Feather network", authWelcome = "Welcome to Feather", authBody = "One iFruit account. Every conversation, wherever you sign in.", + login = "Log in", register = "Register", loginTitle = "Good to see you again", loginBody = "Log in with your iFruit address to continue to Feather.", + registerTitle = "Create your iFruit account", registerBody = "Choose an address and secure it with a password. Your Feather profile comes next.", + email = "iFruit address", emailPlaceholder = "yourname", password = "Password", passwordPlaceholder = "6–64 characters", + confirmPassword = "Confirm password", confirmPasswordPlaceholder = "Enter it again", showPassword = "Show password", hidePassword = "Hide password", + loginAction = "Continue to Feather", registerAction = "Create account", noAccount = "New to Feather?", haveAccount = "Already registered?", + registerNow = "Create an account", loginNow = "Log in", authTrust = "Your credentials are verified by the server and this phone is linked to your iFruit account.", + profileStep = "Step 2 of 2", accountConnected = "iFruit account connected", + authErrors = { + invalid_email = "Enter a valid 3–32 character iFruit address.", invalid_password = "Password must be 6–64 characters.", + password_mismatch = "The passwords do not match.", invalid_credentials = "The iFruit address or password is incorrect.", + email_taken = "That iFruit address is already registered.", rate_limited = "Too many attempts. Try again in a minute.", + default = "The account request failed. Please try again.", + }, + welcome = "Find your voice", welcomeBody = "Join the live conversation in Los Santos. Your Feather profile stays linked to your iFruit account.", + createProfile = "Create Feather profile", profileDetailsHint = "Choose how people will recognize you.", displayName = "Display name", displayNamePlaceholder = "Your name", handle = "Username", handlePlaceholder = "your_username", handleHint = "3-30 letters, numbers or underscores", + bio = "Bio", bioPlaceholder = "What should people know about you?", start = "Join Feather", chooseAvatar = "Choose profile photo", changeAvatar = "Change photo", + newPost = "New feather", reply = "Reply", replyingTo = "Replying to @{handle}", quote = "Quote", post = "Post", posting = "Posting...", + composerPlaceholder = "What's happening?", replyPlaceholder = "Post your reply", addPhotos = "Add photos", removePhoto = "Remove photo {number}", charactersLeft = "{count} left", composerAudience = "Everyone can join the conversation", mediaTitle = "Add to your feather", mediaBody = "Share up to four photos from your phone or take a new one.", chooseGallery = "Gallery", chooseGalleryBody = "Choose one or more saved photos", takePhoto = "Camera", takePhotoBody = "Take a new photo now", selectedPhotos = "Selected photos", photoLimit = "You can add up to four photos.", + emptyFeed = "Your timeline is quiet", emptyFeedBody = "Follow people or post the first feather.", emptyExplore = "No results found", emptyExploreBody = "Try another search or explore a topic.", noTrendingHashtags = "No hashtags yet", noTrendingHashtagsBody = "Hashtags from published posts will appear here.", + happeningNow = "What's happening", trends = "Trends for you", trendingIn = "Trending in Los Santos", openTopic = "View conversation", postsCount = "{count} posts", + exploreTabs = { explore = "Explore", trending = "Trends", news = "News" }, + trendKinds = { explore = "Trending in Los Santos", trending = "Popular right now", news = "News · Trending", sports = "Sports · Trending", entertainment = "Entertainment · Trending" }, + people = "Who to follow", posts = "Posts", followers = "Followers", followingCount = "Following", removeConnection = "Remove", followerRemoved = "Follower removed.", followingRemoved = "Account unfollowed.", noConnections = "Nobody here yet", noConnectionsBody = "Profiles will appear here as connections are made.", follow = "Follow", unfollow = "Following", + editProfile = "Edit profile", saveProfile = "Save", joined = "Joined Feather", joinedDate = "Joined August 2026", shareProfile = "Share profile", profileCopied = "Profile handle copied.", postCopied = "Post copied.", verified = "Verified profile", noBio = "No bio yet.", showMore = "Show more", + replies = "Replies", noReplies = "No replies yet. Start the conversation.", likes = "Likes", bookmarks = "Bookmarks", delete = "Delete", report = "Report", block = "Block @{handle}", + reportTitle = "Report feather", reportBody = "Tell us why this feather should be reviewed.", reportSubmit = "Submit report", reported = "Report submitted.", blocked = "Profile blocked.", deleted = "Feather deleted.", + reasons = { spam = "Spam", harassment = "Harassment", dangerous = "Dangerous content", illegal = "Illegal content", other = "Other" }, + noActivity = "No activity yet", activityBody = "Likes, replies and new followers will appear here.", + activityKinds = { like = "liked your feather", reply = "replied to your feather", follow = "followed you", quote = "quoted your feather" }, + errors = { accountRequired = "Sign in to iFruit in Settings first.", invalidHandle = "Use 3–30 letters, numbers or underscores.", handleTaken = "That handle is already taken.", invalidPost = "Write something or attach a photo.", generic = "Something went wrong. Please try again." }, + notifications = { default = "You have new activity.", like = "{actor} liked your feather.", reply = "{actor} replied to your feather.", follow = "{actor} followed you.", quote = "{actor} quoted your feather." }, + }, fliptok = { name = "FlipTok", loading = "Loading FlipTok", following = "Following", forYou = "For You", verified = "Verified account", originalSound = "original sound", save = "Save", home = "Home", discover = "Discover", create = "Create", activity = "Activity", profile = "Profile", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index f1dd2e2..6e14cbd 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -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', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 3aad653..f3d7a67 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -82,6 +82,24 @@ local server_callbacks = { "fliptok:admin-resolve-report", "fliptok:block", "fliptok:delete", + "feather:bootstrap", + "feather:create-profile", + "feather:update-profile", + "feather:feed", + "feather:explore", + "feather:network", + "feather:create-post", + "feather:react", + "feather:follow", + "feather:connections", + "feather:remove-connection", + "feather:profile", + "feather:thread", + "feather:activities", + "feather:mark-activities", + "feather:delete", + "feather:block", + "feather:report", "calendar:list", "calendar:create", "calendar:update", @@ -450,6 +468,14 @@ RegisterNetEvent("sky_phone:fliptok:new", function(data) SendNUIMessage({ type = "fliptok:new", data = data }) end) +RegisterNetEvent("sky_phone:feather:new", function(data) + local feather_locale = get_locale().Nui.Apps.feather + local notification_text = feather_locale.notifications[data.kind] or feather_locale.notifications.default + data.title = feather_locale.name + data.text = notification_text:gsub("{actor}", tostring(data.actor or "")) + SendNUIMessage({ type = "feather:new", data = data }) +end) + RegisterNetEvent("sky_phone:marketplace:new-message", function(data) local marketplace_locale = get_locale().Nui.Apps.citymarkt data.title = marketplace_locale.name diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index aa61d1f..09530fe 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -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 diff --git a/sky_phone/source/server/feather.lua b/sky_phone/source/server/feather.lua new file mode 100644 index 0000000..d28ae19 --- /dev/null +++ b/sky_phone/source/server/feather.lua @@ -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) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 6a92bf3..2a9667b 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -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;