From 2ca96504d359d97478a7d4b952bad3aa8282a024 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Sun, 9 Aug 2026 01:50:11 +0200 Subject: [PATCH] ADD - implement FlipTok creator platform --- README.md | 20 + frontend/src/App.vue | 44 + frontend/src/assets/img/app-icons/fliptok.svg | 18 + frontend/src/components/PhoneMediaCapture.vue | 45 +- frontend/src/config/apps.test.ts | 9 +- frontend/src/config/apps.ts | 15 + frontend/src/stores/fliptok.test.ts | 133 + frontend/src/stores/fliptok.ts | 208 ++ frontend/src/stores/phone.ts | 120 + frontend/src/types/apps.ts | 1 + frontend/src/types/fliptok.ts | 97 + frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/CameraApp.vue | 155 +- frontend/src/views/apps/FlipTokApp.vue | 2473 +++++++++++++++++ frontend/src/views/apps/GalleryApp.vue | 30 +- frontend/testserver/index.cjs | 318 +++ sky_phone/config/config.lua | 12 + sky_phone/config/locales/en.lua | 35 +- sky_phone/fxmanifest.lua | 2 + .../source/bridge/server/frameworks/esx.lua | 14 + .../source/bridge/server/frameworks/qb.lua | 8 + .../source/bridge/server/frameworks/qbox.lua | 8 + sky_phone/source/client/main.lua | 35 + sky_phone/source/server/db_migrate.lua | 160 ++ sky_phone/source/server/fliptok.lua | 541 ++++ sky_phone/sql/install.sql | 75 + 26 files changed, 4523 insertions(+), 54 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/fliptok.svg create mode 100644 frontend/src/stores/fliptok.test.ts create mode 100644 frontend/src/stores/fliptok.ts create mode 100644 frontend/src/types/fliptok.ts create mode 100644 frontend/src/views/apps/FlipTokApp.vue create mode 100644 sky_phone/source/server/fliptok.lua diff --git a/README.md b/README.md index 428972a..1c3d429 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,25 @@ # sky_phone +## FlipTok verification + +FlipTok verification is server-authoritative and limited to the framework groups configured in +`Config.FlipTok.AdminGroups`; no command ACE is required. Use +`/fliptokverify <@handle> [on|off]`. Without `on` or `off`, the current blue-check state is toggled. +The command name is configurable through `Config.FlipTok.VerifyCommand`. +Verification command access uses `Config.FlipTok.AdminGroups`. The report moderation overview is +server-authoritative and independently restricted through `Config.FlipTok.ReportAdminGroups`. + +## FlipTok music + +Licensed music can be exposed in the composer through `Config.FlipTok.MusicTracks`. Keep the IDs +stable because published videos store the selected ID; URLs must be directly playable by the NUI. + +```lua +MusicTracks = { + { Id = "night-drive", Title = "Night Drive", Artist = "Sky Radio", Url = "https://cdn.example.com/night-drive.ogg" }, +} +``` + Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3af545f..7f9714e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -30,6 +30,7 @@ import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' import { useMessagesStore } from '@/stores/messages' import { useDarkChatStore } from '@/stores/darkchat' +import { useFlipTokStore } from '@/stores/fliptok' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' import { useAppStoreStore } from '@/stores/app-store' @@ -59,6 +60,8 @@ type AppMessage = { | MarketplaceEventData | MessagesEventData | DarkChatEventData + | FlipTokVerificationData + | FlipTokNotificationData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -114,6 +117,20 @@ type CalendarReminderData = { text?: string title?: string } + +type FlipTokVerificationData = { + profileId: number + verified: boolean +} + +type FlipTokNotificationData = { + actor?: string + device?: PhoneNotificationDevicePayload + kind?: 'like' | 'comment' | 'follow' | 'verified' + text?: string + title?: string + videoId?: string +} const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 @@ -129,6 +146,7 @@ const banking = useBankingStore() const mail = useMailStore() const messages = useMessagesStore() const darkchat = useDarkChatStore() +const fliptok = useFlipTokStore() const media = useMediaStore() const marketplace = useMarketplaceStore() const appStore = useAppStoreStore() @@ -276,6 +294,32 @@ function onMessage(event: MessageEvent): void { } else if (event.data?.type === 'marketplace:changed' && event.data.data) { const data = event.data.data as MarketplaceEventData if (data.counts) marketplace.setCounts(data.counts) + } else if ( + event.data?.type === 'fliptok:verification-changed' && + event.data.data + ) { + const data = event.data.data as FlipTokVerificationData + fliptok.applyVerification(Number(data.profileId), data.verified === true) + } else if (event.data?.type === 'fliptok:new' && event.data.data) { + const data = event.data.data as FlipTokNotificationData + const notification: PhoneNotificationInput = { + appId: 'fliptok', + subtitle: data.actor, + text: data.text ?? phone.t('Apps.fliptok.notifications.default'), + title: data.title ?? phone.t('Apps.fliptok.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 fliptok.loadActivities() } else if ( event.data?.type === 'marketplace:new-message' && event.data.data diff --git a/frontend/src/assets/img/app-icons/fliptok.svg b/frontend/src/assets/img/app-icons/fliptok.svg new file mode 100644 index 0000000..9cc1d98 --- /dev/null +++ b/frontend/src/assets/img/app-icons/fliptok.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/PhoneMediaCapture.vue b/frontend/src/components/PhoneMediaCapture.vue index cbf6c2c..cf6f48d 100644 --- a/frontend/src/components/PhoneMediaCapture.vue +++ b/frontend/src/components/PhoneMediaCapture.vue @@ -23,6 +23,7 @@ let renderFrameId: number | undefined let lastRenderAt = 0 let recorder: MediaRecorder | null = null let stream: MediaStream | null = null +let microphoneStream: MediaStream | null = null let chunks: RecordingChunk[] = [] let lastChunkAt = 0 let lastChunkTimecode: number | null = null @@ -95,7 +96,9 @@ function resetRecording(): void { function stopTracks(): void { stream?.getTracks().forEach((track) => track.stop()) + microphoneStream?.getTracks().forEach((track) => track.stop()) stream = null + microphoneStream = null } function cleanupRecording(): void { @@ -109,7 +112,7 @@ function cleanupRecording(): void { postRecordState(false) } -function startRecording(data: Record): void { +async function startRecording(data: Record): Promise { if (recorder) return if (typeof MediaRecorder === 'undefined') { window.postMessage( @@ -127,13 +130,45 @@ function startRecording(data: Record): void { } startRenderLoop() resetRecording() - stream = canvasRef.value?.captureStream(captureFps) ?? null - if (!stream) { + const videoStream = canvasRef.value?.captureStream(captureFps) ?? null + if (!videoStream) { cleanupRecording() return } + if (data.microphoneEnabled === true) { + try { + microphoneStream = await navigator.mediaDevices.getUserMedia({ + audio: { + autoGainControl: true, + echoCancellation: true, + noiseSuppression: true, + }, + }) + } catch { + videoStream.getTracks().forEach((track) => track.stop()) + cleanupRecording() + window.postMessage( + { + data: { error: 'microphone_unavailable', success: false }, + type: 'camera:recordError', + }, + '*', + ) + return + } + } + stream = new MediaStream([ + ...videoStream.getVideoTracks(), + ...(microphoneStream?.getAudioTracks() ?? []), + ]) + const mimeType = [ + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp8', + 'video/webm', + ].find((type) => MediaRecorder.isTypeSupported(type)) recorder = new MediaRecorder(stream, { - mimeType: 'video/webm', + ...(mimeType ? { mimeType } : {}), + audioBitsPerSecond: 128_000, videoBitsPerSecond: bitrateBps, }) recorder.ondataavailable = (event) => { @@ -311,7 +346,7 @@ function onMessage(event: MessageEvent): void { type?: string } if (message.type === 'camera:recordStart') { - startRecording(message.data ?? {}) + void startRecording(message.data ?? {}) } else if (message.type === 'camera:recordStop') { void stopRecording(message.data ?? {}) } else if (message.type === 'camera:recordCancel') { diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 2ef0ae6..3d3910a 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -118,7 +118,14 @@ describe('app registry', () => { PHONE_APPS.filter((app) => app.category === 'social').map( (app) => app.id, ), - ).toEqual(['local-pages', 'phone', 'darkchat', 'banking', 'mail']) + ).toEqual([ + 'fliptok', + 'local-pages', + 'phone', + 'darkchat', + 'banking', + 'mail', + ]) expect( PHONE_APPS.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index e265d84..476001f 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -52,6 +52,7 @@ import bankingIcon from '@/assets/img/app-icons/banking.svg' import garageIcon from '@/assets/img/app-icons/garage.svg' import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp' import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' +import flipTokIcon from '@/assets/img/app-icons/fliptok.svg' import type { LaunchablePhoneAppDefinition, LaunchablePhoneAppId, @@ -59,6 +60,20 @@ import type { } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + category: 'social', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/FlipTokApp.vue')), + ), + dockOrder: null, + gridOrder: 22, + icon: markRaw(Blocks), + iconClass: 'app-icon--fliptok', + iconImage: flipTokIcon, + id: 'fliptok', + labelKey: 'Apps.fliptok.name', + route: '/apps/fliptok', + }, { category: 'productivity', component: markRaw( diff --git a/frontend/src/stores/fliptok.test.ts b/frontend/src/stores/fliptok.test.ts new file mode 100644 index 0000000..a7f1dfd --- /dev/null +++ b/frontend/src/stores/fliptok.test.ts @@ -0,0 +1,133 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useFlipTokStore } from '@/stores/fliptok' +import type { + FlipTokActivity, + FlipTokComment, + FlipTokProfile, + FlipTokVideo, +} from '@/types/fliptok' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const profile: FlipTokProfile = { + account_type: 'person', + bio: '', + display_name: 'Nova', + followers: 1, + following: 2, + handle: 'nova', + id: 7, + is_following: false, + is_owner: true, + verified: false, + video_count: 1, +} + +const video: FlipTokVideo = { + caption: 'Los Santos', + comment_count: 1, + comments_enabled: true, + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'video-1', + is_following: false, + is_liked: false, + is_owner: true, + is_saved: false, + like_count: 2, + location: '', + cover_time_ms: 0, + music_artist: '', + music_title: '', + music_track: '', + music_url: '', + music_volume: 0, + original_volume: 100, + profile_id: 7, + share_count: 0, + trim_end_ms: null, + trim_start_ms: 0, + url: 'https://example.com/video.webm', + verified: false, + view_count: 3, +} + +const comment: FlipTokComment = { + body: 'Nice', + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'comment-1', + profile_id: 7, + verified: false, +} + +const activity: FlipTokActivity = { + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'activity-1', + kind: 'follow', + profile_id: 7, + read_at: null, + verified: false, + video_id: null, +} + +describe('FlipTok verification updates', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(nuiCall).mockReset() + }) + + it('updates the badge everywhere the profile is already visible', () => { + const store = useFlipTokStore() + store.profile = { ...profile } + store.feed = [{ ...video }] + store.searchResults = [{ ...video }] + store.comments = [{ ...comment }] + store.activities = [{ ...activity }] + + store.applyVerification(7, true) + + expect(store.profile.verified).toBe(true) + expect(store.feed[0].verified).toBe(true) + expect(store.searchResults[0].verified).toBe(true) + expect(store.comments[0].verified).toBe(true) + expect(store.activities[0].verified).toBe(true) + }) + + it('does not alter another profile', () => { + const store = useFlipTokStore() + store.feed = [{ ...video }] + + store.applyVerification(99, true) + + expect(store.feed[0].verified).toBe(false) + }) + + it('removes a blocked creator from every visible surface', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true }) + const store = useFlipTokStore() + store.feed = [{ ...video }] + store.searchResults = [{ ...video }] + store.profileVideos = [{ ...video }] + store.comments = [{ ...comment }] + store.activities = [{ ...activity }] + store.viewedProfile = { ...profile } + + expect(await store.blockProfile(7)).toBe(true) + expect(store.feed).toEqual([]) + expect(store.searchResults).toEqual([]) + expect(store.profileVideos).toEqual([]) + expect(store.comments).toEqual([]) + expect(store.activities).toEqual([]) + expect(store.viewedProfile).toBeNull() + }) +}) diff --git a/frontend/src/stores/fliptok.ts b/frontend/src/stores/fliptok.ts new file mode 100644 index 0000000..022bc6f --- /dev/null +++ b/frontend/src/stores/fliptok.ts @@ -0,0 +1,208 @@ +import { defineStore } from 'pinia' + +import type { + FlipTokActivity, + FlipTokComment, + FlipTokMusicTrack, + FlipTokPage, + FlipTokProfile, + FlipTokProfilePage, + FlipTokReport, + FlipTokVideo, +} from '@/types/fliptok' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +export const useFlipTokStore = defineStore('fliptok', { + state: () => ({ + activities: [] as FlipTokActivity[], + comments: [] as FlipTokComment[], + feed: [] as FlipTokVideo[], + isAdmin: false, + loading: false, + musicTracks: [] as FlipTokMusicTrack[], + mode: 'for-you' as 'for-you' | 'following', + profile: null as FlipTokProfile | null, + profileVideos: [] as FlipTokVideo[], + reports: [] as FlipTokReport[], + searchResults: [] as FlipTokVideo[], + viewedProfile: null as FlipTokProfile | null, + }), + actions: { + applyVerification(profileId: number, verified: boolean): void { + if (this.profile?.id === profileId) this.profile.verified = verified + if (this.viewedProfile?.id === profileId) + this.viewedProfile.verified = verified + this.feed + .filter((video) => video.profile_id === profileId) + .forEach((video) => { + video.verified = verified + }) + this.searchResults + .filter((video) => video.profile_id === profileId) + .forEach((video) => { + video.verified = verified + }) + this.profileVideos + .filter((video) => video.profile_id === profileId) + .forEach((video) => { + video.verified = verified + }) + this.comments + .filter((comment) => comment.profile_id === profileId) + .forEach((comment) => { + comment.verified = verified + }) + this.activities + .filter((activity) => activity.profile_id === profileId) + .forEach((activity) => { + activity.verified = verified + }) + }, + async bootstrap(): Promise { + this.loading = true + const response = await nuiCall<{ + feed: FlipTokPage + isAdmin: boolean + musicTracks: FlipTokMusicTrack[] + profile: FlipTokProfile + }>('fliptok:bootstrap') + this.loading = false + if (!response.success || !response.data) return false + this.profile = response.data.profile + this.feed = response.data.feed.items + this.isAdmin = response.data.isAdmin === true + this.musicTracks = response.data.musicTracks ?? [] + return true + }, + async loadFeed(mode?: 'for-you' | 'following'): Promise { + mode ??= this.mode + this.mode = mode + this.loading = true + const response = await nuiCall('fliptok:feed', { + mode, + offset: 0, + }) + this.loading = false + if (!response.success || !response.data) return false + this.feed = response.data.items + return true + }, + async discover(search: string): Promise { + const response = await nuiCall('fliptok:discover', { + search, + }) + this.searchResults = + response.success && response.data ? response.data : [] + return this.searchResults + }, + async react(video: FlipTokVideo, kind: 'like' | 'save'): Promise { + const key = kind === 'like' ? 'is_liked' : 'is_saved' + const next = !video[key] + video[key] = next + if (kind === 'like') video.like_count += next ? 1 : -1 + const response = await nuiCall('fliptok:react', { + active: next, + id: video.id, + kind, + }) + if (!response.success) { + video[key] = !next + if (kind === 'like') video.like_count += next ? -1 : 1 + } + }, + async follow(video: FlipTokVideo): Promise { + const next = !video.is_following + const response = await nuiCall('fliptok:follow', { + active: next, + profileId: video.profile_id, + }) + if (response.success) + this.feed + .filter((item) => item.profile_id === video.profile_id) + .forEach((item) => { + item.is_following = next + }) + }, + async followProfile(profile: FlipTokProfile): Promise { + const next = !profile.is_following + const response = await nuiCall('fliptok:follow', { + active: next, + profileId: profile.id, + }) + if (!response.success) return + profile.is_following = next + profile.followers += next ? 1 : -1 + this.feed + .filter((item) => item.profile_id === profile.id) + .forEach((item) => { + item.is_following = next + }) + }, + async loadProfile(query: { + handle?: string + profileId?: number + }): Promise { + const response = await nuiCall( + 'fliptok:profile', + query, + ) + if (!response.success || !response.data) return false + this.viewedProfile = response.data.profile + this.profileVideos = response.data.videos + return true + }, + showOwnProfile(): void { + this.viewedProfile = null + this.profileVideos = this.feed.filter((item) => item.is_owner) + }, + async blockProfile(profileId: number): Promise { + const response = await nuiCall('fliptok:block', { profileId }) + if (!response.success) return false + this.feed = this.feed.filter((video) => video.profile_id !== profileId) + this.searchResults = this.searchResults.filter( + (video) => video.profile_id !== profileId, + ) + this.comments = this.comments.filter( + (comment) => comment.profile_id !== profileId, + ) + this.activities = this.activities.filter( + (activity) => activity.profile_id !== profileId, + ) + this.profileVideos = this.profileVideos.filter( + (video) => video.profile_id !== profileId, + ) + if (this.viewedProfile?.id === profileId) this.viewedProfile = null + return true + }, + async loadComments(id: string): Promise { + const response = await nuiCall('fliptok:comments', { + id, + }) + this.comments = response.success && response.data ? response.data : [] + }, + async comment(id: string, body: string): Promise { + return nuiCall('fliptok:comment', { body, id }) + }, + async loadActivities(): Promise { + const response = await nuiCall('fliptok:activities') + this.activities = response.success && response.data ? response.data : [] + if (response.success) await nuiCall('fliptok:mark-activities') + }, + async loadReports(): Promise { + const response = await nuiCall('fliptok:admin-reports') + this.reports = response.success && response.data ? response.data : [] + return response.success + }, + async resolveReport( + id: string, + action: 'dismiss' | 'remove', + ): Promise { + const response = await nuiCall('fliptok:admin-resolve-report', { + action, + id, + }) + if (response.success) await this.loadReports() + return response.success + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 65d609e..237e506 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -40,6 +40,121 @@ const namespaceQueues = new Map>() const defaultLocales: LocaleTree = { Apps: { + 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', + emptyFeed: 'No videos yet', + emptyFeedBody: 'Follow creators or post the first FlipTok.', + searchPlaceholder: 'Search creators and videos', + noActivity: 'No activity yet', + followers: 'Followers', + videos: 'Videos', + emptyBio: 'No bio yet.', + editProfile: 'Edit profile', + newVideo: 'New FlipTok', + chooseVideo: 'Choose a video', + chooseVideoHint: 'Select one from Gallery', + changeVideo: 'Change', + captionPlaceholder: 'Write a caption...', + location: 'Add location', + whoCanWatch: 'Who can watch', + public: 'Everyone', + followersOnly: 'Followers', + private: 'Only me', + allowComments: 'Allow comments', + saveDraft: 'Drafts', + publishing: 'Posting...', + post: 'Post', + draftSaved: 'Draft saved.', + published: 'Your FlipTok is live.', + linkCopied: 'Video link copied.', + reported: 'Report submitted.', + blocked: 'Creator blocked.', + comments: 'Comments', + noComments: 'No comments yet', + addComment: 'Add comment...', + report: 'Report video', + reportReason: 'Reason', + reportDetails: 'Additional details (optional)', + submitReport: 'Submit report', + reportReasons: { + spam: 'Spam or misleading', + harassment: 'Harassment or bullying', + dangerous: 'Dangerous activity', + illegal: 'Illegal content', + other: 'Something else', + }, + block: 'Block creator', + follow: 'Follow', + unfollow: 'Following', + backToProfile: 'Back', + sounds: 'Sound', + chooseSound: 'Choose music', + originalOnly: 'Original sound only', + noMusic: 'No music tracks are configured.', + trimAndCover: 'Trim & cover', + trimStart: 'Start', + trimEnd: 'End', + coverFrame: 'Cover', + originalVolume: 'Original sound', + musicVolume: 'Music', + moderation: 'Moderation', + reports: 'Open reports', + noReports: 'No open reports', + removeVideo: 'Remove video', + dismissReport: 'Dismiss', + cancel: 'Cancel', + done: 'Done', + displayName: 'Name', + username: 'Username', + bio: 'Bio', + accountType: 'Account type', + accountTypes: { + person: 'Person', + business: 'Business', + organization: 'Organization', + media: 'Media', + event: 'Event', + }, + activityKinds: { + like: 'liked your video', + comment: 'commented on your video', + follow: 'started following you', + verified: 'verification changed', + }, + notifications: { + like: '{actor} liked your video.', + comment: '{actor} commented on your video.', + follow: '{actor} started following you.', + verified: 'Your FlipTok account is now verified.', + default: 'You have new FlipTok activity.', + }, + errors: { + invalid_video: 'Check the video details.', + invalid_media: 'Choose a video from this phone.', + invalid_comment: 'Enter a valid comment.', + comments_disabled: 'Comments are disabled.', + invalid_profile: 'Check your profile details.', + handle_taken: 'This username is already taken.', + video_not_found: 'This video is unavailable.', + rate_limited: 'Too many actions. Try again shortly.', + not_authenticated: 'Sign in to iFruit first.', + blocked: 'This account is blocked.', + not_authorized: 'You do not have moderation access.', + report_not_found: 'This report is no longer open.', + default: 'FlipTok could not complete the request.', + }, + }, darkchat: { name: 'DarkChat', newMessage: 'New DarkChat message from {sender}', @@ -1014,6 +1129,8 @@ const defaultLocales: LocaleTree = { portrait: 'Switch to portrait', photo: 'Photo', video: 'Video', + microphoneOn: 'Microphone on', + microphoneOff: 'Microphone muted', focusHelp: 'Space for movement', returnHelp: 'Space to return', uploading: '{count} uploading', @@ -1031,6 +1148,8 @@ const defaultLocales: LocaleTree = { invalid_upload: 'The upload could not be verified.', invalid_upload_token: 'The upload session is no longer valid.', missing_config: 'Camera uploads are not configured.', + microphone_unavailable: + 'Allow microphone access or mute the microphone before recording.', not_found: 'The media item no longer exists.', operation_in_progress: 'Another media operation is already in progress.', @@ -1488,6 +1607,7 @@ const defaultLocales: LocaleTree = { send: 'Send', start: 'Start', stop: 'Stop', + use: 'Use', }, Notifications: { now: 'now' }, LockScreen: { diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index fc9c11a..f08aad4 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -26,6 +26,7 @@ export type PhoneAppId = | 'neon-drop' | 'citymarkt' | 'local-pages' + | 'fliptok' export type LaunchablePhoneAppId = PhoneAppId diff --git a/frontend/src/types/fliptok.ts b/frontend/src/types/fliptok.ts new file mode 100644 index 0000000..29e790b --- /dev/null +++ b/frontend/src/types/fliptok.ts @@ -0,0 +1,97 @@ +export type FlipTokProfile = { + account_type: 'person' | 'business' | 'organization' | 'media' | 'event' + bio: string + display_name: string + followers: number + following: number + handle: string + id: number + is_following: boolean + is_owner: boolean + verified: boolean + video_count: number +} + +export type FlipTokVideo = { + caption: string + comment_count: number + comments_enabled: boolean + created_at: number + display_name: string + handle: string + id: string + is_following: boolean + is_liked: boolean + is_owner: boolean + is_saved: boolean + like_count: number + location: string + cover_time_ms: number + music_artist: string + music_title: string + music_track: string + music_url: string + music_volume: number + original_volume: number + profile_id: number + share_count: number + trim_end_ms: number | null + trim_start_ms: number + url: string + verified: boolean + view_count: number +} + +export type FlipTokMusicTrack = { + artist: string + id: string + title: string + url: string +} + +export type FlipTokReport = { + caption: string + created_at: number + creator_display_name: string + creator_handle: string + details: string + id: string + reason: 'spam' | 'harassment' | 'dangerous' | 'illegal' | 'other' + reporter_display_name: string + reporter_handle: string + url: string + video_id: string +} + +export type FlipTokProfilePage = { + profile: FlipTokProfile + videos: FlipTokVideo[] +} + +export type FlipTokComment = { + body: string + created_at: number + display_name: string + handle: string + id: string + profile_id: number + verified: boolean +} + +export type FlipTokActivity = { + created_at: number + display_name: string + handle: string + id: string + kind: 'like' | 'comment' | 'follow' | 'verified' + profile_id: number + read_at: string | null + verified: boolean + video_id: string | null +} + +export type FlipTokPage = { + hasMore: boolean + items: FlipTokVideo[] + offset: number +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 2118a05..0137885 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -68,6 +68,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< 'neon-drop': { enabled: true, sounds: true }, citymarkt: { enabled: true, sounds: true }, 'local-pages': { enabled: true, sounds: true }, + fliptok: { 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/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index 43f7487..c1e604e 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -1,14 +1,10 @@ + +