From 332060b8e3d8887818e9f5d41427bc2b2aaae3d0 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Fri, 14 Aug 2026 01:28:43 +0200 Subject: [PATCH] ENH - modernize FlipTok and Messages Replace the mixed legacy Konsta presentation and fixed light surfaces with the shared Sky UI system. Add FlipTok comment replies and reactions, editable profile media, follower lists, external sound metadata, and the required schema migration. --- .../EasyShareSheet.contract.test.ts | 27 + frontend/src/components/EasyShareSheet.vue | 782 ++++- .../MessageContactBubble.contract.test.ts | 25 + .../src/components/MessageContactBubble.vue | 55 +- frontend/src/stores/fliptok.test.ts | 42 + frontend/src/stores/fliptok.ts | 32 +- frontend/src/stores/music.ts | 6 +- frontend/src/stores/phone.ts | 35 + frontend/src/types/fliptok.ts | 11 + .../views/apps/FlipTokApp.contract.test.ts | 119 + frontend/src/views/apps/FlipTokApp.vue | 3008 +++++++++++++---- .../views/apps/MessagesApp.contract.test.ts | 81 + frontend/src/views/apps/MessagesApp.vue | 1572 ++++++--- frontend/testserver/index.cjs | 219 +- sky_phone/config/locales/en.lua | 18 +- sky_phone/fxmanifest.lua | 1 + sky_phone/source/client/main.lua | 3 + sky_phone/source/server/db_migrate.lua | 34 +- sky_phone/source/server/fliptok.lua | 236 +- sky_phone/source/server/media_metadata.lua | 84 + sky_phone/source/server/music.lua | 85 +- sky_phone/sql/install.sql | 19 +- 22 files changed, 4981 insertions(+), 1513 deletions(-) create mode 100644 frontend/src/components/EasyShareSheet.contract.test.ts create mode 100644 frontend/src/components/MessageContactBubble.contract.test.ts create mode 100644 frontend/src/views/apps/FlipTokApp.contract.test.ts create mode 100644 frontend/src/views/apps/MessagesApp.contract.test.ts create mode 100644 sky_phone/source/server/media_metadata.lua diff --git a/frontend/src/components/EasyShareSheet.contract.test.ts b/frontend/src/components/EasyShareSheet.contract.test.ts new file mode 100644 index 0000000..cb93fa0 --- /dev/null +++ b/frontend/src/components/EasyShareSheet.contract.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('./EasyShareSheet.vue', import.meta.url), + 'utf8', +) + +describe('EasyShareSheet Sky UI contract', () => { + it('uses the first-party bottom sheet without Konsta markup', () => { + expect(source).not.toContain("from 'konsta/vue'") + expect(source).not.toMatch(/<\/?k-[a-z]/) + expect(source).toContain(' -import { kButton, kGlass, kLink, kList, kListItem, kPreloader, kSheet } from 'konsta/vue' import { Check, Clock3, @@ -30,6 +29,15 @@ import { easyShareDestinationAppIds, openEasySharePayload, } from '@/utils/easyshare' +import { + SkyButton, + SkyGlass, + SkyLink, + SkyList, + SkyListItem, + SkySheet, + SkySpinner, +} from '@/ui' import { consumeEscape } from '@/utils/keyboard' const phone = usePhoneStore() @@ -208,7 +216,10 @@ function openShareApp(appId: EasyShareDestinationApp): void { function saveAsNote(): void { if (!easyShare.payload) return - const body = [easyShare.payload.copyText.trim(), easyShare.payload.link?.trim()] + const body = [ + easyShare.payload.copyText.trim(), + easyShare.payload.link?.trim(), + ] .filter((part): part is string => Boolean(part)) .filter((part, index, parts) => parts.indexOf(part) === index) .join('\n') @@ -227,7 +238,10 @@ async function requestTransfer(targetId: number): Promise { : label(`errors.${response.error ?? 'request_failed'}`) } -async function respond(transfer: EasyShareTransfer, accepted: boolean): Promise { +async function respond( + transfer: EasyShareTransfer, + accepted: boolean, +): Promise { if (!(await easyShare.respond(transfer.id, accepted))) { feedback.value = label('errors.request_failed') } @@ -257,167 +271,639 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown, true)) }" :style="hostStyle" > -
-
-
- - -
- {{ app ? getPhoneAppLabel(app, phone.t) : label('name') }} - {{ easyShare.payload?.title ?? label('incoming') }} -

{{ easyShare.payload?.subtitle || easyShare.payload?.copyText }}

-
-
+
+
+ + +
+ {{ + app ? getPhoneAppLabel(app, phone.t) : label('name') + }} + {{ easyShare.payload?.title ?? label('incoming') }} +

+ {{ easyShare.payload?.subtitle || easyShare.payload?.copyText }} +

+
+
- + +
+
+ + {{ label('nearby') }} + +
+ + + +
+ {{ + label('incomingFrom', { + name: easyShare.incomingTransfer.otherName, + }) + }} + {{ easyShare.incomingTransfer.payload.title }} +
+
+ + +
+
+ + +
+ {{ easyShare.activeTransfer.otherName }} + {{ statusLabel(easyShare.activeTransfer) }} +
+
+ +
+ {{ label('cancel') }} +
+ +
+ +
+ + + + + + +

{{ label('noNearby') }}

-
- - - +
+
+ {{ phone.t('Common.back') }} + {{ label('history') }} + +
+ + + + + +

+ {{ label('noHistory') }} +

+
- - -
-
- - {{ label('nearby') }} - -
- - - -
- {{ label('incomingFrom', { name: easyShare.incomingTransfer.otherName }) }} - {{ easyShare.incomingTransfer.payload.title }} -
-
- - -
-
- - -
- {{ easyShare.activeTransfer.otherName }} - {{ statusLabel(easyShare.activeTransfer) }} -
-
- {{ label('cancel') }} -
- -
- - - - - - -

{{ label('noNearby') }}

-
- -
-
- {{ phone.t('Common.back') }} - {{ label('history') }} - -
- - - - - -

{{ label('noHistory') }}

-
-

{{ feedback }}

-
+ diff --git a/frontend/src/stores/fliptok.test.ts b/frontend/src/stores/fliptok.test.ts index 70bee90..d5ab6c8 100644 --- a/frontend/src/stores/fliptok.test.ts +++ b/frontend/src/stores/fliptok.test.ts @@ -16,6 +16,8 @@ vi.mock('@/utils/nui', () => ({ const profile: FlipTokProfile = { account_type: 'person', + avatar_media_id: null, + avatar_url: null, bio: '', display_name: 'Nova', followers: 1, @@ -29,6 +31,7 @@ const profile: FlipTokProfile = { } const video: FlipTokVideo = { + avatar_url: null, caption: 'Los Santos', comment_count: 1, comments_enabled: true, @@ -44,9 +47,11 @@ const video: FlipTokVideo = { location: '', cover_time_ms: 0, music_artist: '', + music_source: '', music_title: '', music_track: '', music_url: '', + music_video_id: '', music_volume: 0, original_volume: 100, profile_id: 7, @@ -59,16 +64,22 @@ const video: FlipTokVideo = { } const comment: FlipTokComment = { + avatar_url: null, body: 'Nice', created_at: 1, display_name: 'Nova', handle: 'nova', id: 'comment-1', + is_liked: false, + like_count: 0, + parent_id: null, profile_id: 7, + reply_to_handle: null, verified: false, } const activity: FlipTokActivity = { + avatar_url: null, created_at: 1, display_name: 'Nova', handle: 'nova', @@ -191,4 +202,35 @@ describe('FlipTok verification updates', () => { expect(await store.loadVideo(video.id)).toEqual(video) expect(nuiCall).toHaveBeenCalledWith('fliptok:video', { id: video.id }) }) + + it('likes comments optimistically and restores them on failure', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: false }) + const store = useFlipTokStore() + const visibleComment = { ...comment } + + await store.reactComment(visibleComment) + + expect(nuiCall).toHaveBeenCalledWith('fliptok:comment-react', { + active: true, + id: visibleComment.id, + }) + expect(visibleComment.is_liked).toBe(false) + expect(visibleComment.like_count).toBe(0) + }) + + it('loads the selected follower list', async () => { + const follower = { ...profile, id: 8, is_owner: false } + vi.mocked(nuiCall).mockResolvedValue({ + success: true, + data: [follower], + }) + const store = useFlipTokStore() + + expect(await store.loadConnections(profile.id, 'followers')).toBe(true) + expect(store.connections).toEqual([follower]) + expect(nuiCall).toHaveBeenCalledWith('fliptok:connections', { + mode: 'followers', + profileId: profile.id, + }) + }) }) diff --git a/frontend/src/stores/fliptok.ts b/frontend/src/stores/fliptok.ts index 2bc5960..0d24f12 100644 --- a/frontend/src/stores/fliptok.ts +++ b/frontend/src/stores/fliptok.ts @@ -17,6 +17,7 @@ export const useFlipTokStore = defineStore('fliptok', { activities: [] as FlipTokActivity[], authenticated: false, comments: [] as FlipTokComment[], + connections: [] as FlipTokProfile[], feed: [] as FlipTokVideo[], isAdmin: false, loading: false, @@ -211,8 +212,35 @@ export const useFlipTokStore = defineStore('fliptok', { }) this.comments = response.success && response.data ? response.data : [] }, - async comment(id: string, body: string): Promise { - return nuiCall('fliptok:comment', { body, id }) + async comment( + id: string, + body: string, + parentId?: string, + ): Promise { + return nuiCall('fliptok:comment', { body, id, parentId }) + }, + async reactComment(comment: FlipTokComment): Promise { + const active = !comment.is_liked + comment.is_liked = active + comment.like_count += active ? 1 : -1 + const response = await nuiCall('fliptok:comment-react', { + active, + id: comment.id, + }) + if (response.success) return + comment.is_liked = !active + comment.like_count += active ? -1 : 1 + }, + async loadConnections( + profileId: number, + mode: 'followers' | 'following', + ): Promise { + const response = await nuiCall('fliptok:connections', { + mode, + profileId, + }) + this.connections = response.success && response.data ? response.data : [] + return response.success }, async loadActivities(): Promise { const response = await nuiCall('fliptok:activities') diff --git a/frontend/src/stores/music.ts b/frontend/src/stores/music.ts index 754b4cc..5aba534 100644 --- a/frontend/src/stores/music.ts +++ b/frontend/src/stores/music.ts @@ -8,7 +8,7 @@ import type { } from '@/types/music' import { nuiCall } from '@/utils/nui' -type YouTubePlayer = { +export type YouTubePlayer = { destroy: () => void getCurrentTime: () => number getDuration: () => number @@ -19,7 +19,7 @@ type YouTubePlayer = { setVolume: (volume: number) => void } -type YouTubeApi = { +export type YouTubeApi = { Player: new ( target: HTMLElement, options: { @@ -199,7 +199,7 @@ function bindAudioEvents(): void { }) } -function loadYouTubeApi(): Promise { +export function loadYouTubeApi(): Promise { if (window.YT?.Player) return Promise.resolve(window.YT) if (youtubeApiPromise) return youtubeApiPromise youtubeApiPromise = new Promise((resolve, reject) => { diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index cad1a55..1bae593 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1344,18 +1344,24 @@ const defaultLocales: LocaleTree = { create: 'Create', activity: 'Activity', profile: 'Profile', + navigation: 'FlipTok navigation', emptyFeed: 'No videos yet', emptyFeedBody: 'Follow creators or post the first FlipTok.', searchPlaceholder: 'Search creators and videos', + clearSearch: 'Clear search', noActivity: 'No activity yet', followers: 'Followers', videos: 'Videos', emptyBio: 'No bio yet.', editProfile: 'Edit profile', newVideo: 'New FlipTok', + createTitle: 'Create your next FlipTok', + createBody: + 'Choose a clip, set the cover and sound, then decide who can watch it.', chooseVideo: 'Choose a video', chooseVideoHint: 'Select one from Gallery', changeVideo: 'Change', + caption: 'Caption', captionPlaceholder: 'Write a caption...', location: 'Add location', whoCanWatch: 'Who can watch', @@ -1374,6 +1380,10 @@ const defaultLocales: LocaleTree = { comments: 'Comments', noComments: 'No comments yet', addComment: 'Add comment...', + reply: 'Reply', + replyPlaceholder: 'Write a reply...', + replyingTo: 'Replying to {handle}', + cancelReply: 'Cancel reply', report: 'Report video', reportReason: 'Reason', reportDetails: 'Additional details (optional)', @@ -1393,6 +1403,19 @@ const defaultLocales: LocaleTree = { chooseSound: 'Choose music', originalOnly: 'Original sound only', noMusic: 'No music tracks are configured.', + customSound: 'Custom sound', + customSoundLink: 'YouTube or audio link', + customSoundHint: + 'Paste a YouTube link or a direct HTTPS audio-file link.', + customSoundPlaceholder: + 'https://youtu.be/... or https://example.com/song.mp3', + customSoundFormats: + 'YouTube, YouTube Music, Shorts, MP3, M4A, AAC, OGG, OPUS, WAV, or WEBM', + useCustomSound: 'Use sound', + loadingSound: 'Loading sound', + invalidCustomSoundLink: + 'Use a YouTube link or a direct public HTTPS audio-file link.', + customSoundLoadFailed: 'This sound could not be loaded.', trimAndCover: 'Trim & cover', trimStart: 'Start', trimEnd: 'End', @@ -1410,6 +1433,12 @@ const defaultLocales: LocaleTree = { username: 'Username', bio: 'Bio', accountType: 'Account type', + profilePhoto: 'Profile photo', + changePhoto: 'Change photo', + chooseFromGallery: 'Gallery', + takePhoto: 'Camera', + removePhoto: 'Remove photo', + noConnections: 'No profiles to show', authTitle: 'Your FlipTok account', login: 'Sign In', register: 'Register', @@ -1456,10 +1485,13 @@ const defaultLocales: LocaleTree = { }, errors: { invalid_video: 'Check the video details.', + invalid_music_url: + 'Use a valid YouTube or direct HTTPS audio-file link.', 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.', + invalid_profile_image: 'Choose a valid photo from this phone.', invalid_handle: 'Use 3–24 letters, numbers, dots, or underscores.', invalid_display_name: 'Enter a display name.', invalid_password: 'Password must be 8–72 characters.', @@ -1633,6 +1665,9 @@ const defaultLocales: LocaleTree = { officialContact: 'Official company contact', messagingUnavailable: 'This company contact does not accept messages.', filterUnread: 'Show Unread Messages', + filterLabel: 'Conversation Filter', + allMessages: 'All', + unreadMessages: 'Unread', smsLabel: 'Text Message · SMS', photo: 'Photo', gif: 'GIF', diff --git a/frontend/src/types/fliptok.ts b/frontend/src/types/fliptok.ts index 29e790b..e0a0791 100644 --- a/frontend/src/types/fliptok.ts +++ b/frontend/src/types/fliptok.ts @@ -1,5 +1,7 @@ export type FlipTokProfile = { account_type: 'person' | 'business' | 'organization' | 'media' | 'event' + avatar_media_id: number | null + avatar_url: string | null bio: string display_name: string followers: number @@ -13,6 +15,7 @@ export type FlipTokProfile = { } export type FlipTokVideo = { + avatar_url: string | null caption: string comment_count: number comments_enabled: boolean @@ -28,9 +31,11 @@ export type FlipTokVideo = { location: string cover_time_ms: number music_artist: string + music_source: '' | 'audio' | 'youtube' music_title: string music_track: string music_url: string + music_video_id: string music_volume: number original_volume: number profile_id: number @@ -69,16 +74,22 @@ export type FlipTokProfilePage = { } export type FlipTokComment = { + avatar_url: string | null body: string created_at: number display_name: string handle: string id: string + is_liked: boolean + like_count: number + parent_id: string | null profile_id: number + reply_to_handle: string | null verified: boolean } export type FlipTokActivity = { + avatar_url: string | null created_at: number display_name: string handle: string diff --git a/frontend/src/views/apps/FlipTokApp.contract.test.ts b/frontend/src/views/apps/FlipTokApp.contract.test.ts new file mode 100644 index 0000000..0fc116e --- /dev/null +++ b/frontend/src/views/apps/FlipTokApp.contract.test.ts @@ -0,0 +1,119 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('./FlipTokApp.vue', import.meta.url), + 'utf8', +) +const serverSource = readFileSync( + new URL('../../../../sky_phone/source/server/fliptok.lua', import.meta.url), + 'utf8', +) +const migrationSource = readFileSync( + new URL( + '../../../../sky_phone/source/server/db_migrate.lua', + import.meta.url, + ), + 'utf8', +) +const youtubeSource = readFileSync( + new URL('../../../../sky_phone/source/server/media_metadata.lua', import.meta.url), + 'utf8', +) + +describe('FlipTokApp Sky UI contract', () => { + it('uses first-party Sky UI without direct Konsta markup', () => { + expect(source).not.toContain("from 'konsta/vue'") + expect(source).not.toMatch(/<\/?k-[a-z]/) + expect(source).toContain(' { + expect(source).toMatch( + / { + expect(source).not.toContain('formatTimestamp(video.created_at)') + expect(source).toContain('formatTimestamp(thread.comment.created_at)') + expect(source).toContain('@click="startReply(thread.comment)"') + expect(source).toContain('@click="reactCommentWithPulse(thread.comment)"') + expect(source).toContain('comment-like-pulse') + expect(source).toContain('@keyframes comment-heart-pop') + expect(source).toContain('class="comment-row comment-row--reply"') + expect(source).toContain('form.comments-composer') + }) + + it('keeps moderation and profile actions compact and inside Sky UI', () => { + expect(source).toContain('class="moderation-button"') + expect(source).toContain('') + expect(source).toContain('.moderation-button__surface') + expect(source).toMatch(/\.profile-actions\s*\{[^}]*max-width:\s*286px/s) + }) + + it('exposes follower lists and editable profile photos', () => { + expect(source).toContain("openConnections('following')") + expect(source).toContain("openConnections('followers')") + expect(source).toContain("openProfileMedia('photos')") + expect(source).toContain("openProfileMedia('camera')") + expect(source).toContain('@click="removeProfilePhoto"') + expect(source).toContain('profilePhotoSheetOpen') + expect(source).toContain('avatarMediaId') + }) + + it('shows a transient follow confirmation', () => { + expect(source).toContain('followFeedbackIds') + expect(source).toContain('follow-dot--confirmed') + expect(source).toContain('@click="followFromFeed(video)"') + }) + + it('supports validated custom audio links in the composer', () => { + expect(source).toContain('function validCustomMusicUrl') + expect(source).toContain('customMusicDraftUrl') + expect(source).toContain('customMusicLoadFailed') + expect(source).toContain('type="url"') + expect(source).toContain('@click="chooseCustomMusic"') + expect(source).toContain( + "customMusicUrl: musicTrack.value ? '' : customMusicUrl.value", + ) + expect(source).toContain('v-if="composerMusicUrl"') + expect(source).toContain('function parseYoutubeVideoId') + expect(source).toContain("'fliptok:music-metadata'") + expect(source).toContain('playFlipTokYoutube') + }) + + it('validates and persists custom audio links on the server', () => { + expect(serverSource).toContain('local function valid_custom_music_url') + expect(serverSource).toContain('error = "invalid_music_url"') + expect(serverSource).toContain('v.`custom_music_url`') + expect(serverSource).toContain('video.custom_music_url = nil') + expect(migrationSource).toContain('name = "custom_music_url"') + expect(migrationSource).toContain('name = "custom_music_title"') + expect(migrationSource).toContain('name = "custom_music_artist"') + expect(migrationSource).toContain('collation = "ascii_bin"') + expect(youtubeSource).toContain('function SkyPhoneYouTube.ParseId') + expect(youtubeSource).toContain('function SkyPhoneYouTube.FetchMetadata') + expect(youtubeSource).toContain('www.youtube.com/oembed') + }) + + it('keeps report and profile editing surfaces rounded and safe', () => { + expect(source).toContain('class="report-sheet__actions"') + expect(source).toContain('class="profile-account-list"') + expect(source).toMatch( + /\.report-sheet\s*\{[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\)/s, + ) + expect(source).toMatch( + /\.profile-form-list,[\s\S]*?\.profile-account-list\s*\{[^}]*border-radius:\s*var\(--sky-radius-card\)/, + ) + }) +}) diff --git a/frontend/src/views/apps/FlipTokApp.vue b/frontend/src/views/apps/FlipTokApp.vue index 0d70d55..ca0124e 100644 --- a/frontend/src/views/apps/FlipTokApp.vue +++ b/frontend/src/views/apps/FlipTokApp.vue @@ -2,54 +2,33 @@ import { Bell, Bookmark, + Camera, Check, ChevronDown, Compass, Heart, Home, + ImagePlus, + Link2, MapPin, MessageCircle, MoreHorizontal, Music2, Play, Plus, + Reply, Search, ShieldAlert, + ShieldCheck, Send, Share2, TriangleAlert, + Trash2, UserRound, + UsersRound, Video, X, } from 'lucide-vue-next' -import { - kBlock, - kBlockTitle, - kButton, - kChip, - kDialog, - kDialogButton, - kGlass, - kLink, - kList, - kListButton, - kListInput, - kListItem, - kMessagebar, - kNavbar, - kNavbarBackLink, - kPage, - kPreloader, - kRange, - kSearchbar, - kSegmented, - kSegmentedButton, - kSheet, - kTabbar, - kTabbarLink, - kToast, - kToggle, -} from 'konsta/vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' @@ -57,17 +36,61 @@ import flipTokIcon from '@/assets/img/app-icons/fliptok.webp' import { useFlipTokStore } from '@/stores/fliptok' import { useEasyShareStore } from '@/stores/easyshare' import { useMessageMediaStore } from '@/stores/messageMedia' +import { + loadYouTubeApi, + type YouTubeApi, + type YouTubePlayer, +} from '@/stores/music' import { usePhoneStore } from '@/stores/phone' import type { + FlipTokComment, FlipTokProfile, FlipTokReport, FlipTokVideo, } from '@/types/fliptok' import type { PhoneMedia } from '@/types/media' +import { + SkyAppPage, + SkyBlock, + SkyBlockTitle, + SkyButton, + SkyChip, + SkyDialog, + SkyDialogButton, + SkyField, + SkyGlass, + SkyLink, + SkyList, + SkyListButton, + SkyListItem, + SkyMessagebar, + SkyNavbar, + SkyPillNavigation, + SkyRange, + SkyScrollArea, + SkySearchbar, + SkySegmented, + SkySegmentedButton, + SkySheet, + SkySpinner, + SkyToast, + SkyToggle, +} from '@/ui' import { nuiCall } from '@/utils/nui' type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile' type AuthMode = 'login' | 'register' +type ConnectionMode = 'followers' | 'following' +type ProfileMediaContext = { + draft: { + accountType: string + avatarMediaId: number + bio: string + displayName: string + handle: string + } + selectedPhoto: PhoneMedia | null +} const phone = usePhoneStore() const store = useFlipTokStore() @@ -89,13 +112,17 @@ const commentsOpen = ref(false) const actionsOpen = ref(false) const visibilitySheetOpen = ref(false) const accountTypeSheetOpen = ref(false) +const profilePhotoSheetOpen = ref(false) const composeOpen = ref(false) const profileEditOpen = ref(false) const moderationOpen = ref(false) const musicSheetOpen = ref(false) const reportSheetOpen = ref(false) +const connectionsOpen = ref(false) +const connectionsMode = ref('followers') const search = ref('') const commentBody = ref('') +const replyingTo = ref(null) const caption = ref('') const location = ref('') const visibility = ref<'public' | 'followers' | 'private'>('public') @@ -106,6 +133,13 @@ const coverTimeMs = ref(0) const originalVolume = ref(100) const musicVolume = ref(35) const musicTrack = ref('') +const customMusicUrl = ref('') +const customMusicDraftUrl = ref('') +const customMusicTitle = ref('') +const customMusicArtist = ref('') +const customMusicVideoId = ref('') +const customMusicResolving = ref(false) +const customMusicLoadFailed = ref(false) const videoDurationMs = ref(0) const previewVideo = ref(null) const composerMusic = ref(null) @@ -116,52 +150,32 @@ const reportDetails = ref('') const publishing = ref(false) const feedback = ref('') const likedPulseId = ref(null) +const commentLikePulseId = ref(null) const reactionPulse = ref<{ id: string; kind: 'like' | 'save' } | null>(null) const playbackFailedIds = ref(new Set()) +const followFeedbackIds = ref(new Set()) +const followPendingIds = ref(new Set()) const profileDraft = ref({ accountType: 'person', + avatarMediaId: 0, bio: '', displayName: '', handle: '', }) +const selectedProfilePhoto = ref(null) const videoElements = new Map() const musicElements = new Map() +let flipTokYoutubePlayer: YouTubePlayer | null = null +let flipTokYoutubeApi: YouTubeApi | null = null +let flipTokYoutubeOwner = '' +let flipTokYoutubeVideoId = '' let observer: IntersectionObserver | null = null let videoClickTimer: number | null = null let likePulseTimer: number | null = null +let commentLikePulseTimer: number | null = null let reactionPulseTimer: number | null = null let feedbackTimer: number | null = null -const darkNavbarColors = { bgIos: 'bg-black', textIos: 'text-white' } -const darkSheetColors = { bgIos: 'bg-[#151517]' } -const darkListColors = { strongBgIos: 'bg-[#1c1c1e]' } -const darkInputColors = { - bgIos: 'bg-transparent', - labelTextFocusIos: 'text-[#0a84ff]', - labelTextIos: 'text-[#a1a1a6]', -} -const darkSearchbarColors = { - inputBgIos: 'bg-[#1c1c1e]', - placeholderIos: 'placeholder-[#8e8e93]', -} -const commentMessagebarColors = { - bgIos: 'bg-[#151517]', - borderIos: 'border-[#3a3a3c]', - inputBgIos: 'bg-[#2c2c2e]', - placeholderIos: 'placeholder-[#8e8e93]', - toolbarIconIos: 'fill-[#0a84ff]', -} -const authGlassColors = { - bgIos: 'bg-white/75 dark:bg-white/[0.09]', - shadowIos: 'shadow-ios-light-glass dark:shadow-ios-dark-glass', -} -const draftButtonColors = { - tonalBgIos: 'bg-[#1c1c1e] active:bg-[#2c2c2e]', - tonalTextIos: 'text-[#64a8ff]', -} -const reportRemoveButtonColors = { - fillBgIos: 'bg-[#ff453a] active:bg-[#d93832]', - fillTextIos: 'text-white', -} +const followFeedbackTimers = new Map() const visibilityOptions = ['public', 'followers', 'private'] as const const accountTypeOptions = [ 'person', @@ -182,9 +196,34 @@ const currentProfile = computed(() => store.viewedProfile ?? store.profile) const selectedMusic = computed(() => store.musicTracks.find((track) => track.id === musicTrack.value), ) -const canPublish = computed( - () => Boolean(selectedMedia.value) && caption.value.length <= 500, +const composerMusicUrl = computed( + () => + selectedMusic.value?.url ?? + (customMusicVideoId.value ? '' : customMusicUrl.value.trim()), ) +const hasMusic = computed(() => + Boolean(selectedMusic.value || customMusicUrl.value.trim()), +) +const customMusicDraftError = computed(() => { + const value = customMusicDraftUrl.value.trim() + return value && !validCustomMusicUrl(value) ? t('invalidCustomSoundLink') : '' +}) +const canPublish = computed( + () => + Boolean(selectedMedia.value) && + caption.value.length <= 500 && + !customMusicLoadFailed.value, +) +const tabIndex = computed(() => + ['feed', 'discover', 'create', 'activity', 'profile'].indexOf(tab.value), +) +const commentThreads = computed(() => { + const roots = store.comments.filter((comment) => !comment.parent_id) + return roots.map((comment) => ({ + comment, + replies: store.comments.filter((reply) => reply.parent_id === comment.id), + })) +}) function t(key: string, values?: Record): string { return phone.t(`Apps.fliptok.${key}`, values) @@ -201,6 +240,105 @@ function compactCount(value: number): string { }).format(value) } +function parseYoutubeVideoId(value: string): string { + const trimmed = value.trim() + if (!trimmed || trimmed.length > 500) return '' + try { + const url = new URL(trimmed) + if (url.protocol !== 'https:' || url.username || url.password || url.port) { + return '' + } + const host = url.hostname.toLowerCase() + let videoId = '' + if (host === 'youtu.be' || host === 'www.youtu.be') { + videoId = url.pathname.split('/')[1] ?? '' + } else if ( + [ + 'youtube.com', + 'www.youtube.com', + 'm.youtube.com', + 'music.youtube.com', + 'youtube-nocookie.com', + 'www.youtube-nocookie.com', + ].includes(host) + ) { + videoId = + url.searchParams.get('v') ?? + url.pathname.match(/^\/(?:shorts|embed|live)\/([a-z0-9_-]+)/i)?.[1] ?? + '' + } + return /^[a-z0-9_-]{11}$/i.test(videoId) ? videoId : '' + } catch { + return '' + } +} + +function validCustomMusicUrl(value: string): boolean { + const audioExtensions = new Set([ + 'aac', + 'm4a', + 'mp3', + 'oga', + 'ogg', + 'opus', + 'wav', + 'webm', + ]) + const trimmed = value.trim() + if (parseYoutubeVideoId(trimmed)) return true + if ( + !trimmed || + trimmed.length > 2048 || + /[\s\u0000-\u001f\u007f]/.test(trimmed) + ) + return false + + try { + const url = new URL(trimmed) + const host = url.hostname.toLowerCase() + const extension = url.pathname.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() + const labels = host.split('.') + return ( + url.protocol === 'https:' && + !url.username && + !url.password && + !url.port && + host.includes('.') && + host !== 'localhost' && + !host.endsWith('.localhost') && + !host.endsWith('.local') && + !host.endsWith('.internal') && + !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) && + /^[a-z0-9.-]+$/.test(host) && + labels.every( + (label) => + label.length > 0 && + label.length <= 63 && + !label.startsWith('-') && + !label.endsWith('-'), + ) && + Boolean(extension && audioExtensions.has(extension)) + ) + } catch { + return false + } +} + +function formatTimestamp(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '' + const timestamp = new Date(value) + const now = new Date() + const sameDay = + timestamp.getFullYear() === now.getFullYear() && + timestamp.getMonth() === now.getMonth() && + timestamp.getDate() === now.getDate() + return new Intl.DateTimeFormat(undefined, { + ...(sameDay ? {} : { day: '2-digit', month: 'short' }), + hour: '2-digit', + minute: '2-digit', + }).format(timestamp) +} + function notify(message: string): void { if (feedbackTimer !== null) window.clearTimeout(feedbackTimer) feedback.value = message @@ -276,10 +414,11 @@ function setPlaybackFailed(id: string, failed: boolean): void { } async function playFeedVideo( - id: string, + video: FlipTokVideo, element: HTMLVideoElement, showNotice = false, ): Promise { + const id = video.id setPlaybackFailed(id, false) try { await element.play() @@ -290,8 +429,17 @@ async function playFeedVideo( return false } - const music = musicElements.get(id) - if (music) { + if (video.music_source === 'youtube' && video.music_video_id) { + await playFlipTokYoutube( + id, + video.music_video_id, + video.music_volume, + element.currentTime - video.trim_start_ms / 1000, + ) + } else { + pauseFlipTokYoutube() + const music = musicElements.get(id) + if (!music) return true try { await music.play() } catch (error) { @@ -311,14 +459,170 @@ function handleFeedVideoError(id: string, event: Event): void { function retryPlayback(video: FlipTokVideo): void { const element = videoElements.get(video.id) - if (element) void playFeedVideo(video.id, element, true) + if (element) void playFeedVideo(video, element, true) +} + +function clearCustomMusicDetails(): void { + customMusicTitle.value = '' + customMusicArtist.value = '' + customMusicVideoId.value = '' } function chooseMusicTrack(trackId: string): void { musicTrack.value = trackId + customMusicUrl.value = '' + customMusicDraftUrl.value = '' + clearCustomMusicDetails() + customMusicLoadFailed.value = false musicSheetOpen.value = false } +function openMusicSheet(): void { + customMusicDraftUrl.value = customMusicUrl.value + musicSheetOpen.value = true +} + +async function chooseCustomMusic(): Promise { + const value = customMusicDraftUrl.value.trim() + if (!validCustomMusicUrl(value)) return + const youtubeVideoId = parseYoutubeVideoId(value) + if (youtubeVideoId) { + customMusicResolving.value = true + const response = await nuiCall<{ + artist: string + title: string + url: string + videoId: string + }>('fliptok:music-metadata', { url: value }) + customMusicResolving.value = false + if (!response.success || !response.data) { + notify(t(`errors.${response.error ?? 'invalid_music_url'}`)) + return + } + customMusicUrl.value = response.data.url + customMusicTitle.value = response.data.title + customMusicArtist.value = response.data.artist + customMusicVideoId.value = response.data.videoId + } else { + customMusicUrl.value = value + clearCustomMusicDetails() + } + musicTrack.value = '' + customMusicLoadFailed.value = false + musicSheetOpen.value = false +} + +function markComposerMusicReady(): void { + customMusicLoadFailed.value = false +} + +function markComposerMusicFailed(): void { + if (customMusicUrl.value) customMusicLoadFailed.value = true +} + +function destroyFlipTokYoutube(): void { + try { + flipTokYoutubePlayer?.destroy() + } catch (error) { + console.error('[FlipTok] YouTube player cleanup failed.', error) + } + flipTokYoutubePlayer = null + flipTokYoutubeApi = null + flipTokYoutubeOwner = '' + flipTokYoutubeVideoId = '' + document.getElementById('sky-phone-fliptok-youtube-player')?.remove() +} + +function pauseFlipTokYoutube(owner?: string): void { + if (owner && flipTokYoutubeOwner !== owner) return + flipTokYoutubePlayer?.pauseVideo() +} + +function seekFlipTokYoutube(owner: string, seconds: number): void { + if (flipTokYoutubeOwner !== owner) return + flipTokYoutubePlayer?.seekTo(Math.max(0, seconds), true) +} + +async function playFlipTokYoutube( + owner: string, + videoId: string, + volume: number, + seconds = 0, +): Promise { + flipTokYoutubeOwner = owner + try { + const api = await loadYouTubeApi() + flipTokYoutubeApi = api + if (flipTokYoutubePlayer) { + if (flipTokYoutubeVideoId !== videoId) { + flipTokYoutubeVideoId = videoId + flipTokYoutubePlayer.loadVideoById(videoId) + } + flipTokYoutubePlayer.setVolume(volume) + flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true) + flipTokYoutubePlayer.playVideo() + if (owner === 'composer') customMusicLoadFailed.value = false + return + } + + const host = document.createElement('div') + host.id = 'sky-phone-fliptok-youtube-player' + host.style.position = 'fixed' + host.style.left = '-10000px' + host.style.top = '0' + host.style.width = '200px' + host.style.height = '200px' + host.style.pointerEvents = 'none' + document.body.append(host) + flipTokYoutubeVideoId = videoId + + await new Promise((resolve, reject) => { + flipTokYoutubePlayer = new api.Player(host, { + height: '200', + width: '200', + videoId, + playerVars: { + autoplay: 1, + controls: 0, + disablekb: 1, + fs: 0, + origin: window.location.origin, + playsinline: 1, + rel: 0, + }, + events: { + onError: () => { + if (flipTokYoutubeOwner === 'composer') + customMusicLoadFailed.value = true + destroyFlipTokYoutube() + reject(new Error('YouTube rejected the selected sound.')) + }, + onReady: (event) => { + flipTokYoutubePlayer = event.target + event.target.setVolume(volume) + event.target.seekTo(Math.max(0, seconds), true) + event.target.playVideo() + if (owner === 'composer') customMusicLoadFailed.value = false + resolve() + }, + onStateChange: (event) => { + if ( + event.data === flipTokYoutubeApi?.PlayerState.ENDED && + flipTokYoutubePlayer + ) { + flipTokYoutubePlayer.seekTo(0, true) + flipTokYoutubePlayer.playVideo() + } + }, + }, + }) + }) + } catch (error) { + if (owner === 'composer') customMusicLoadFailed.value = true + console.error('[FlipTok] YouTube sound playback failed.', error) + } +} + function textParts( value: string, ): Array<{ kind: 'text' | 'hashtag' | 'mention'; value: string }> { @@ -358,17 +662,29 @@ function observeVideos(): void { videoElements.forEach((item) => { if (item !== video) { item.pause() - const otherAudio = musicElements.get(item.dataset.id ?? '') + const otherId = item.dataset.id ?? '' + const otherAudio = musicElements.get(otherId) otherAudio?.pause() + pauseFlipTokYoutube(otherId) } }) const id = video.dataset.id - if (id) void playFeedVideo(id, video) + const item = id + ? [ + ...store.feed, + ...store.searchResults, + ...store.profileVideos, + ].find((candidate) => candidate.id === id) + : undefined + if (item) void playFeedVideo(item, video) if (id) void nuiCall('fliptok:view', { id }) } else { video.pause() const id = video.dataset.id - if (id) musicElements.get(id)?.pause() + if (id) { + musicElements.get(id)?.pause() + pauseFlipTokYoutube(id) + } } }) }, @@ -382,10 +698,11 @@ function togglePlayback(video: FlipTokVideo): void { if (!element) return const music = musicElements.get(video.id) if (element.paused) { - void playFeedVideo(video.id, element, true) + void playFeedVideo(video, element, true) } else { element.pause() music?.pause() + pauseFlipTokYoutube(video.id) } } @@ -414,6 +731,7 @@ function enforceVideoTrim( element.currentTime = video.trim_start_ms / 1000 const music = musicElements.get(video.id) if (music) music.currentTime = 0 + seekFlipTokYoutube(video.id, 0) } else { const music = musicElements.get(video.id) if (music?.duration) { @@ -422,6 +740,19 @@ function enforceVideoTrim( if (Math.abs(music.currentTime - expected) > 0.65) music.currentTime = expected } + if ( + flipTokYoutubeOwner === video.id && + flipTokYoutubePlayer && + Math.abs( + flipTokYoutubePlayer.getCurrentTime() - + (element.currentTime - video.trim_start_ms / 1000), + ) > 0.65 + ) { + seekFlipTokYoutube( + video.id, + element.currentTime - video.trim_start_ms / 1000, + ) + } } } @@ -467,6 +798,53 @@ async function reactWithPulse( await store.react(video, kind) } +async function reactCommentWithPulse(comment: FlipTokComment): Promise { + const shouldPulse = !comment.is_liked + const request = store.reactComment(comment) + + if (shouldPulse) { + commentLikePulseId.value = null + await nextTick() + commentLikePulseId.value = comment.id + if (commentLikePulseTimer !== null) + window.clearTimeout(commentLikePulseTimer) + commentLikePulseTimer = window.setTimeout(() => { + commentLikePulseId.value = null + commentLikePulseTimer = null + }, 420) + } + + await request +} + +async function followFromFeed(video: FlipTokVideo): Promise { + if (video.is_following || followPendingIds.value.has(video.id)) return + + const pending = new Set(followPendingIds.value) + pending.add(video.id) + followPendingIds.value = pending + await store.follow(video) + const settled = new Set(followPendingIds.value) + settled.delete(video.id) + followPendingIds.value = settled + if (!video.is_following) return + + const visible = new Set(followFeedbackIds.value) + visible.add(video.id) + followFeedbackIds.value = visible + const previousTimer = followFeedbackTimers.get(video.id) + if (previousTimer !== undefined) window.clearTimeout(previousTimer) + followFeedbackTimers.set( + video.id, + window.setTimeout(() => { + const next = new Set(followFeedbackIds.value) + next.delete(video.id) + followFeedbackIds.value = next + followFeedbackTimers.delete(video.id) + }, 950), + ) +} + async function changeMode(mode: 'for-you' | 'following'): Promise { await store.loadFeed(mode) await nextTick() @@ -475,19 +853,30 @@ async function changeMode(mode: 'for-you' | 'following'): Promise { async function openComments(video: FlipTokVideo): Promise { selectedVideo.value = video + replyingTo.value = null await store.loadComments(video.id) commentsOpen.value = true } +function startReply(comment: FlipTokComment): void { + replyingTo.value = comment +} + +function cancelReply(): void { + replyingTo.value = null +} + async function submitComment(): Promise { if (!selectedVideo.value || !commentBody.value.trim()) return const response = await store.comment( selectedVideo.value.id, commentBody.value.trim(), + replyingTo.value?.id, ) if (!response.success) return notify(t(`errors.${response.error ?? 'default'}`)) commentBody.value = '' + replyingTo.value = null selectedVideo.value.comment_count += 1 await store.loadComments(selectedVideo.value.id) } @@ -504,6 +893,10 @@ function chooseVideo(): void { originalVolume: originalVolume.value, musicVolume: musicVolume.value, musicTrack: musicTrack.value, + customMusicUrl: customMusicUrl.value, + customMusicTitle: customMusicTitle.value, + customMusicArtist: customMusicArtist.value, + customMusicVideoId: customMusicVideoId.value, }) void router.push({ path: '/apps/photos', @@ -524,8 +917,9 @@ async function publish(draft = false): Promise { trimEndMs: trimEndMs.value || null, coverTimeMs: coverTimeMs.value, originalVolume: originalVolume.value, - musicVolume: musicTrack.value ? musicVolume.value : 0, + musicVolume: hasMusic.value ? musicVolume.value : 0, musicTrack: musicTrack.value, + customMusicUrl: musicTrack.value ? '' : customMusicUrl.value, visibility: visibility.value, }) publishing.value = false @@ -552,6 +946,11 @@ function resetComposer(): void { originalVolume.value = 100 musicVolume.value = 35 musicTrack.value = '' + customMusicUrl.value = '' + customMusicDraftUrl.value = '' + clearCustomMusicDetails() + customMusicLoadFailed.value = false + pauseFlipTokYoutube('composer') videoDurationMs.value = 0 } @@ -576,6 +975,21 @@ function loadComposerVideo(event: Event): void { } function handleComposerPlayback(playing: boolean): void { + if (customMusicVideoId.value) { + if (playing) { + void playFlipTokYoutube( + 'composer', + customMusicVideoId.value, + musicVolume.value, + ((previewVideo.value?.currentTime ?? 0) * 1000 - trimStartMs.value) / + 1000, + ) + } else { + pauseFlipTokYoutube('composer') + } + return + } + pauseFlipTokYoutube('composer') if (!composerMusic.value) return if (playing) void composerMusic.value.play().catch(() => undefined) else composerMusic.value.pause() @@ -586,6 +1000,7 @@ function enforceComposerTrim(event: Event): void { if (trimEndMs.value && element.currentTime * 1000 >= trimEndMs.value) { element.currentTime = trimStartMs.value / 1000 if (composerMusic.value) composerMusic.value.currentTime = 0 + seekFlipTokYoutube('composer', 0) } } @@ -613,10 +1028,6 @@ function formatDuration(value: number): string { return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` } -function setCommentsEnabled(event: Event): void { - commentsEnabled.value = (event.target as HTMLInputElement).checked -} - function chooseVisibility(value: (typeof visibilityOptions)[number]): void { visibility.value = value visibilitySheetOpen.value = false @@ -653,6 +1064,22 @@ async function openOwnProfile(): Promise { tab.value = 'profile' } +async function openConnections(mode: ConnectionMode): Promise { + const profile = currentProfile.value + if (!profile) return + connectionsMode.value = mode + if (!(await store.loadConnections(profile.id, mode))) { + notify(t('errors.default')) + return + } + connectionsOpen.value = true +} + +async function openConnectionProfile(profile: FlipTokProfile): Promise { + connectionsOpen.value = false + await openProfile(profile.id) +} + function openActions(video: FlipTokVideo): void { selectedVideo.value = video actionsOpen.value = true @@ -740,27 +1167,57 @@ function editProfile(): void { if (!store.profile) return profileDraft.value = { accountType: store.profile.account_type, + avatarMediaId: store.profile.avatar_media_id ?? 0, bio: store.profile.bio, displayName: store.profile.display_name, handle: store.profile.handle, } + selectedProfilePhoto.value = null profileEditOpen.value = true } -async function saveProfile(): Promise { - const response = await nuiCall( - 'fliptok:update-profile', - profileDraft.value, +function openProfileMedia(app: 'camera' | 'photos'): void { + profilePhotoSheetOpen.value = false + messageMedia.begin( + 'fliptok:profile-avatar', + 'photo', + '/apps/fliptok?profileEdit=1', + 1, + { + draft: { ...profileDraft.value }, + selectedPhoto: selectedProfilePhoto.value, + } satisfies ProfileMediaContext, ) + void router.push({ + path: `/apps/${app}`, + query: { mediaAttachment: 'photo' }, + }) +} + +function removeProfilePhoto(): void { + selectedProfilePhoto.value = null + profileDraft.value.avatarMediaId = 0 + profilePhotoSheetOpen.value = false +} + +async function saveProfile(): Promise { + const response = await nuiCall('fliptok:update-profile', { + ...profileDraft.value, + avatarMediaId: + selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId, + }) if (!response.success || !response.data) return notify(t(`errors.${response.error ?? 'default'}`)) store.profile = response.data + store.viewedProfile = null + selectedProfilePhoto.value = null profileEditOpen.value = false } watch(tab, async (value) => { videoElements.forEach((video) => video.pause()) musicElements.forEach((audio) => audio.pause()) + pauseFlipTokYoutube() if (value === 'activity') await store.loadActivities() if (value === 'discover' && store.searchResults.length === 0) await runSearch() @@ -793,18 +1250,49 @@ watch(originalVolume, (value) => { watch(musicVolume, (value) => { if (composerMusic.value) composerMusic.value.volume = value / 100 + if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value) }) -watch(musicTrack, async () => { - await nextTick() - if (!composerMusic.value) return - composerMusic.value.volume = musicVolume.value / 100 - composerMusic.value.currentTime = 0 - if (previewVideo.value && !previewVideo.value.paused) - void composerMusic.value.play().catch(() => undefined) -}) +watch( + [composerMusicUrl, customMusicVideoId], + async ([value, youtubeVideoId]) => { + customMusicLoadFailed.value = false + await nextTick() + if (youtubeVideoId) { + if (previewVideo.value && !previewVideo.value.paused) + void playFlipTokYoutube( + 'composer', + youtubeVideoId, + musicVolume.value, + (previewVideo.value.currentTime * 1000 - trimStartMs.value) / 1000, + ) + return + } + pauseFlipTokYoutube('composer') + if (!value || !composerMusic.value) return + composerMusic.value.volume = musicVolume.value / 100 + composerMusic.value.currentTime = 0 + if (previewVideo.value && !previewVideo.value.paused) + void composerMusic.value.play().catch(() => undefined) + }, +) onMounted(async () => { + const profileSelection = messageMedia.consumeMany( + 'fliptok:profile-avatar', + ) + if (profileSelection?.context) { + profileDraft.value = profileSelection.context.draft + selectedProfilePhoto.value = profileSelection.context.selectedPhoto + } + if (profileSelection?.media[0]) { + selectedProfilePhoto.value = profileSelection.media[0] + profileDraft.value.avatarMediaId = profileSelection.media[0].id + } + if (profileSelection) { + tab.value = 'profile' + profileEditOpen.value = true + } const selection = messageMedia.consumeMany<{ caption?: string commentsEnabled?: boolean @@ -816,6 +1304,10 @@ onMounted(async () => { originalVolume?: number musicVolume?: number musicTrack?: string + customMusicUrl?: string + customMusicTitle?: string + customMusicArtist?: string + customMusicVideoId?: string }>('fliptok:compose') if (selection?.media[0]) { selectedMedia.value = selection.media[0] @@ -829,6 +1321,13 @@ onMounted(async () => { originalVolume.value = selection.context?.originalVolume ?? 100 musicVolume.value = selection.context?.musicVolume ?? 35 musicTrack.value = selection.context?.musicTrack ?? '' + customMusicUrl.value = selection.context?.customMusicUrl ?? '' + customMusicTitle.value = selection.context?.customMusicTitle ?? '' + customMusicArtist.value = selection.context?.customMusicArtist ?? '' + customMusicVideoId.value = + selection.context?.customMusicVideoId ?? + parseYoutubeVideoId(customMusicUrl.value) + customMusicLoadFailed.value = false composeOpen.value = true } await store.bootstrap() @@ -852,15 +1351,25 @@ onBeforeUnmount(() => { observer?.disconnect() if (videoClickTimer !== null) window.clearTimeout(videoClickTimer) if (likePulseTimer !== null) window.clearTimeout(likePulseTimer) + if (commentLikePulseTimer !== null) window.clearTimeout(commentLikePulseTimer) if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer) + followFeedbackTimers.forEach((timer) => window.clearTimeout(timer)) + followFeedbackTimers.clear() videoElements.forEach((video) => video.pause()) + destroyFlipTokYoutube() }) - - - - - - - - -
- -
- -
+ +
-
+ +

{{ t('trimAndCover') }}

-
-
- @@ -1434,7 +1999,7 @@ onBeforeUnmount(() => { >{{ t('originalVolume') }} {{ originalVolume }}% - { @input="originalVolume = rangeNumber($event)" /> -
- - + {{ t('customSoundLoadFailed') }} +

+ + + - - { " @click="visibilitySheetOpen = true" /> - + - -
+ +
- {{ t('saveDraft') }}{{ t('saveDraft') }}{{ publishing ? t('publishing') : t('post') }}{{ publishing ? t('publishing') : t('post') }}
-
-
+ + -
- - +
+ - + + +
+ + + {{ t('changePhoto') }} + +
+ {{ t('profileDetails') }} + + + + + + - {{ t('profileDetails') }} - - - - - - - - {{ t('account') }} - - - {{ t('logout') }} - - -
+ {{ t('account') }} + + +
- - - +

{{ t('reports') }}

{ >

{{ report.details || report.caption }}

- {{ t('dismissReport') }}{{ t('dismissReport') }} - {{ t('removeVideo') }}{{ t('removeVideo') }}
@@ -1631,289 +2224,578 @@ onBeforeUnmount(() => {
- -
-
-
- {{ t('comments') }} -
-
-
-
- {{ initials(comment.display_name) }} -
-

- {{ comment.display_name }} - - - -

-
-
- {{ t('noComments') }} -
-
- - - -
- +
+ +
+
+ + +
+

+ +

+
+ + +
+
+
+
+ +
+
+ + +
+

+ @{{ reply.reply_to_handle }} + {{ reply.body }} +

+
+ + +
+
+
+
+
+ {{ t('noComments') }} +
+
+
+
+ {{ + t('replyingTo', { handle: `@${replyingTo.handle}` }) + }} + +
+ + + +
+ +
-
-
- {{ - t('cancel') - }} -
+ {{ + t('cancel') + }} +
- -
-
-

{{ t('report') }}

- - - - - - - - - {{ - t('submitReport') - }} - {{ - t('cancel') - }} -
- +
+
+

{{ t('report') }}

+ + + + + + + + +
+ {{ + t('submitReport') + }} + {{ + t('cancel') + }} +
+
+
- -
-
-

{{ t('chooseSound') }}

- - - - - - +
+

{{ t('chooseSound') }}

+ + - - -

- {{ t('noMusic') }} -

- {{ - t('cancel') - }} -
- + + + + + + +

+ {{ t('noMusic') }} +

+
+
+ +
+ {{ t('customSound') }} +

{{ t('customSoundHint') }}

+
+ +
+ + + +
+ {{ + t('cancel') + }} +
+
-
-
-

{{ t('whoCanWatch') }}

- {{ - t('cancel') - }} -
+

{{ t('whoCanWatch') }}

+ {{ t('cancel') }} +
+
+ +
+
+

{{ t('profilePhoto') }}

+ + + + + + + + + + + + + {{ t('cancel') }} + +
+ +
-
-
-

{{ t('accountType') }}

- {{ - t('cancel') - }} -
+

{{ t('accountType') }}

+ {{ t('cancel') }} +
- +
+
+
+
+ + {{ t(connectionsMode) }} +
+ +
+
+
+ + + {{ profile.is_following ? t('unfollow') : t('follow') }} + +
+
+ {{ t('noConnections') }} +
+
+
+ +

{{ t('signOutBody') }}

- - + {{ feedback }} - - + + diff --git a/frontend/src/views/apps/MessagesApp.contract.test.ts b/frontend/src/views/apps/MessagesApp.contract.test.ts new file mode 100644 index 0000000..d81ca7b --- /dev/null +++ b/frontend/src/views/apps/MessagesApp.contract.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('./MessagesApp.vue', import.meta.url), + 'utf8', +) + +describe('MessagesApp Sky UI contract', () => { + it('uses first-party Sky UI without direct Konsta markup', () => { + expect(source).not.toContain("from 'konsta/vue'") + expect(source).not.toMatch(/<\/?k-[a-z]/) + expect(source).toContain(' { + const inboxStart = source.indexOf('messages-sky-inbox') + const composeStart = source.indexOf('v-else-if="composing"') + const inbox = source.slice(inboxStart, composeStart) + + expect(inbox).toContain(' { + expect(source).toContain('class="messages-sky-thread-scroll"') + expect(source).toContain('class="messages-bubbles"') + expect(source).toContain('class="messages-sky-composer-pill"') + expect(source).toContain('class="messages-sky-messagebar"') + expect(source).toMatch( + /\.messages-bubbles\s*\{[^}]*min-height:\s*100%[^}]*justify-content:\s*flex-end/s, + ) + expect(source).toMatch( + /\.messages-sky-composer-shell\s*\{[^}]*flex:\s*none/s, + ) + expect(source).toMatch( + /\.messages-sky-composer-pill\s*\{[^}]*border-radius:\s*var\(--sky-radius-pill\)/s, + ) + }) + + it('provides a full-size back target and a compact recipient row', () => { + expect(source).toContain('back-appearance="plain"') + expect(source).toContain('class="messages-recipient-field"') + expect(source).toContain('layout="inline"') + expect(source).toMatch( + /\.messages-recipient-field :deep\(\.sky-field\)\s*\{[^}]*min-height:\s*52px/s, + ) + }) + + it('keeps the SMS compose action in the inbox navbar', () => { + const inboxStart = source.indexOf('messages-sky-inbox') + const composeStart = source.indexOf('v-else-if="composing"') + const inbox = source.slice(inboxStart, composeStart) + + expect(inbox).toContain('@click="beginCompose"') + expect(inbox).toContain(' { + expect(source).toMatch( + /v-if="filteredConversations.length"\s+flush\s+class="messages-sky-conversation-list"/, + ) + expect(source).toMatch( + /class="messages-recipient-field"\s+density="compact"\s+flush/, + ) + expect(source).toMatch( + /v-if="contactSuggestions.length"\s+class="messages-contact-list"\s+flush/, + ) + }) +}) diff --git a/frontend/src/views/apps/MessagesApp.vue b/frontend/src/views/apps/MessagesApp.vue index bae7e1f..fa28d47 100644 --- a/frontend/src/views/apps/MessagesApp.vue +++ b/frontend/src/views/apps/MessagesApp.vue @@ -1,38 +1,16 @@ + + + + + + {{ phone.t('Apps.messages.deleteSelected') }} - - - + + + - - - + + +
+ +
+ + + {{ composerNumber }} + + + + + + + - +

{{ phone.t('Apps.messages.noContactsToShare') }}

- +
- +
- +
@@ -1453,24 +1524,24 @@ onBeforeUnmount(() => { class="shared-composer-preview" > - +
- + - +
- - - - - +
+ + + + + + + + +
+
{{ phone.t('Apps.messages.messagingUnavailable') }} - - +
+ - + {{ toastText }} - + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 5dbf8e1..229709d 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -413,6 +413,8 @@ function crewLinkBootstrap(testScenario = '') { } const flipTokProfile = { id: 1, + avatar_media_id: null, + avatar_url: null, handle: 'skyline', display_name: 'Skyline', bio: 'Life around Los Santos.', @@ -433,8 +435,97 @@ const flipTokMusicTracks = [ url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga', }, ] +const flipTokMusicExtensions = new Set([ + 'aac', + 'm4a', + 'mp3', + 'oga', + 'ogg', + 'opus', + 'wav', + 'webm', +]) +function validFlipTokMusicUrl(value) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 2048 || + /[\s\u0000-\u001f\u007f]/.test(value) + ) { + return false + } + try { + const url = new URL(value) + const host = url.hostname.toLowerCase() + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + !host.includes('.') || + host === 'localhost' || + host.endsWith('.local') || + host.endsWith('.internal') || + /^\d+\.\d+\.\d+\.\d+$/.test(host) || + !/^[a-z0-9.-]+$/.test(host) || + host + .split('.') + .some( + (label) => + !label || + label.length > 63 || + label.startsWith('-') || + label.endsWith('-'), + ) + ) { + return false + } + const extension = url.pathname.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() + return Boolean(extension && flipTokMusicExtensions.has(extension)) + } catch { + return false + } +} +function parseFlipTokYoutubeId(value) { + if (typeof value !== 'string' || value.length > 500) return '' + try { + const url = new URL(value) + if (url.protocol !== 'https:' || url.username || url.password || url.port) { + return '' + } + const host = url.hostname.toLowerCase() + let videoId = '' + if (host === 'youtu.be' || host === 'www.youtu.be') { + videoId = url.pathname.split('/')[1] || '' + } else if ( + [ + 'youtube.com', + 'www.youtube.com', + 'm.youtube.com', + 'music.youtube.com', + 'youtube-nocookie.com', + 'www.youtube-nocookie.com', + ].includes(host) + ) { + videoId = + url.searchParams.get('v') || + url.pathname.match(/^\/(?:shorts|embed|live)\/([a-z0-9_-]+)/i)?.[1] || + '' + } + return /^[a-z0-9_-]{11}$/i.test(videoId) ? videoId : '' + } catch { + return '' + } +} +function mockYoutubeMetadata(videoId) { + if (videoId === 'dQw4w9WgXcQ') { + return { artist: 'Rick Astley', title: 'Never Gonna Give You Up' } + } + return { artist: 'YouTube', title: `YouTube ${videoId}` } +} let flipTokVideos = [ { + avatar_url: null, id: 'fliptok-1', profile_id: 2, handle: 'novals', @@ -450,7 +541,9 @@ let flipTokVideos = [ music_track: '', music_title: '', music_artist: '', + music_source: '', music_url: '', + music_video_id: '', url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', comments_enabled: true, is_liked: false, @@ -464,6 +557,7 @@ let flipTokVideos = [ created_at: Date.now() - 3600000, }, { + avatar_url: null, id: 'fliptok-2', profile_id: 1, handle: 'skyline', @@ -479,7 +573,9 @@ let flipTokVideos = [ music_track: 'night-drive', music_title: 'Night Drive', music_artist: 'Los Santos Radio', + music_source: 'audio', music_url: flipTokMusicTracks[0].url, + music_video_id: '', url: 'https://media.w3.org/2010/05/sintel/trailer.mp4', comments_enabled: true, is_liked: true, @@ -495,8 +591,13 @@ let flipTokVideos = [ ] let flipTokComments = [ { + avatar_url: null, id: 'comment-1', + is_liked: false, + like_count: 7, + parent_id: null, profile_id: 2, + reply_to_handle: null, handle: 'nova', display_name: 'Nova', verified: true, @@ -506,6 +607,7 @@ let flipTokComments = [ ] let flipTokActivities = [ { + avatar_url: null, id: 'activity-1', profile_id: 2, handle: 'nova', @@ -6433,15 +6535,33 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'fliptok:comment') { - flipTokComments.unshift({ + const comment = { + avatar_url: flipTokProfile.avatar_url, id: `comment-${Date.now()}`, + is_liked: false, + like_count: 0, + parent_id: request.body.parentId || null, profile_id: 1, + reply_to_handle: request.body.parentId + ? flipTokComments.find((item) => item.id === request.body.parentId) + ?.handle || null + : null, handle: flipTokProfile.handle, display_name: flipTokProfile.display_name, verified: flipTokProfile.verified, body: request.body.body, created_at: Date.now(), - }) + } + flipTokComments.push(comment) + response.json({ success: true, data: { id: comment.id } }) + return + } + if (endpoint === 'fliptok:comment-react') { + const comment = flipTokComments.find((item) => item.id === request.body.id) + if (comment) { + comment.is_liked = request.body.active === true + comment.like_count += comment.is_liked ? 1 : -1 + } response.json({ success: true }) return } @@ -6516,6 +6636,8 @@ app.post('/api/:endpoint', (request, response) => { display_name: first.display_name, bio: 'Creator in Los Santos.', account_type: 'person', + avatar_media_id: null, + avatar_url: first.avatar_url ?? null, verified: first.verified, is_following: first.is_following, is_owner: false, @@ -6528,6 +6650,38 @@ app.post('/api/:endpoint', (request, response) => { }) return } + if (endpoint === 'fliptok:connections') { + const creatorVideo = flipTokVideos.find((video) => !video.is_owner) + const creator = creatorVideo + ? { + account_type: 'person', + avatar_media_id: null, + avatar_url: creatorVideo.avatar_url ?? null, + bio: 'Creator in Los Santos.', + display_name: creatorVideo.display_name, + followers: 12840, + following: 91, + handle: creatorVideo.handle, + id: creatorVideo.profile_id, + is_following: creatorVideo.is_following, + is_owner: false, + verified: creatorVideo.verified, + video_count: 1, + } + : null + response.json({ + success: true, + data: + request.body.mode === 'followers' + ? creator + ? [creator] + : [] + : creator + ? [creator, { ...flipTokProfile }] + : [{ ...flipTokProfile }], + }) + return + } if (endpoint === 'fliptok:block') { const profileId = Number(request.body.profileId) flipTokVideos = flipTokVideos.filter( @@ -6568,10 +6722,30 @@ app.post('/api/:endpoint', (request, response) => { display_name: request.body.displayName, bio: request.body.bio, account_type: request.body.accountType, + avatar_media_id: request.body.avatarMediaId || null, + avatar_url: + mockMedia.find((item) => item.id === request.body.avatarMediaId)?.url || + null, }) response.json({ success: true, data: flipTokProfile }) return } + if (endpoint === 'fliptok:music-metadata') { + const videoId = parseFlipTokYoutubeId(String(request.body.url || '').trim()) + if (!videoId) { + response.json({ success: false, error: 'invalid_music_url' }) + return + } + response.json({ + success: true, + data: { + ...mockYoutubeMetadata(videoId), + url: `https://www.youtube.com/watch?v=${videoId}`, + videoId, + }, + }) + return + } if (endpoint === 'fliptok:publish') { const media = mockMedia.find( (item) => item.id === request.body.mediaId && item.mediaType === 'video', @@ -6580,7 +6754,26 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: false, error: 'invalid_media' }) return } + const musicTrack = String(request.body.musicTrack || '') + const configuredTrack = flipTokMusicTracks.find( + (track) => track.id === musicTrack, + ) + const customMusicUrl = String(request.body.customMusicUrl || '').trim() + const youtubeVideoId = parseFlipTokYoutubeId(customMusicUrl) + const youtubeMetadata = youtubeVideoId + ? mockYoutubeMetadata(youtubeVideoId) + : null + if ( + (musicTrack && !configuredTrack) || + (customMusicUrl && + (configuredTrack || + (!youtubeVideoId && !validFlipTokMusicUrl(customMusicUrl)))) + ) { + response.json({ success: false, error: 'invalid_music_url' }) + return + } flipTokVideos.unshift({ + avatar_url: flipTokProfile.avatar_url, id: `fliptok-${Date.now()}`, profile_id: 1, handle: flipTokProfile.handle, @@ -6593,16 +6786,18 @@ app.post('/api/:endpoint', (request, response) => { cover_time_ms: request.body.coverTimeMs || 0, original_volume: request.body.originalVolume ?? 100, music_volume: request.body.musicVolume || 0, - music_track: request.body.musicTrack || '', - music_title: - flipTokMusicTracks.find((track) => track.id === request.body.musicTrack) - ?.title || '', - music_artist: - flipTokMusicTracks.find((track) => track.id === request.body.musicTrack) - ?.artist || '', - music_url: - flipTokMusicTracks.find((track) => track.id === request.body.musicTrack) - ?.url || '', + music_track: configuredTrack?.id || '', + music_title: configuredTrack?.title || youtubeMetadata?.title || '', + music_artist: configuredTrack?.artist || youtubeMetadata?.artist || '', + music_source: configuredTrack + ? 'audio' + : youtubeVideoId + ? 'youtube' + : customMusicUrl + ? 'audio' + : '', + music_url: youtubeVideoId ? '' : configuredTrack?.url || customMusicUrl, + music_video_id: youtubeVideoId, url: media.url, comments_enabled: request.body.commentsEnabled, is_liked: false, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index f1ce122..3f066d9 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -452,21 +452,23 @@ Locales["en"] = { }, 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", + originalSound = "original sound", save = "Save", home = "Home", discover = "Discover", create = "Create", activity = "Activity", profile = "Profile", navigation = "FlipTok navigation", + emptyFeed = "No videos yet", emptyFeedBody = "Follow creators or post the first FlipTok.", searchPlaceholder = "Search creators and videos", clearSearch = "Clear search", 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", + newVideo = "New FlipTok", createTitle = "Create your next FlipTok", createBody = "Choose a clip, set the cover and sound, then decide who can watch it.", chooseVideo = "Choose a video", chooseVideoHint = "Select one from Gallery", changeVideo = "Change", + caption = "Caption", 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", + comments = "Comments", noComments = "No comments yet", addComment = "Add comment...", reply = "Reply", replyPlaceholder = "Write a reply...", replyingTo = "Replying to {handle}", cancelReply = "Cancel reply", 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", cancel = "Cancel", sounds = "Sound", chooseSound = "Choose music", originalOnly = "Original sound only", noMusic = "No music tracks are configured.", + customSound = "Custom sound", customSoundLink = "YouTube or audio link", customSoundHint = "Paste a YouTube link or a direct HTTPS audio-file link.", customSoundPlaceholder = "https://youtu.be/... or https://example.com/song.mp3", + customSoundFormats = "YouTube, YouTube Music, Shorts, MP3, M4A, AAC, OGG, OPUS, WAV, or WEBM", useCustomSound = "Use sound", loadingSound = "Loading sound", invalidCustomSoundLink = "Use a YouTube link or a direct public HTTPS audio-file link.", customSoundLoadFailed = "This sound could not be loaded.", 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", - done = "Done", displayName = "Name", username = "Username", bio = "Bio", accountType = "Account type", + done = "Done", displayName = "Name", username = "Username", bio = "Bio", accountType = "Account type", profilePhoto = "Profile photo", changePhoto = "Change photo", chooseFromGallery = "Gallery", takePhoto = "Camera", removePhoto = "Remove photo", noConnections = "No profiles to show", authTitle = "Your FlipTok account", login = "Sign In", register = "Register", createAccount = "Create Account", logout = "Sign Out", loginBody = "Sign in to continue with your videos, follows, and saved posts.", registerBody = "Create a private FlipTok login for this profile.", password = "Password", confirmPassword = "Confirm password", passwordsMismatch = "The passwords do not match.", @@ -477,7 +479,7 @@ Locales["en"] = { 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.", invalid_handle = "Use 3–24 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 8–72 characters.", invalid_credentials = "Username or password is incorrect.", already_registered = "This Sky Cloud account already owns a registered FlipTok profile.", handle_taken = "This username is already taken.", video_not_found = "This video is unavailable.", blocked = "This account is blocked.", not_authorized = "You do not have moderation access.", report_not_found = "This report is no longer open.", rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to Sky Cloud first.", default = "FlipTok could not complete the request." }, + errors = { invalid_video = "Check the video details.", invalid_music_url = "Use a valid YouTube or direct HTTPS audio-file link.", 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.", invalid_profile_image = "Choose a valid photo from this phone.", invalid_handle = "Use 3–24 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 8–72 characters.", invalid_credentials = "Username or password is incorrect.", already_registered = "This Sky Cloud account already owns a registered FlipTok profile.", handle_taken = "This username is already taken.", video_not_found = "This video is unavailable.", blocked = "This account is blocked.", not_authorized = "You do not have moderation access.", report_not_found = "This report is no longer open.", rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to Sky Cloud first.", default = "FlipTok could not complete the request." }, }, darkchat = { name = "DarkChat", newMessage = "New DarkChat message from {sender}", privateNotification = "New DarkChat message", @@ -508,7 +510,7 @@ Locales["en"] = { name = "Messages", newMessage = "New message from {sender}", compose = "New Message", search = "Search", to = "To:", message = "Message", send = "Send", details = "Details", officialContact = "Official company contact", messagingUnavailable = "This company contact does not accept messages.", - filterUnread = "Show Unread Messages", smsLabel = "Text Message · SMS", + filterUnread = "Show Unread Messages", filterLabel = "Conversation Filter", allMessages = "All", unreadMessages = "Unread", smsLabel = "Text Message · SMS", photo = "Photo", gif = "GIF", video = "Video", contact = "Contact", attachPhoto = "Attach Photo", takePhoto = "Take Photo", attachGif = "Attach GIF", attachVideo = "Attach Video", photos = "Photos", gifs = "GIFs", videos = "Videos", contacts = "Contacts", shareContact = "Share Contact", noContactsToShare = "Save a contact first to share it here.", contactSaved = "Contact Saved", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 995c0be..600d5b8 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -65,6 +65,7 @@ server_scripts { 'source/server/custom_apps.lua', 'source/server/custom_app_compat.lua', 'source/server/db_migrate.lua', + 'source/server/media_metadata.lua', 'source/server/phone.lua', 'source/server/companies.lua', 'source/server/custom_app_storage.lua', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index c2346b5..f46b264 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -91,14 +91,17 @@ local server_callbacks = { "fliptok:feed", "fliptok:video", "fliptok:discover", + "fliptok:music-metadata", "fliptok:publish", "fliptok:react", "fliptok:follow", "fliptok:comments", "fliptok:comment", + "fliptok:comment-react", "fliptok:view", "fliptok:share", "fliptok:profile", + "fliptok:connections", "fliptok:update-profile", "fliptok:activities", "fliptok:mark-activities", diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 9c1f561..2e6a38d 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -1533,6 +1533,7 @@ local schema = { { name = "display_name", type = "VARCHAR(40) NOT NULL" }, { name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, { name = "account_type", type = "ENUM('person', 'business', 'organization', 'media', 'event') NOT NULL DEFAULT 'person'" }, + { 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" }, @@ -1542,7 +1543,10 @@ local schema = { { name = "uniq_sky_phone_fliptok_account", columns = "(`account_id`)" }, { name = "uniq_sky_phone_fliptok_handle", columns = "(`handle`)" }, }, - foreignKeys = {{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }}, + 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", }, { @@ -1590,6 +1594,9 @@ local schema = { { name = "original_volume", type = "TINYINT UNSIGNED NOT NULL DEFAULT 100" }, { name = "music_volume", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" }, { name = "music_track", type = "VARCHAR(64) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "custom_music_url", type = "VARCHAR(2048) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_bin" }, + { name = "custom_music_title", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "custom_music_artist", type = "VARCHAR(120) NOT NULL DEFAULT ''" }, { name = "status", type = "ENUM('draft', 'published', 'removed') NOT NULL DEFAULT 'published'" }, { name = "view_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, { name = "share_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, @@ -1646,15 +1653,38 @@ local schema = { { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, { name = "video_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, { name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "parent_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, { name = "body", type = "VARCHAR(300) NOT NULL" }, { name = "status", type = "ENUM('visible', 'removed') NOT NULL DEFAULT 'visible'" }, { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, }, primaryKey = "id", - indexes = {{ name = "idx_sky_phone_fliptok_comments", columns = "(`video_id`, `created_at`)" }}, + indexes = { + { name = "idx_sky_phone_fliptok_comments", columns = "(`video_id`, `created_at`)" }, + { name = "idx_sky_phone_fliptok_comment_parent", columns = "(`parent_id`, `created_at`)" }, + }, foreignKeys = { { column = "video_id", references = "`sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE" }, { column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" }, + { column = "parent_id", references = "`sky_phone_fliptok_comments` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_fliptok_comment_reactions", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "comment_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_fliptok_comment_reaction", columns = "(`comment_id`, `profile_id`)" }, + }, + foreignKeys = { + { column = "comment_id", references = "`sky_phone_fliptok_comments` (`id`) ON DELETE CASCADE" }, + { column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" }, }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, diff --git a/sky_phone/source/server/fliptok.lua b/sky_phone/source/server/fliptok.lua index 03a20ad..d70e051 100644 --- a/sky_phone/source/server/fliptok.lua +++ b/sky_phone/source/server/fliptok.lua @@ -5,6 +5,16 @@ local report_reasons = { spam = true, harassment = true, dangerous = true, illeg local report_actions = { dismiss = true, remove = true } local music_tracks = {} local music_track_list = {} +local custom_music_extensions = { + aac = true, + m4a = true, + mp3 = true, + oga = true, + ogg = true, + opus = true, + wav = true, + webm = true, +} local password_pepper = GetConvar(Config.FlipTok.PasswordPepperConvar, "") if password_pepper == "" then @@ -39,6 +49,37 @@ local function valid_text(value, minimum, maximum) return length and length >= minimum and length <= maximum end +local function valid_custom_music_url(value) + if type(value) ~= "string" or value == "" or #value > Config.Media.UrlMaxLength + or value:find("[^\33-\126]") + then + return false + end + + local authority = value:match("^https://([^/%?#]+)") + if not authority or authority:find("@", 1, true) then return false end + + local host = authority:match("^([^:]+)") + local port = host and authority:sub(#host + 1) or "" + if not host or (port ~= "" and port ~= ":443") then return false end + + host = host:lower() + if host == "localhost" or host:match("%.localhost$") or host:match("%.local$") or host:match("%.internal$") + or host:match("^%d+%.%d+%.%d+%.%d+$") or not host:find("%.") + or host:find("[^a-z0-9%.%-]") + then + return false + end + + for label in host:gmatch("[^.]+") do + if #label > 63 or label:match("^%-") or label:match("%-$") then return false end + end + + local path = value:match("^https://[^/]+(/[^?#]*)") or "" + local extension = path:match("%.([%w]+)$") + return extension and custom_music_extensions[extension:lower()] == true or false +end + local function affected_rows(result) if type(result) == "number" then return result end if type(result) == "table" then return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0 end @@ -91,6 +132,7 @@ end local function hydrate_profile(profile, viewer_id) profile.id = tonumber(profile.id) + profile.avatar_media_id = tonumber(profile.avatar_media_id) profile.verified = tonumber(profile.verified) == 1 profile.is_following = viewer_id and tonumber(profile.is_following) == 1 or false profile.is_owner = viewer_id and profile.id == viewer_id or false @@ -102,12 +144,14 @@ end local function load_profile(profile_id, viewer_id) local rows = Bridge.Database.Query([[ - SELECT p.*, + SELECT p.*, avatar.`url` AS `avatar_url`, EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = p.`id`) AS `is_following`, (SELECT COUNT(*) FROM `sky_phone_fliptok_follows` f WHERE f.`following_id` = p.`id`) AS `followers`, (SELECT COUNT(*) FROM `sky_phone_fliptok_follows` f WHERE f.`follower_id` = p.`id`) AS `following`, (SELECT COUNT(*) FROM `sky_phone_fliptok_videos` v WHERE v.`profile_id` = p.`id` AND v.`status` = 'published') AS `video_count` - FROM `sky_phone_fliptok_profiles` p WHERE p.`id` = ? LIMIT 1 + FROM `sky_phone_fliptok_profiles` p + LEFT JOIN `sky_phone_media` avatar ON avatar.`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 @@ -143,9 +187,16 @@ local function hydrate_videos(rows) video.original_volume = tonumber(video.original_volume) or 100 video.music_volume = tonumber(video.music_volume) or 0 local track = music_tracks[video.music_track] - video.music_title = track and track.title or "" - video.music_artist = track and track.artist or "" - video.music_url = track and track.url or "" + local custom_music_url = type(video.custom_music_url) == "string" and video.custom_music_url or "" + local youtube_id = SkyPhoneYouTube.ParseId(custom_music_url) + video.music_title = track and track.title or video.custom_music_title or "" + video.music_artist = track and track.artist or video.custom_music_artist or "" + video.music_url = track and track.url or (youtube_id and "" or custom_music_url) + video.music_source = track and "audio" or (youtube_id and "youtube" or (custom_music_url ~= "" and "audio" or "")) + video.music_video_id = youtube_id or "" + video.custom_music_url = nil + video.custom_music_title = nil + video.custom_music_artist = nil video.created_at = (tonumber(video.created_at_unix) or 0) * 1000 video.created_at_unix = nil end @@ -160,7 +211,9 @@ local function list_videos(viewer_id, where_clause, values, limit, offset, ranki return hydrate_videos(Bridge.Database.Query(([[ SELECT v.`id`, v.`profile_id`, v.`caption`, v.`location`, v.`comments_enabled`, v.`view_count`, v.`share_count`, v.`trim_start_ms`, v.`trim_end_ms`, v.`cover_time_ms`, v.`original_volume`, v.`music_volume`, v.`music_track`, + v.`custom_music_url`, v.`custom_music_title`, v.`custom_music_artist`, m.`url`, UNIX_TIMESTAMP(v.`created_at`) AS `created_at_unix`, p.`handle`, p.`display_name`, p.`verified`, + avatar.`url` AS `avatar_url`, (v.`profile_id` = ?) AS `is_owner`, EXISTS(SELECT 1 FROM `sky_phone_fliptok_reactions` r WHERE r.`video_id` = v.`id` AND r.`profile_id` = ? AND r.`kind` = 'like') AS `is_liked`, EXISTS(SELECT 1 FROM `sky_phone_fliptok_reactions` r WHERE r.`video_id` = v.`id` AND r.`profile_id` = ? AND r.`kind` = 'save') AS `is_saved`, @@ -170,6 +223,7 @@ local function list_videos(viewer_id, where_clause, values, limit, offset, ranki FROM `sky_phone_fliptok_videos` v JOIN `sky_phone_fliptok_profiles` p ON p.`id` = v.`profile_id` JOIN `sky_phone_media` m ON m.`id` = v.`media_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` WHERE v.`status` = 'published' AND %s AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE (b.`blocker_id` = ? AND b.`blocked_id` = v.`profile_id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = v.`profile_id`)) @@ -339,6 +393,25 @@ Bridge.Callbacks.Register("sky_phone:fliptok:discover", function(source, data) return { success = true, data = rows } end) +Bridge.Callbacks.Register("sky_phone:fliptok:music-metadata", function(source, data) + local profile, error_response = require_profile(source) + if not profile then return error_response end + if not SkyPhone.AllowOperation(source, "fliptok:music-metadata", 12, 60) then + return { success = false, error = "rate_limited" } + end + local value = trim(type(data) == "table" and data.url or nil) + local video_id = SkyPhoneYouTube.ParseId(value) + if not video_id then return { success = false, error = "invalid_music_url" } end + + local metadata = SkyPhoneYouTube.FetchMetadata(video_id) + return { success = true, data = { + url = SkyPhoneYouTube.WatchUrl(video_id), + videoId = video_id, + title = metadata and metadata.title or "YouTube " .. video_id, + artist = metadata and metadata.artist or "YouTube", + } } +end) + Bridge.Callbacks.Register("sky_phone:fliptok:publish", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end @@ -354,6 +427,9 @@ Bridge.Callbacks.Register("sky_phone:fliptok:publish", function(source, data) local original_volume = math.floor(tonumber(data.originalVolume) or 100) local music_volume = math.floor(tonumber(data.musicVolume) or 0) local music_track = type(data.musicTrack) == "string" and data.musicTrack or "" + local custom_music_url = trim(data.customMusicUrl) or "" + local custom_music_title = "" + local custom_music_artist = "" if not media_id or media_id < 1 or media_id ~= math.floor(media_id) or not valid_text(caption, 0, Config.FlipTok.CaptionMaxLength) or not valid_text(location, 0, 80) or not visibilities[visibility] @@ -364,17 +440,31 @@ Bridge.Callbacks.Register("sky_phone:fliptok:publish", function(source, data) or original_volume < 0 or original_volume > 100 or music_volume < 0 or music_volume > 100 or (music_track ~= "" and not music_tracks[music_track]) then return { success = false, error = "invalid_video" } end - if music_track == "" then music_volume = 0 end + if custom_music_url ~= "" then + if music_track ~= "" then return { success = false, error = "invalid_music_url" } end + local youtube_id = SkyPhoneYouTube.ParseId(custom_music_url) + if youtube_id then + local metadata = SkyPhoneYouTube.FetchMetadata(youtube_id) + custom_music_url = SkyPhoneYouTube.WatchUrl(youtube_id) + custom_music_title = metadata and metadata.title or "YouTube " .. youtube_id + custom_music_artist = metadata and metadata.artist or "YouTube" + elseif not valid_custom_music_url(custom_music_url) then + return { success = false, error = "invalid_music_url" } + end + end + if music_track == "" and custom_music_url == "" then music_volume = 0 end if not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "video") then return { success = false, error = "invalid_media" } end local id = new_id() Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_videos` (`id`, `profile_id`, `media_id`, `caption`, `location`, `visibility`, `comments_enabled`, `trim_start_ms`, `trim_end_ms`, - `cover_time_ms`, `original_volume`, `music_volume`, `music_track`, `status`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]], { + `cover_time_ms`, `original_volume`, `music_volume`, `music_track`, `custom_music_url`, `custom_music_title`, + `custom_music_artist`, `status`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]], { id, profile.id, media_id, caption, location, visibility, data.commentsEnabled and 1 or 0, trim_start_ms, trim_end_ms, - cover_time_ms, original_volume, music_volume, music_track, data.draft == true and "draft" or "published", + cover_time_ms, original_volume, music_volume, music_track, custom_music_url, custom_music_title, custom_music_artist, + data.draft == true and "draft" or "published", }) return { success = true, data = { id = id } } end) @@ -426,14 +516,30 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comments", 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 rows = Bridge.Database.Query([[SELECT c.`id`, c.`body`, UNIX_TIMESTAMP(c.`created_at`) * 1000 AS `created_at`, - p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified` - FROM `sky_phone_fliptok_comments` c JOIN `sky_phone_fliptok_profiles` p ON p.`id` = c.`profile_id` + local rows = Bridge.Database.Query([[SELECT c.`id`, c.`parent_id`, c.`body`, UNIX_TIMESTAMP(c.`created_at`) * 1000 AS `created_at`, + p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`, avatar.`url` AS `avatar_url`, + parent_author.`handle` AS `reply_to_handle`, + EXISTS(SELECT 1 FROM `sky_phone_fliptok_comment_reactions` reaction + WHERE reaction.`comment_id` = c.`id` AND reaction.`profile_id` = ?) AS `is_liked`, + (SELECT COUNT(*) FROM `sky_phone_fliptok_comment_reactions` reaction + WHERE reaction.`comment_id` = c.`id`) AS `like_count` + FROM `sky_phone_fliptok_comments` c + JOIN `sky_phone_fliptok_profiles` p ON p.`id` = c.`profile_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` + LEFT JOIN `sky_phone_fliptok_comments` parent ON parent.`id` = c.`parent_id` + LEFT JOIN `sky_phone_fliptok_profiles` parent_author ON parent_author.`id` = parent.`profile_id` WHERE c.`video_id` = ? AND c.`status` = 'visible' AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE (b.`blocker_id` = ? AND b.`blocked_id` = c.`profile_id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = c.`profile_id`)) - ORDER BY c.`created_at` DESC LIMIT 100]], { data.id, profile.id, profile.id }) - for _, row in ipairs(rows) do row.verified = tonumber(row.verified) == 1 end + ORDER BY COALESCE(parent.`created_at`, c.`created_at`) DESC, + c.`parent_id` IS NOT NULL, c.`created_at` ASC LIMIT 100]], { + profile.id, data.id, profile.id, profile.id, + }) + for _, row in ipairs(rows) do + row.verified = tonumber(row.verified) == 1 + row.is_liked = tonumber(row.is_liked) == 1 + row.like_count = tonumber(row.like_count) or 0 + end return { success = true, data = rows } end) @@ -442,18 +548,57 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comment", function(source, data) if not profile then return error_response end if not SkyPhone.AllowOperation(source, "fliptok:comment", 20, 60) then return { success = false, error = "rate_limited" } end local body = type(data) == "table" and trim(data.body) or nil + local parent_id = type(data) == "table" and trim(data.parentId) or nil if type(data) ~= "table" or type(data.id) ~= "string" or not valid_text(body, 1, Config.FlipTok.CommentMaxLength) then return { success = false, error = "invalid_comment" } end + if parent_id and #parent_id ~= 36 then return { success = false, error = "invalid_comment" } end local videos = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_videos` WHERE `id` = ? AND `status` = 'published' AND `comments_enabled` = 1 LIMIT 1", { data.id }) if not videos[1] then return { success = false, error = "comments_disabled" } end local owner_id = tonumber(videos[1].profile_id) if owner_id ~= profile.id and are_profiles_blocked(profile.id, owner_id) then return { success = false, error = "blocked" } end - Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `body`) VALUES (?, ?, ?, ?)", { new_id(), data.id, profile.id, body }) + if parent_id then + local parents = Bridge.Database.Query([[SELECT c.`id`, c.`profile_id` FROM `sky_phone_fliptok_comments` c + WHERE c.`id` = ? AND c.`video_id` = ? AND c.`status` = 'visible' LIMIT 1]], { parent_id, data.id }) + if not parents[1] or are_profiles_blocked(profile.id, tonumber(parents[1].profile_id)) then + return { success = false, error = "invalid_comment" } + end + end + local comment_id = new_id() + Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `parent_id`, `body`) VALUES (?, ?, ?, ?, ?)", { + comment_id, data.id, profile.id, parent_id, body, + }) if tonumber(videos[1].profile_id) ~= profile.id then Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'comment')", { new_id(), videos[1].profile_id, profile.id, data.id }) notify_profile(owner_id, profile.id, "comment", data.id) end + return { success = true, data = { id = comment_id } } +end) + +Bridge.Callbacks.Register("sky_phone:fliptok:comment-react", function(source, data) + local profile, error_response = require_profile(source) + if not profile then return error_response end + if not SkyPhone.AllowOperation(source, "fliptok:comment-react", 60, 60) then + return { success = false, error = "rate_limited" } + end + local comment_id = type(data) == "table" and data.id or nil + if type(comment_id) ~= "string" or #comment_id ~= 36 or type(data.active) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + local comments = Bridge.Database.Query([[SELECT c.`profile_id` FROM `sky_phone_fliptok_comments` c + JOIN `sky_phone_fliptok_videos` v ON v.`id` = c.`video_id` + WHERE c.`id` = ? AND c.`status` = 'visible' AND v.`status` = 'published' LIMIT 1]], { comment_id }) + if not comments[1] then return { success = false, error = "invalid_comment" } end + if are_profiles_blocked(profile.id, tonumber(comments[1].profile_id)) then + return { success = false, error = "blocked" } + end + if data.active then + Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_fliptok_comment_reactions` + (`comment_id`, `profile_id`) VALUES (?, ?)]], { comment_id, profile.id }) + else + Bridge.Database.Query([[DELETE FROM `sky_phone_fliptok_comment_reactions` + WHERE `comment_id` = ? AND `profile_id` = ?]], { comment_id, profile.id }) + end return { success = true } end) @@ -491,19 +636,65 @@ Bridge.Callbacks.Register("sky_phone:fliptok:profile", function(source, data) return { success = true, data = { profile = target, videos = videos } } end) +Bridge.Callbacks.Register("sky_phone:fliptok:connections", function(source, data) + local viewer, error_response = require_profile(source) + if not viewer then return error_response end + local target_id = type(data) == "table" and tonumber(data.profileId) or nil + local mode = type(data) == "table" and data.mode or nil + if not target_id or (mode ~= "followers" and mode ~= "following") then + return { success = false, error = "invalid_request" } + end + if target_id ~= viewer.id and are_profiles_blocked(viewer.id, target_id) then + return { success = false, error = "profile_not_found" } + end + local join_column = mode == "followers" and "f.`follower_id`" or "f.`following_id`" + local filter_column = mode == "followers" and "f.`following_id`" or "f.`follower_id`" + local rows = Bridge.Database.Query(([[SELECT p.*, avatar.`url` AS `avatar_url`, + EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` own_follow + WHERE own_follow.`follower_id` = ? AND own_follow.`following_id` = p.`id`) AS `is_following`, + (SELECT COUNT(*) FROM `sky_phone_fliptok_follows` followers WHERE followers.`following_id` = p.`id`) AS `followers`, + (SELECT COUNT(*) FROM `sky_phone_fliptok_follows` following WHERE following.`follower_id` = p.`id`) AS `following`, + (SELECT COUNT(*) FROM `sky_phone_fliptok_videos` video WHERE video.`profile_id` = p.`id` AND video.`status` = 'published') AS `video_count` + FROM `sky_phone_fliptok_follows` f + JOIN `sky_phone_fliptok_profiles` p ON p.`id` = %s + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` + WHERE %s = ? AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` block WHERE + (block.`blocker_id` = ? AND block.`blocked_id` = p.`id`) OR + (block.`blocked_id` = ? AND block.`blocker_id` = p.`id`)) + ORDER BY p.`display_name`, p.`handle` LIMIT 200]]):format(join_column, filter_column), { + viewer.id, target_id, viewer.id, viewer.id, + }) + for _, row in ipairs(rows) do hydrate_profile(row, viewer.id) end + return { success = true, data = rows } +end) + Bridge.Callbacks.Register("sky_phone:fliptok:update-profile", function(source, data) local profile, error_response = require_profile(source) if not profile then return error_response end - local handle = type(data) == "table" and trim(data.handle) or nil - local display_name = type(data) == "table" and trim(data.displayName) or nil - local bio = type(data) == "table" and trim(data.bio) or nil - local account_type = type(data) == "table" and data.accountType or nil - if not handle or not handle:match("^[a-z0-9._]+$") or not valid_text(handle, 3, 24) + if type(data) ~= "table" then return { success = false, error = "invalid_request" } end + local handle = normalize_handle(data.handle) + local display_name = trim(data.displayName) + local bio = trim(data.bio) + local account_type = data.accountType + if not handle or not valid_text(display_name, 1, 40) or not valid_text(bio, 0, Config.FlipTok.BioMaxLength) or not account_types[account_type] then return { success = false, error = "invalid_profile" } end + local avatar_media_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil + if data.avatarMediaId ~= nil then + local requested_avatar_id = tonumber(data.avatarMediaId) + if not requested_avatar_id or requested_avatar_id < 0 or requested_avatar_id ~= math.floor(requested_avatar_id) then + return { success = false, error = "invalid_profile_image" } + end + if requested_avatar_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, requested_avatar_id, "photo") then + return { success = false, error = "invalid_profile_image" } + end + avatar_media_id = requested_avatar_id > 0 and requested_avatar_id or nil + end local duplicate = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? AND `id` <> ? LIMIT 1", { handle, profile.id }) if duplicate[1] then return { success = false, error = "handle_taken" } end - Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `handle` = ?, `display_name` = ?, `bio` = ?, `account_type` = ? WHERE `id` = ?", { handle, display_name, bio, account_type, profile.id }) + Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `handle` = ?, `display_name` = ?, `bio` = ?, `account_type` = ?, `avatar_media_id` = ? WHERE `id` = ?", { + handle, display_name, bio, account_type, avatar_media_id, profile.id, + }) return { success = true, data = load_profile(profile.id, profile.id) } end) @@ -511,8 +702,9 @@ Bridge.Callbacks.Register("sky_phone:fliptok: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.`video_id`, n.`read_at`, UNIX_TIMESTAMP(n.`created_at`) * 1000 AS `created_at`, - p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified` + p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`, avatar.`url` AS `avatar_url` FROM `sky_phone_fliptok_notifications` n JOIN `sky_phone_fliptok_profiles` p ON p.`id` = n.`actor_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` WHERE n.`recipient_id` = ? AND (n.`kind` = 'verified' OR NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE (b.`blocker_id` = n.`recipient_id` AND b.`blocked_id` = n.`actor_id`) OR diff --git a/sky_phone/source/server/media_metadata.lua b/sky_phone/source/server/media_metadata.lua new file mode 100644 index 0000000..39153b7 --- /dev/null +++ b/sky_phone/source/server/media_metadata.lua @@ -0,0 +1,84 @@ +SkyPhoneYouTube = {} + +local function trim(value) + return type(value) == "string" and value:match("^%s*(.-)%s*$") or nil +end + +local function truncate_text(value, maximum) + local length = type(value) == "string" and utf8.len(value) or nil + if not length or length <= maximum then return value end + local boundary = utf8.offset(value, maximum + 1) + return boundary and value:sub(1, boundary - 1) or value +end + +function SkyPhoneYouTube.ParseId(value) + if type(value) ~= "string" or #value > 500 or value:find("[^\33-\126]") then return nil end + + local authority, path = value:match("^https://([^/]+)(/.*)$") + if not authority or authority:find("@", 1, true) then return nil end + + local host = authority:lower():gsub(":443$", "") + local id + if host == "youtu.be" or host == "www.youtu.be" then + id = path:match("^/([%w_-]+)") + elseif host == "youtube.com" + or host == "www.youtube.com" + or host == "m.youtube.com" + or host == "music.youtube.com" + or host == "youtube-nocookie.com" + or host == "www.youtube-nocookie.com" + then + id = path:match("[?&]v=([%w_-]+)") + or path:match("^/shorts/([%w_-]+)") + or path:match("^/embed/([%w_-]+)") + or path:match("^/live/([%w_-]+)") + end + return id and #id == 11 and id or nil +end + +function SkyPhoneYouTube.WatchUrl(video_id) + return "https://www.youtube.com/watch?v=" .. video_id +end + +function SkyPhoneYouTube.FetchMetadata(video_id) + local request = promise.new() + local settled = false + local function resolve(value) + if settled then return end + settled = true + request:resolve(value) + end + + PerformHttpRequest( + "https://www.youtube.com/oembed?format=json&url=" .. SkyPhoneYouTube.WatchUrl(video_id), + function(status, body) + if status ~= 200 or type(body) ~= "string" then + resolve(nil) + return + end + local success, decoded = pcall(json.decode, body) + if not success or type(decoded) ~= "table" then + resolve(nil) + return + end + local title = trim(decoded.title) + local artist = trim(decoded.author_name) + if not title or title == "" or not artist or artist == "" then + resolve(nil) + return + end + resolve({ + title = truncate_text(title, 160), + artist = truncate_text(artist, 120), + }) + end, + "GET", + "", + { ["Accept"] = "application/json" } + ) + + SetTimeout(Config.Music.MetadataTimeoutMs, function() + resolve(nil) + end) + return Citizen.Await(request) +end diff --git a/sky_phone/source/server/music.lua b/sky_phone/source/server/music.lua index 63eba59..040e372 100644 --- a/sky_phone/source/server/music.lua +++ b/sky_phone/source/server/music.lua @@ -24,15 +24,6 @@ local function text_length(value) return type(value) == "string" and utf8.len(value) or nil end -local function truncate_text(value, maximum) - local length = text_length(value) - if not length or length <= maximum then - return value - end - local boundary = utf8.offset(value, maximum + 1) - return boundary and value:sub(1, boundary - 1) or value -end - local function optional_text(value) local normalized = trim(value) return normalized ~= "" and normalized or nil @@ -414,78 +405,6 @@ local function bootstrap(account_id, imei) } end -local function parse_youtube_id(value) - if type(value) ~= "string" or #value > 500 then - return nil - end - local host, path = value:match("^https?://([^/]+)(/.*)$") - if not host or not path then - return nil - end - host = host:lower():gsub(":443$", "") - local id - if host == "youtu.be" or host == "www.youtu.be" then - id = path:match("^/([%w_-]+)") - elseif host == "youtube.com" - or host == "www.youtube.com" - or host == "m.youtube.com" - or host == "music.youtube.com" - or host == "youtube-nocookie.com" - or host == "www.youtube-nocookie.com" - then - id = path:match("[?&]v=([%w_-]+)") - or path:match("^/shorts/([%w_-]+)") - or path:match("^/embed/([%w_-]+)") - or path:match("^/live/([%w_-]+)") - end - return id and #id == 11 and id or nil -end - -local function fetch_youtube_metadata(video_id) - local request = promise.new() - local settled = false - local function resolve(value) - if settled then - return - end - settled = true - request:resolve(value) - end - - PerformHttpRequest( - "https://www.youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v=" .. video_id, - function(status, body) - if status ~= 200 or type(body) ~= "string" then - resolve(nil) - return - end - local success, decoded = pcall(json.decode, body) - if not success or type(decoded) ~= "table" then - resolve(nil) - return - end - local title = trim(decoded.title) - local artist = trim(decoded.author_name) - if not text_length(title) or not text_length(artist) then - resolve(nil) - return - end - resolve({ - title = truncate_text(title, 160), - artist = truncate_text(artist, 120), - }) - end, - "GET", - "", - { ["Accept"] = "application/json" } - ) - - SetTimeout(Config.Music.MetadataTimeoutMs, function() - resolve(nil) - end) - return Citizen.Await(request) -end - local function owned_playlist(account_id, imei, playlist_id) local condition, owner_params = owner_condition(account_id, imei) local params = { playlist_id } @@ -525,7 +444,7 @@ Bridge.Callbacks.Register("sky_phone:music:add-youtube", function(source, data) return error_response end local payload = type(data) == "table" and data or {} - local video_id = parse_youtube_id(payload.url) + local video_id = SkyPhoneYouTube.ParseId(payload.url) if not video_id then return { success = false, error = "invalid_youtube_url" } end @@ -557,7 +476,7 @@ Bridge.Callbacks.Register("sky_phone:music:add-youtube", function(source, data) end local metadata = (not custom_title or not custom_artist) - and fetch_youtube_metadata(video_id) + and SkyPhoneYouTube.FetchMetadata(video_id) or nil local title = custom_title or metadata and metadata.title or "YouTube " .. video_id local artist = custom_artist or metadata and metadata.artist or "YouTube" diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index c7d7a45..e48e87f 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -423,10 +423,12 @@ CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_profiles` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `account_id` BIGINT UNSIGNED NOT NULL, `handle` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, `display_name` VARCHAR(40) NOT NULL, `bio` VARCHAR(160) NOT NULL DEFAULT '', `account_type` ENUM('person','business','organization','media','event') NOT NULL DEFAULT 'person', + `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_fliptok_account` (`account_id`), UNIQUE KEY `uniq_sky_phone_fliptok_handle` (`handle`), - FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE + 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_fliptok_credentials` ( @@ -454,6 +456,8 @@ CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_videos` ( `trim_start_ms` INT UNSIGNED NOT NULL DEFAULT 0, `trim_end_ms` INT UNSIGNED NULL, `cover_time_ms` INT UNSIGNED NOT NULL DEFAULT 0, `original_volume` TINYINT UNSIGNED NOT NULL DEFAULT 100, `music_volume` TINYINT UNSIGNED NOT NULL DEFAULT 0, `music_track` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '', + `custom_music_url` VARCHAR(2048) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + `custom_music_title` VARCHAR(160) NOT NULL DEFAULT '', `custom_music_artist` VARCHAR(120) NOT NULL DEFAULT '', `status` ENUM('draft','published','removed') NOT NULL DEFAULT 'published', `view_count` INT UNSIGNED NOT NULL DEFAULT 0, `share_count` INT UNSIGNED NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -480,9 +484,20 @@ CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_follows` ( CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_comments` ( `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `video_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `profile_id` BIGINT UNSIGNED NOT NULL, `body` VARCHAR(300) NOT NULL, `status` ENUM('visible','removed') NOT NULL DEFAULT 'visible', + `profile_id` BIGINT UNSIGNED NOT NULL, `parent_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `body` VARCHAR(300) NOT NULL, `status` ENUM('visible','removed') NOT NULL DEFAULT 'visible', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_sky_phone_fliptok_comments` (`video_id`,`created_at`), + KEY `idx_sky_phone_fliptok_comment_parent` (`parent_id`,`created_at`), FOREIGN KEY (`video_id`) REFERENCES `sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`parent_id`) REFERENCES `sky_phone_fliptok_comments` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_comment_reactions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `comment_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` BIGINT UNSIGNED NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_fliptok_comment_reaction` (`comment_id`,`profile_id`), + FOREIGN KEY (`comment_id`) REFERENCES `sky_phone_fliptok_comments` (`id`) ON DELETE CASCADE, FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;