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;