diff --git a/frontend/src/stores/fliptok.test.ts b/frontend/src/stores/fliptok.test.ts index d5ab6c8..234b448 100644 --- a/frontend/src/stores/fliptok.test.ts +++ b/frontend/src/stores/fliptok.test.ts @@ -233,4 +233,30 @@ describe('FlipTok verification updates', () => { profileId: profile.id, }) }) + + it('does not show a follow state when the server rejects it', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: false }) + const store = useFlipTokStore() + const creatorVideo = { ...video, is_owner: false, profile_id: 8 } + store.feed = [creatorVideo] + + expect(await store.follow(creatorVideo)).toBe(false) + expect(creatorVideo.is_following).toBe(false) + }) + + it('removes an owned video from every local surface after deletion', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true }) + const store = useFlipTokStore() + store.profile = { ...profile, video_count: 1 } + store.feed = [{ ...video }] + store.searchResults = [{ ...video }] + store.profileVideos = [{ ...video }] + + expect(await store.deleteVideo(video.id)).toBe(true) + expect(nuiCall).toHaveBeenCalledWith('fliptok:delete', { id: video.id }) + expect(store.feed).toEqual([]) + expect(store.searchResults).toEqual([]) + expect(store.profileVideos).toEqual([]) + expect(store.profile.video_count).toBe(0) + }) }) diff --git a/frontend/src/stores/fliptok.ts b/frontend/src/stores/fliptok.ts index 0d2d7a0..7d910d0 100644 --- a/frontend/src/stores/fliptok.ts +++ b/frontend/src/stores/fliptok.ts @@ -137,26 +137,27 @@ export const useFlipTokStore = defineStore('fliptok', { if (kind === 'like') video.like_count += next ? -1 : 1 } }, - async follow(video: FlipTokVideo): Promise { + async follow(video: FlipTokVideo): Promise { const next = !video.is_following const response = await nuiCall('fliptok:follow', { active: next, profileId: video.profile_id, }) - if (response.success) - this.feed - .filter((item) => item.profile_id === video.profile_id) - .forEach((item) => { - item.is_following = next - }) + if (!response.success) return false + this.feed + .filter((item) => item.profile_id === video.profile_id) + .forEach((item) => { + item.is_following = next + }) + return true }, - async followProfile(profile: FlipTokProfile): Promise { + async followProfile(profile: FlipTokProfile): Promise { const next = !profile.is_following const response = await nuiCall('fliptok:follow', { active: next, profileId: profile.id, }) - if (!response.success) return + if (!response.success) return false profile.is_following = next profile.followers += next ? 1 : -1 this.feed @@ -164,6 +165,7 @@ export const useFlipTokStore = defineStore('fliptok', { .forEach((item) => { item.is_following = next }) + return true }, async loadProfile(query: { handle?: string @@ -201,11 +203,12 @@ export const useFlipTokStore = defineStore('fliptok', { if (this.viewedProfile?.id === profileId) this.viewedProfile = null return true }, - async loadComments(id: string): Promise { + async loadComments(id: string): Promise { const response = await nuiCall('fliptok:comments', { id, }) this.comments = response.success && response.data ? response.data : [] + return response.success }, async comment( id: string, @@ -237,10 +240,23 @@ export const useFlipTokStore = defineStore('fliptok', { this.connections = response.success && response.data ? response.data : [] return response.success }, - async loadActivities(): Promise { + async loadActivities(): Promise { const response = await nuiCall('fliptok:activities') this.activities = response.success && response.data ? response.data : [] if (response.success) await nuiCall('fliptok:mark-activities') + return response.success + }, + async deleteVideo(id: string): Promise { + const response = await nuiCall('fliptok:delete', { id }) + if (!response.success) return false + this.feed = this.feed.filter((video) => video.id !== id) + this.searchResults = this.searchResults.filter((video) => video.id !== id) + this.profileVideos = this.profileVideos.filter((video) => video.id !== id) + if (this.profile && this.profile.video_count > 0) + this.profile.video_count -= 1 + if (this.viewedProfile?.is_owner && this.viewedProfile.video_count > 0) + this.viewedProfile.video_count -= 1 + return true }, }, }) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index bb0253a..c31f545 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1467,18 +1467,28 @@ const defaultLocales: LocaleTree = { emptyFeedBody: 'Follow creators or post the first FlipTok.', searchPlaceholder: 'Search creators and videos', clearSearch: 'Clear search', + trendLosSantos: '# Los Santos', + trendRoleplay: '# Roleplay', + trendTrending: '# Trending', noActivity: 'No activity yet', followers: 'Followers', videos: 'Videos', emptyBio: 'No bio yet.', editProfile: 'Edit profile', + more: 'More', 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 Photos', - changeVideo: 'Change', + createBody: 'Record a clip or turn photos into a swipeable FlipTok.', + chooseMedia: 'Choose media', + chooseMediaHint: + 'Record a video, choose a clip, or select up to 10 photos.', + recordVideo: 'Record video', + camera: 'Camera', + chooseVideo: 'Choose video', + photoSlideshow: 'Photos as FlipTok', + changeMedia: 'Change', + previousPhoto: 'Previous photo', + nextPhoto: 'Next photo', caption: 'Caption', captionPlaceholder: 'Write a caption...', location: 'Add location', @@ -1492,6 +1502,11 @@ const defaultLocales: LocaleTree = { post: 'Post', draftSaved: 'Draft saved.', published: 'Your FlipTok is live.', + videoDeleted: 'Video removed.', + deleteVideoTitle: 'Remove this video?', + deleteVideoBody: + 'The video disappears from FlipTok and cannot be restored.', + deletingVideo: 'Removing...', linkCopied: 'Video link copied.', reported: 'Report submitted.', blocked: 'Creator blocked.', @@ -1499,6 +1514,8 @@ const defaultLocales: LocaleTree = { noComments: 'No comments yet', addComment: 'Add comment...', reply: 'Reply', + showReplies: 'Show {count} replies', + hideReplies: 'Hide {count} replies', replyPlaceholder: 'Write a reply...', replyingTo: 'Replying to {handle}', cancelReply: 'Cancel reply', @@ -1506,6 +1523,8 @@ const defaultLocales: LocaleTree = { reportReason: 'Reason', reportDetails: 'Additional details (optional)', submitReport: 'Submit report', + reportDiscordNote: + 'This report is sent directly to the moderation team through Discord.', reportReasons: { spam: 'Spam or misleading', harassment: 'Harassment or bullying', @@ -1547,6 +1566,7 @@ const defaultLocales: LocaleTree = { dismissReport: 'Dismiss', cancel: 'Cancel', done: 'Done', + savingProfile: 'Saving...', displayName: 'Name', username: 'Username', bio: 'Bio', @@ -1605,7 +1625,7 @@ const defaultLocales: LocaleTree = { 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_media: 'Choose valid media from this phone.', invalid_comment: 'Enter a valid comment.', comments_disabled: 'Comments are disabled.', invalid_profile: 'Check your profile details.', @@ -1618,11 +1638,15 @@ const defaultLocales: LocaleTree = { 'This Sky Cloud account already owns a registered FlipTok profile.', handle_taken: 'This username is already taken.', video_not_found: 'This video is unavailable.', + profile_not_found: 'This profile is unavailable.', rate_limited: 'Too many actions. Try again shortly.', not_authenticated: 'Sign in to Sky Cloud first.', blocked: 'This account is blocked.', not_authorized: 'You do not have moderation access.', report_not_found: 'This report is no longer open.', + report_unavailable: 'Discord reporting is not configured yet.', + request_failed: + 'Your FlipTok request could not be completed. Try again.', default: 'FlipTok could not complete the request.', }, }, diff --git a/frontend/src/views/apps/FlipTokApp.contract.test.ts b/frontend/src/views/apps/FlipTokApp.contract.test.ts index c9ecd0d..a99ec8a 100644 --- a/frontend/src/views/apps/FlipTokApp.contract.test.ts +++ b/frontend/src/views/apps/FlipTokApp.contract.test.ts @@ -18,7 +18,14 @@ const migrationSource = readFileSync( 'utf8', ) const youtubeSource = readFileSync( - new URL('../../../../sky_phone/source/server/media_metadata.lua', import.meta.url), + new URL( + '../../../../sky_phone/source/server/media_metadata.lua', + import.meta.url, + ), + 'utf8', +) +const mockServerSource = readFileSync( + new URL('../../../testserver/index.cjs', import.meta.url), 'utf8', ) @@ -51,8 +58,14 @@ describe('FlipTokApp Sky UI contract', () => { 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('v-show="expandedCommentThreads.has(thread.comment.id)"') + expect(source).toContain( + 'v-show="expandedCommentThreads.has(thread.comment.id)"', + ) expect(source).toContain("t('showReplies'") + expect(source).toContain("t('hideReplies', {") + expect(source).toContain( + 'const expandedCommentThreads = ref(new Set())', + ) expect(source).toContain('form.comments-composer') }) @@ -61,7 +74,9 @@ describe('FlipTokApp Sky UI contract', () => { expect(source).toContain('reportDiscordNote') expect(serverSource).toContain('Config.FlipTok.ReportWebhookConvar') expect(serverSource).toContain('PerformHttpRequest(webhook') - expect(serverSource).not.toContain('INSERT IGNORE INTO `sky_phone_fliptok_reports`') + expect(serverSource).not.toContain( + 'INSERT IGNORE INTO `sky_phone_fliptok_reports`', + ) }) it('exposes follower lists and editable profile photos', () => { @@ -81,15 +96,62 @@ describe('FlipTokApp Sky UI contract', () => { expect(source).toContain("choosePhotoSlideshow('photos')") expect(source).toContain("'/apps/fliptok?compose=1'") expect(source).toContain("route.query.compose === '1'") - expect(source).toContain('mediaIds: selectedMediaItems.value.map') + expect(source).toContain('mediaIds,') expect(source).toContain('class="photo-slideshow"') + expect(source).toContain('@pointerdown="beginPhotoSlideDrag(video.id, $event)"') + expect(source).toContain('@pointermove="updatePhotoSlideDrag"') + expect(source).toContain('@pointerup="endPhotoSlideDrag(video, $event)"') + expect(source).not.toContain('photo-slideshow__arrow') + expect(source).toContain('window.requestAnimationFrame(() => renderPhotoSlideDrag(drag))') + expect(source).toContain('function settlePhotoSlide(') + expect(source).toContain('class="photo-slideshow__track"') + expect(source).toContain('const animation = track.animate(') + expect(source).toContain("easing: 'cubic-bezier(0.22, 1, 0.36, 1)'") + expect(source).toMatch( + /updatePhotoSlideIndex\(videoId, index\)[\s\S]*?const animation = track\.animate\(/, + ) + expect(source).toContain('startIndex: photoSlideIndex(videoId)') + expect(source).not.toContain('element.scrollLeft') + expect(source).toContain('moveComposerPhoto(1)') + expect(source).toContain('video-shade--passive') expect(migrationSource).toContain('sky_phone_fliptok_video_media') + expect(serverSource).toContain( + 'if not Bridge.Database.Transaction(queries) then', + ) + expect(serverSource).toContain( + 'return { success = false, error = "request_failed" }', + ) + expect(source).toMatch( + /\.media-source-grid button strong\s*\{[^}]*width:\s*100%;[^}]*overflow-wrap:\s*anywhere;/s, + ) }) - it('shows a transient follow confirmation', () => { + it('shows a centered transient follow control', () => { expect(source).toContain('followFeedbackIds') expect(source).toContain('follow-dot--confirmed') expect(source).toContain('@click="followFromFeed(video)"') + expect(source).toMatch( + /\.video-actions \.follow-dot\s*\{[^}]*min-width: 20px !important;[^}]*min-height: 20px !important;[^}]*place-items: center;/s, + ) + }) + + it('keeps full-screen media inside FlipTok and raises Discover', () => { + expect(source).toMatch( + /\.video-feed\s*\{[^}]*position: absolute;[^}]*inset: 0;[^}]*height: 100%;/s, + ) + expect(source).toContain('class="fliptok-navbar fliptok-discover-navbar"') + expect(source).toMatch( + /\.fliptok-discover-navbar\.sky-navbar--no-navigation\s*\{[^}]*padding-top:/s, + ) + }) + + it('uses separate profile action boxes and disables duplicate follows', () => { + expect(source).toContain('class="fliptok-navbar fliptok-profile-navbar"') + expect(source).toMatch( + /\.profile-navbar-actions :deep\(\.sky-link\)\s*\{[^}]*width: 38px;[^}]*border:/s, + ) + expect(source).toContain(':disabled="profile.is_following"') + expect(source).toContain('@click="followConnection(profile)"') }) it('supports validated custom audio links in the composer', () => { @@ -131,4 +193,54 @@ describe('FlipTokApp Sky UI contract', () => { /\.profile-form-list,[\s\S]*?\.profile-account-list\s*\{[^}]*border-radius:\s*var\(--sky-radius-card\)/, ) }) + + it('keeps private video actions behind one server access check', () => { + expect(serverSource).toContain('local function load_accessible_video') + expect( + serverSource.match(/load_accessible_video\(/g)?.length, + ).toBeGreaterThan(6) + expect(serverSource).toContain("v.`visibility` = 'followers'") + expect(serverSource).toContain('profile_follow.`follower_id` = ?') + }) + + it('surfaces failed actions and lets owners remove their own videos', () => { + expect(source).toContain('if (!(await store.loadComments(video.id)))') + expect(source).toContain('if (!response.success) {') + expect(source).toContain('function requestDeleteVideo') + expect(source).toContain('store.deleteVideo(selectedVideo.value.id)') + expect(source).toContain('v-if="selectedVideo?.is_owner"') + expect(source).toContain('v-if="selectedVideo?.comments_enabled"') + }) + + it('keeps discovery localized and cancels delayed searches on close', () => { + expect(source).toContain("labelKey: 'trendLosSantos'") + expect(source).toContain('@click="search = trend.value"') + expect(source).toContain('new Intl.NumberFormat(phone.lang') + expect(source).toContain('new Intl.DateTimeFormat(phone.lang') + expect(source).toContain( + 'if (searchTimer !== null) window.clearTimeout(searchTimer)', + ) + }) + + it('restores the feed after a discovery preview and safely notifies on comments', () => { + expect(source).toContain( + 'const feedBeforePreview = ref(null)', + ) + expect(source).toContain('store.feed = feedBeforePreview.value') + expect(source).toContain('@click="openFeedTab"') + expect(serverSource).toContain( + 'parent_id = parents[1].parent_id or parents[1].id', + ) + expect(serverSource).toContain('if owner_id ~= profile.id then') + expect(serverSource).not.toContain('videos[1].profile_id') + }) + + it('keeps photo slides and scoped comments functional in the browser mock', () => { + expect(mockServerSource).toContain("media_type: 'photo'") + expect(mockServerSource).toContain('comment.video_id === video.id') + expect(mockServerSource).toContain( + "request.body.mediaType === 'photo' ? 'photo' : 'video'", + ) + expect(mockServerSource).toContain("endpoint === 'fliptok:delete'") + }) }) diff --git a/frontend/src/views/apps/FlipTokApp.vue b/frontend/src/views/apps/FlipTokApp.vue index 52dce55..edaa799 100644 --- a/frontend/src/views/apps/FlipTokApp.vue +++ b/frontend/src/views/apps/FlipTokApp.vue @@ -5,6 +5,8 @@ import { Camera, Check, ChevronDown, + ChevronLeft, + ChevronRight, Compass, Heart, Home, @@ -103,6 +105,8 @@ const authConfirmPassword = ref('') const authSubmitting = ref(false) const logoutDialogOpen = ref(false) const logoutSubmitting = ref(false) +const deleteDialogOpen = ref(false) +const deleteSubmitting = ref(false) const tab = ref('feed') const selectedVideo = ref(null) const selectedMedia = ref(null) @@ -146,7 +150,10 @@ const reportReason = ref< 'spam' | 'harassment' | 'dangerous' | 'illegal' | 'other' >('spam') const reportDetails = ref('') +const reportSubmitting = ref(false) const publishing = ref(false) +const profileSaving = ref(false) +const profileFollowPending = ref(false) const feedback = ref('') const likedPulseId = ref(null) const commentLikePulseId = ref(null) @@ -154,6 +161,9 @@ 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 feedBeforePreview = ref(null) +const photoSlideIndexes = ref>({}) +const composerPhotoIndex = ref(0) const profileDraft = ref({ accountType: 'person', avatarMediaId: 0, @@ -165,6 +175,21 @@ const selectedProfilePhoto = ref(null) const feedCards = new Map() const videoElements = new Map() const musicElements = new Map() +const photoSlideElements = new Map() +const photoSlideAnimations = new Map() +let photoSlideDrag: { + currentX: number + element: HTMLElement + frame: number | null + lastMoveAt: number + lastX: number + pointerId: number + startIndex: number + startTranslateX: number + startX: number + track: HTMLElement + velocity: number +} | null = null let flipTokYoutubePlayer: YouTubePlayer | null = null let flipTokYoutubeApi: YouTubeApi | null = null let flipTokYoutubeOwner = '' @@ -175,6 +200,7 @@ let likePulseTimer: number | null = null let commentLikePulseTimer: number | null = null let reactionPulseTimer: number | null = null let feedbackTimer: number | null = null +let searchTimer: number | null = null const followFeedbackTimers = new Map() const visibilityOptions = ['public', 'followers', 'private'] as const const accountTypeOptions = [ @@ -191,6 +217,11 @@ const reportReasonOptions = [ 'illegal', 'other', ] as const +const discoveryTags = [ + { labelKey: 'trendLosSantos', value: '#LosSantos' }, + { labelKey: 'trendRoleplay', value: '#Roleplay' }, + { labelKey: 'trendTrending', value: '#Trending' }, +] as const const currentProfile = computed(() => store.viewedProfile ?? store.profile) const selectedMusic = computed(() => @@ -215,6 +246,10 @@ const canPublish = computed( !customMusicLoadFailed.value, ) const selectedMediaType = computed(() => selectedMedia.value?.mediaType ?? null) +const selectedComposerPhoto = computed( + () => + selectedMediaItems.value[composerPhotoIndex.value] ?? selectedMedia.value, +) const visibilityMenuItems = computed(() => visibilityOptions.map((option) => ({ checked: visibility.value === option, @@ -247,7 +282,7 @@ function initials(name: string): string { } function compactCount(value: number): string { - return new Intl.NumberFormat('en', { + return new Intl.NumberFormat(phone.lang, { maximumFractionDigits: 1, notation: 'compact', }).format(value) @@ -259,6 +294,177 @@ function videoMedia(video: FlipTokVideo) { : [{ id: 0, mediaType: video.media_type ?? 'video', url: video.url }] } +function photoSlideIndex(videoId: string): number { + return photoSlideIndexes.value[videoId] ?? 0 +} + +function setPhotoSlideElement(videoId: string, element: unknown): void { + if (element instanceof HTMLElement) { + photoSlideElements.set(videoId, element) + const track = element.firstElementChild + if (track instanceof HTMLElement) { + track.style.transform = `translate3d(-${photoSlideIndex(videoId) * 100}%, 0, 0)` + } + return + } + const previous = photoSlideElements.get(videoId) + if (previous) cancelPhotoSlideAnimation(previous) + photoSlideElements.delete(videoId) +} + +function updatePhotoSlideIndex(videoId: string, index: number): void { + const normalizedIndex = Math.max(0, index) + if (photoSlideIndexes.value[videoId] === normalizedIndex) return + photoSlideIndexes.value = { + ...photoSlideIndexes.value, + [videoId]: normalizedIndex, + } +} + +function photoSlideTranslateX(track: HTMLElement): number { + const transform = window.getComputedStyle(track).transform + if (!transform || transform === 'none') return 0 + try { + return new DOMMatrixReadOnly(transform).m41 + } catch { + return 0 + } +} + +function beginPhotoSlideDrag(videoId: string, event: PointerEvent): void { + if (event.pointerType === 'mouse' && event.button !== 0) return + const element = photoSlideElements.get(videoId) + const track = element?.firstElementChild + if (!element || !(track instanceof HTMLElement)) return + const currentTransform = window.getComputedStyle(track).transform + cancelPhotoSlideAnimation(element) + track.style.transform = + currentTransform && currentTransform !== 'none' + ? currentTransform + : `translate3d(-${photoSlideIndex(videoId) * 100}%, 0, 0)` + const startTranslateX = photoSlideTranslateX(track) + const now = performance.now() + photoSlideDrag = { + currentX: event.clientX, + element, + frame: null, + lastMoveAt: now, + lastX: event.clientX, + pointerId: event.pointerId, + startIndex: photoSlideIndex(videoId), + startTranslateX, + startX: event.clientX, + track, + velocity: 0, + } + element.classList.add('photo-slideshow--dragging') + element.setPointerCapture(event.pointerId) +} + +function renderPhotoSlideDrag( + drag: NonNullable, +): void { + drag.frame = null + if (photoSlideDrag !== drag) return + const translateX = drag.startTranslateX + (drag.currentX - drag.startX) + drag.track.style.transform = `translate3d(${translateX}px, 0, 0)` +} + +function updatePhotoSlideDrag(event: PointerEvent): void { + if (!photoSlideDrag || photoSlideDrag.pointerId !== event.pointerId) return + event.preventDefault() + const samples = event.getCoalescedEvents?.() ?? [event] + const sample = samples.at(-1) ?? event + const now = performance.now() + const elapsed = Math.max(1, now - photoSlideDrag.lastMoveAt) + const instantVelocity = (photoSlideDrag.lastX - sample.clientX) / elapsed + photoSlideDrag.velocity = + photoSlideDrag.velocity * 0.68 + instantVelocity * 0.32 + photoSlideDrag.currentX = sample.clientX + photoSlideDrag.lastX = sample.clientX + photoSlideDrag.lastMoveAt = now + if (photoSlideDrag.frame === null) { + const drag = photoSlideDrag + drag.frame = window.requestAnimationFrame(() => renderPhotoSlideDrag(drag)) + } +} + +function endPhotoSlideDrag(video: FlipTokVideo, event: PointerEvent): void { + if (!photoSlideDrag || photoSlideDrag.pointerId !== event.pointerId) return + const drag = photoSlideDrag + const { element, pointerId } = drag + drag.currentX = event.clientX + if (drag.frame !== null) window.cancelAnimationFrame(drag.frame) + renderPhotoSlideDrag(drag) + const lastIndex = videoMedia(video).length - 1 + const distance = drag.startX - drag.currentX + const shouldAdvance = + Math.abs(distance) > element.clientWidth * 0.12 || + Math.abs(drag.velocity) > 0.35 + const direction = Math.sign(distance || drag.velocity) + const nextIndex = Math.max( + 0, + Math.min( + lastIndex, + shouldAdvance ? drag.startIndex + direction : drag.startIndex, + ), + ) + if (element.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId) + element.classList.remove('photo-slideshow--dragging') + photoSlideDrag = null + settlePhotoSlide(video.id, element, nextIndex) +} + +function cancelPhotoSlideAnimation(element: HTMLElement): void { + const animation = photoSlideAnimations.get(element) + animation?.cancel() + photoSlideAnimations.delete(element) + element.classList.remove('photo-slideshow--settling') +} + +function settlePhotoSlide( + videoId: string, + element: HTMLElement, + index: number, +): void { + cancelPhotoSlideAnimation(element) + const track = element.firstElementChild + if (!(track instanceof HTMLElement)) return + const startTransform = window.getComputedStyle(track).transform + const targetTransform = `translate3d(-${index * 100}%, 0, 0)` + const animationTarget = `translate3d(${-index * element.clientWidth}px, 0, 0)` + updatePhotoSlideIndex(videoId, index) + element.classList.add('photo-slideshow--settling') + const animation = track.animate( + [ + { transform: startTransform === 'none' ? track.style.transform : startTransform }, + { transform: animationTarget }, + ], + { + duration: 420, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + fill: 'forwards', + }, + ) + photoSlideAnimations.set(element, animation) + animation.onfinish = () => { + track.style.transform = targetTransform + animation.cancel() + photoSlideAnimations.delete(element) + element.classList.remove('photo-slideshow--settling') + } +} + +function moveComposerPhoto(direction: -1 | 1): void { + composerPhotoIndex.value = Math.max( + 0, + Math.min( + selectedMediaItems.value.length - 1, + composerPhotoIndex.value + direction, + ), + ) +} + function parseYoutubeVideoId(value: string): string { const trimmed = value.trim() if (!trimmed || trimmed.length > 500) return '' @@ -351,7 +557,7 @@ function formatTimestamp(value: number): string { timestamp.getFullYear() === now.getFullYear() && timestamp.getMonth() === now.getMonth() && timestamp.getDate() === now.getDate() - return new Intl.DateTimeFormat(undefined, { + return new Intl.DateTimeFormat(phone.lang, { ...(sameDay ? {} : { day: '2-digit', month: 'short' }), hour: '2-digit', minute: '2-digit', @@ -710,7 +916,12 @@ function observeVideos(): void { : undefined if (item && video) void playFeedVideo(item, video) else if (item?.music_source === 'youtube' && item.music_video_id) { - void playFlipTokYoutube(id, item.music_video_id, item.music_volume, 0) + void playFlipTokYoutube( + id, + item.music_video_id, + item.music_volume, + 0, + ) } else if (item) { const music = musicElements.get(id) if (music) void music.play().catch(() => undefined) @@ -860,10 +1071,14 @@ async function followFromFeed(video: FlipTokVideo): Promise { const pending = new Set(followPendingIds.value) pending.add(video.id) followPendingIds.value = pending - await store.follow(video) + const followed = await store.follow(video) const settled = new Set(followPendingIds.value) settled.delete(video.id) followPendingIds.value = settled + if (!followed) { + notify(t('errors.default')) + return + } if (!video.is_following) return const visible = new Set(followFeedbackIds.value) @@ -883,7 +1098,11 @@ async function followFromFeed(video: FlipTokVideo): Promise { } async function changeMode(mode: 'for-you' | 'following'): Promise { - await store.loadFeed(mode) + if (!(await store.loadFeed(mode))) { + notify(t('errors.default')) + return + } + feedBeforePreview.value = null await nextTick() observeVideos() } @@ -892,7 +1111,10 @@ async function openComments(video: FlipTokVideo): Promise { selectedVideo.value = video replyingTo.value = null expandedCommentThreads.value = new Set() - await store.loadComments(video.id) + if (!(await store.loadComments(video.id))) { + notify(t('errors.video_not_found')) + return + } commentsOpen.value = true } @@ -926,23 +1148,32 @@ async function submitComment(): Promise { await store.loadComments(selectedVideo.value.id) } -function chooseMedia(source: 'camera' | 'photos', mediaType: 'photo' | 'video'): void { - messageMedia.begin('fliptok:compose', mediaType, '/apps/fliptok?compose=1', mediaType === 'photo' ? 10 : 1, { - caption: caption.value, - commentsEnabled: commentsEnabled.value, - visibility: visibility.value, - trimStartMs: trimStartMs.value, - trimEndMs: trimEndMs.value, - coverTimeMs: coverTimeMs.value, - originalVolume: originalVolume.value, - musicVolume: musicVolume.value, - musicTrack: musicTrack.value, - customMusicUrl: customMusicUrl.value, - customMusicTitle: customMusicTitle.value, - customMusicArtist: customMusicArtist.value, - customMusicVideoId: customMusicVideoId.value, - selectedMediaItems: selectedMediaItems.value, - }) +function chooseMedia( + source: 'camera' | 'photos', + mediaType: 'photo' | 'video', +): void { + messageMedia.begin( + 'fliptok:compose', + mediaType, + '/apps/fliptok?compose=1', + mediaType === 'photo' ? 10 : 1, + { + caption: caption.value, + commentsEnabled: commentsEnabled.value, + visibility: visibility.value, + trimStartMs: trimStartMs.value, + trimEndMs: trimEndMs.value, + coverTimeMs: coverTimeMs.value, + originalVolume: originalVolume.value, + musicVolume: musicVolume.value, + musicTrack: musicTrack.value, + customMusicUrl: customMusicUrl.value, + customMusicTitle: customMusicTitle.value, + customMusicArtist: customMusicArtist.value, + customMusicVideoId: customMusicVideoId.value, + selectedMediaItems: selectedMediaItems.value, + }, + ) void router.push({ path: `/apps/${source}`, query: { mediaAttachment: mediaType }, @@ -959,13 +1190,18 @@ function choosePhotoSlideshow(source: 'camera' | 'photos' = 'photos'): void { async function publish(draft = false): Promise { if (!selectedMedia.value || publishing.value) return + const mediaIds = ( + selectedMediaItems.value.length + ? selectedMediaItems.value + : [selectedMedia.value] + ).map((media) => media.id) publishing.value = true const response = await nuiCall('fliptok:publish', { caption: caption.value, commentsEnabled: commentsEnabled.value, draft, mediaId: selectedMedia.value.id, - mediaIds: selectedMediaItems.value.map((media) => media.id), + mediaIds, mediaType: selectedMediaType.value, trimStartMs: trimStartMs.value, trimEndMs: trimEndMs.value || null, @@ -982,6 +1218,7 @@ async function publish(draft = false): Promise { resetComposer() composeOpen.value = false tab.value = 'feed' + feedBeforePreview.value = null await store.loadFeed('for-you') await nextTick() observeVideos() @@ -991,6 +1228,7 @@ async function publish(draft = false): Promise { function resetComposer(): void { selectedMedia.value = null selectedMediaItems.value = [] + composerPhotoIndex.value = 0 caption.value = '' visibility.value = 'public' commentsEnabled.value = true @@ -1109,22 +1347,38 @@ async function runSearch(): Promise { } function openDiscoveredVideo(video: FlipTokVideo): void { + if (!feedBeforePreview.value) feedBeforePreview.value = [...store.feed] store.feed = [video] tab.value = 'feed' } +async function openFeedTab(): Promise { + if (feedBeforePreview.value) { + store.feed = feedBeforePreview.value + feedBeforePreview.value = null + } + tab.value = 'feed' + await nextTick() + observeVideos() +} + async function openProfile(profileId?: number, handle?: string): Promise { const loaded = await store.loadProfile(handle ? { handle } : { profileId }) if (loaded) { commentsOpen.value = false actionsOpen.value = false tab.value = 'profile' + return } + notify(t('errors.profile_not_found')) } async function openOwnProfile(): Promise { if (store.profile) { - await store.loadProfile({ profileId: store.profile.id }) + if (!(await store.loadProfile({ profileId: store.profile.id }))) { + notify(t('errors.profile_not_found')) + return + } store.viewedProfile = null } else store.showOwnProfile() tab.value = 'profile' @@ -1146,6 +1400,11 @@ async function openConnectionProfile(profile: FlipTokProfile): Promise { await openProfile(profile.id) } +async function followConnection(profile: FlipTokProfile): Promise { + if (profile.is_owner || profile.is_following) return + if (!(await store.followProfile(profile))) notify(t('errors.default')) +} + function openActions(video: FlipTokVideo): void { selectedVideo.value = video actionsOpen.value = true @@ -1153,7 +1412,11 @@ function openActions(video: FlipTokVideo): void { async function shareVideo(video: FlipTokVideo): Promise { const response = await nuiCall('fliptok:share', { id: video.id }) - if (response.success) video.share_count += 1 + if (!response.success) { + notify(t(`errors.${response.error ?? 'default'}`)) + return + } + video.share_count += 1 useEasyShareStore().open({ appId: 'fliptok', copyText: `@${video.handle}: ${video.caption}`, @@ -1181,12 +1444,14 @@ function shareCurrentProfile(): void { } async function reportVideo(): Promise { - if (!selectedVideo.value) return + if (!selectedVideo.value || reportSubmitting.value) return + reportSubmitting.value = true const response = await nuiCall('fliptok:report', { details: reportDetails.value.trim(), id: selectedVideo.value.id, reason: reportReason.value, }) + reportSubmitting.value = false if (!response.success) return notify(t(`errors.${response.error ?? 'default'}`)) actionsOpen.value = false @@ -1211,7 +1476,7 @@ async function blockCreator(): Promise { async function blockCurrentProfile(): Promise { if (!currentProfile.value || currentProfile.value.is_owner) return if (await store.blockProfile(currentProfile.value.id)) { - openOwnProfile() + await openOwnProfile() notify(t('blocked')) } } @@ -1254,11 +1519,14 @@ function removeProfilePhoto(): void { } async function saveProfile(): Promise { + if (profileSaving.value) return + profileSaving.value = true const response = await nuiCall('fliptok:update-profile', { ...profileDraft.value, avatarMediaId: selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId, }) + profileSaving.value = false if (!response.success || !response.data) return notify(t(`errors.${response.error ?? 'default'}`)) store.profile = response.data @@ -1267,11 +1535,41 @@ async function saveProfile(): Promise { profileEditOpen.value = false } +async function followCurrentProfile(): Promise { + const profile = currentProfile.value + if (!profile || profile.is_owner || profileFollowPending.value) return + profileFollowPending.value = true + const success = await store.followProfile(profile) + profileFollowPending.value = false + if (!success) notify(t('errors.default')) +} + +function requestDeleteVideo(): void { + if (!selectedVideo.value?.is_owner) return + actionsOpen.value = false + deleteDialogOpen.value = true +} + +async function confirmDeleteVideo(): Promise { + if (!selectedVideo.value || deleteSubmitting.value) return + deleteSubmitting.value = true + const deleted = await store.deleteVideo(selectedVideo.value.id) + deleteSubmitting.value = false + if (!deleted) { + notify(t('errors.video_not_found')) + return + } + deleteDialogOpen.value = false + selectedVideo.value = null + notify(t('videoDeleted')) +} + watch(tab, async (value) => { videoElements.forEach((video) => video.pause()) musicElements.forEach((audio) => audio.pause()) pauseFlipTokYoutube() - if (value === 'activity') await store.loadActivities() + if (value === 'activity' && !(await store.loadActivities())) + notify(t('errors.default')) if (value === 'discover' && store.searchResults.length === 0) await runSearch() if (value === 'feed') { @@ -1280,6 +1578,22 @@ watch(tab, async (value) => { } }) +watch(commentsOpen, (opened) => { + if (opened) return + expandedCommentThreads.value = new Set() + replyingTo.value = null +}) + +watch( + () => selectedMediaItems.value.length, + (length) => { + composerPhotoIndex.value = Math.max( + 0, + Math.min(composerPhotoIndex.value, length - 1), + ) + }, +) + watch( () => store.authenticated, async (authenticated) => { @@ -1290,11 +1604,11 @@ watch( ) watch(search, () => { - window.clearTimeout((runSearch as unknown as { timer?: number }).timer) - ;(runSearch as unknown as { timer?: number }).timer = window.setTimeout( - runSearch, - 250, - ) + if (searchTimer !== null) window.clearTimeout(searchTimer) + searchTimer = window.setTimeout(() => { + searchTimer = null + void runSearch() + }, 250) }) watch(originalVolume, (value) => { @@ -1368,6 +1682,7 @@ onMounted(async () => { : (selection.context?.selectedMediaItems ?? []) selectedMedia.value = restoredMedia[0] ?? null selectedMediaItems.value = restoredMedia + composerPhotoIndex.value = 0 caption.value = selection.context?.caption ?? '' commentsEnabled.value = selection.context?.commentsEnabled ?? true visibility.value = selection.context?.visibility ?? 'public' @@ -1387,7 +1702,8 @@ onMounted(async () => { composeOpen.value = true } await store.bootstrap() - if (route.query.compose === '1' && store.authenticated) composeOpen.value = true + if (route.query.compose === '1' && store.authenticated) + composeOpen.value = true const easyShareId = String(route.query.easyShareId ?? '') if (easyShareId && route.query.easyShareKind === 'profile') { const profileId = Number(easyShareId) @@ -1410,9 +1726,23 @@ onBeforeUnmount(() => { if (likePulseTimer !== null) window.clearTimeout(likePulseTimer) if (commentLikePulseTimer !== null) window.clearTimeout(commentLikePulseTimer) if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer) + if (feedbackTimer !== null) window.clearTimeout(feedbackTimer) + if (searchTimer !== null) window.clearTimeout(searchTimer) followFeedbackTimers.forEach((timer) => window.clearTimeout(timer)) followFeedbackTimers.clear() + if (photoSlideDrag) { + if (photoSlideDrag.frame !== null) { + window.cancelAnimationFrame(photoSlideDrag.frame) + } + if (photoSlideDrag.element.hasPointerCapture(photoSlideDrag.pointerId)) { + photoSlideDrag.element.releasePointerCapture(photoSlideDrag.pointerId) + } + photoSlideDrag.element.classList.remove('photo-slideshow--dragging') + photoSlideDrag = null + } + Array.from(photoSlideAnimations.keys()).forEach(cancelPhotoSlideAnimation) feedCards.clear() + photoSlideElements.clear() videoElements.forEach((video) => video.pause()) destroyFlipTokYoutube() }) @@ -1567,15 +1897,33 @@ onBeforeUnmount(() => { :key="video.id" :ref="(el) => setFeedCard(video.id, el)" class="video-card" + :class="{ 'video-card--photo': video.media_type === 'photo' }" > -
- +
+
+ +
+ + {{ photoSlideIndex(video.id) + 1 }} / + {{ videoMedia(video).length }} +