mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
FIX - stabilize FlipTok media interactions
Complete the FlipTok UI, store, mock API, and server audit. Add reliable photo-slide publishing, comments, privacy checks, action error handling, deletion, localization, and profile interaction states. Replace the photo carousel scroll path with a GPU-accelerated drag and swipe track that supports rapid consecutive gestures, and cover the corrected behavior with focused contract, store, and mock smoke tests.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,26 +137,27 @@ export const useFlipTokStore = defineStore('fliptok', {
|
||||
if (kind === 'like') video.like_count += next ? -1 : 1
|
||||
}
|
||||
},
|
||||
async follow(video: FlipTokVideo): Promise<void> {
|
||||
async follow(video: FlipTokVideo): Promise<boolean> {
|
||||
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<void> {
|
||||
async followProfile(profile: FlipTokProfile): Promise<boolean> {
|
||||
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<void> {
|
||||
async loadComments(id: string): Promise<boolean> {
|
||||
const response = await nuiCall<FlipTokComment[]>('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<void> {
|
||||
async loadActivities(): Promise<boolean> {
|
||||
const response = await nuiCall<FlipTokActivity[]>('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<boolean> {
|
||||
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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string>())',
|
||||
)
|
||||
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<FlipTokVideo[] | null>(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'")
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+152
-15
@@ -585,6 +585,14 @@ let flipTokVideos = [
|
||||
music_source: '',
|
||||
music_url: '',
|
||||
music_video_id: '',
|
||||
media_type: 'video',
|
||||
media: [
|
||||
{
|
||||
id: 2,
|
||||
mediaType: 'video',
|
||||
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
||||
},
|
||||
],
|
||||
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
||||
comments_enabled: true,
|
||||
is_liked: false,
|
||||
@@ -617,6 +625,14 @@ let flipTokVideos = [
|
||||
music_source: 'audio',
|
||||
music_url: flipTokMusicTracks[0].url,
|
||||
music_video_id: '',
|
||||
media_type: 'video',
|
||||
media: [
|
||||
{
|
||||
id: 6,
|
||||
mediaType: 'video',
|
||||
url: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
|
||||
},
|
||||
],
|
||||
url: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
|
||||
comments_enabled: true,
|
||||
is_liked: true,
|
||||
@@ -629,6 +645,56 @@ let flipTokVideos = [
|
||||
share_count: 220,
|
||||
created_at: Date.now() - 7200000,
|
||||
},
|
||||
{
|
||||
avatar_url: null,
|
||||
id: 'fliptok-3',
|
||||
profile_id: 1,
|
||||
handle: 'skyline',
|
||||
display_name: 'Skyline',
|
||||
verified: true,
|
||||
caption: 'Three stops through Los Santos. #CityLife',
|
||||
location: '',
|
||||
trim_start_ms: 0,
|
||||
trim_end_ms: null,
|
||||
cover_time_ms: 0,
|
||||
original_volume: 0,
|
||||
music_volume: 0,
|
||||
music_track: '',
|
||||
music_title: '',
|
||||
music_artist: '',
|
||||
music_source: '',
|
||||
music_url: '',
|
||||
music_video_id: '',
|
||||
media_type: 'photo',
|
||||
media: [
|
||||
{
|
||||
id: 1,
|
||||
mediaType: 'photo',
|
||||
url: mockGalleryImage('City Night', '#172554', '#111827', '#7c3aed'),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
mediaType: 'photo',
|
||||
url: mockGalleryImage('Beach Drive', '#0369a1', '#164e63', '#fbbf24'),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
mediaType: 'photo',
|
||||
url: mockGalleryImage('Mountain Road', '#475569', '#334155', '#e2e8f0'),
|
||||
},
|
||||
],
|
||||
url: mockGalleryImage('City Night', '#172554', '#111827', '#7c3aed'),
|
||||
comments_enabled: true,
|
||||
is_liked: false,
|
||||
is_saved: false,
|
||||
is_following: false,
|
||||
is_owner: true,
|
||||
like_count: 812,
|
||||
comment_count: 0,
|
||||
view_count: 17300,
|
||||
share_count: 64,
|
||||
created_at: Date.now() - 10800000,
|
||||
},
|
||||
]
|
||||
let flipTokComments = [
|
||||
{
|
||||
@@ -637,6 +703,7 @@ let flipTokComments = [
|
||||
is_liked: false,
|
||||
like_count: 7,
|
||||
parent_id: null,
|
||||
video_id: 'fliptok-1',
|
||||
profile_id: 2,
|
||||
reply_to_handle: null,
|
||||
handle: 'nova',
|
||||
@@ -6951,9 +7018,14 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
const video = flipTokVideos.find((item) => item.id === request.body.id)
|
||||
if (video) {
|
||||
const key = request.body.kind === 'like' ? 'is_liked' : 'is_saved'
|
||||
const changed = video[key] !== request.body.active
|
||||
video[key] = request.body.active
|
||||
if (changed && request.body.kind === 'like')
|
||||
video.like_count += request.body.active ? 1 : -1
|
||||
}
|
||||
response.json({ success: true })
|
||||
response.json(
|
||||
video ? { success: true } : { success: false, error: 'video_not_found' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:video') {
|
||||
@@ -6981,16 +7053,36 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:comments') {
|
||||
response.json({ success: true, data: flipTokComments })
|
||||
const video = flipTokVideos.find((item) => item.id === request.body.id)
|
||||
response.json(
|
||||
video
|
||||
? {
|
||||
success: true,
|
||||
data: flipTokComments.filter(
|
||||
(comment) => comment.video_id === video.id,
|
||||
),
|
||||
}
|
||||
: { success: false, error: 'video_not_found' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:comment') {
|
||||
const video = flipTokVideos.find((item) => item.id === request.body.id)
|
||||
if (!video) {
|
||||
response.json({ success: false, error: 'video_not_found' })
|
||||
return
|
||||
}
|
||||
if (!video.comments_enabled) {
|
||||
response.json({ success: false, error: 'comments_disabled' })
|
||||
return
|
||||
}
|
||||
const comment = {
|
||||
avatar_url: flipTokProfile.avatar_url,
|
||||
id: `comment-${Date.now()}`,
|
||||
is_liked: false,
|
||||
like_count: 0,
|
||||
parent_id: request.body.parentId || null,
|
||||
video_id: video.id,
|
||||
profile_id: 1,
|
||||
reply_to_handle: request.body.parentId
|
||||
? flipTokComments.find((item) => item.id === request.body.parentId)
|
||||
@@ -7003,16 +7095,22 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
created_at: Date.now(),
|
||||
}
|
||||
flipTokComments.push(comment)
|
||||
video.comment_count += 1
|
||||
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) {
|
||||
const changed = comment.is_liked !== (request.body.active === true)
|
||||
comment.is_liked = request.body.active === true
|
||||
comment.like_count += comment.is_liked ? 1 : -1
|
||||
if (changed) comment.like_count += comment.is_liked ? 1 : -1
|
||||
}
|
||||
response.json({ success: true })
|
||||
response.json(
|
||||
comment
|
||||
? { success: true }
|
||||
: { success: false, error: 'invalid_comment' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:activities') {
|
||||
@@ -7197,13 +7295,24 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:publish') {
|
||||
const media = mockMedia.find(
|
||||
(item) => item.id === request.body.mediaId && item.mediaType === 'video',
|
||||
const mediaType = request.body.mediaType === 'photo' ? 'photo' : 'video'
|
||||
const submittedMediaIds = Array.isArray(request.body.mediaIds)
|
||||
? request.body.mediaIds
|
||||
: [request.body.mediaId]
|
||||
const mediaIds = [...new Set(submittedMediaIds.map(Number))]
|
||||
const mediaItems = mediaIds.map((id) =>
|
||||
mockMedia.find((item) => item.id === id && item.mediaType === mediaType),
|
||||
)
|
||||
if (!media) {
|
||||
if (
|
||||
mediaItems.length < 1 ||
|
||||
mediaItems.length > 10 ||
|
||||
(mediaType === 'video' && mediaItems.length !== 1) ||
|
||||
mediaItems.some((item) => !item)
|
||||
) {
|
||||
response.json({ success: false, error: 'invalid_media' })
|
||||
return
|
||||
}
|
||||
const media = mediaItems[0]
|
||||
const musicTrack = String(request.body.musicTrack || '')
|
||||
const configuredTrack = flipTokMusicTracks.find(
|
||||
(track) => track.id === musicTrack,
|
||||
@@ -7222,7 +7331,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
response.json({ success: false, error: 'invalid_music_url' })
|
||||
return
|
||||
}
|
||||
flipTokVideos.unshift({
|
||||
const createdVideo = {
|
||||
avatar_url: flipTokProfile.avatar_url,
|
||||
id: `fliptok-${Date.now()}`,
|
||||
profile_id: 1,
|
||||
@@ -7230,11 +7339,13 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
display_name: flipTokProfile.display_name,
|
||||
verified: flipTokProfile.verified,
|
||||
caption: request.body.caption,
|
||||
location: request.body.location,
|
||||
trim_start_ms: request.body.trimStartMs || 0,
|
||||
trim_end_ms: request.body.trimEndMs || null,
|
||||
cover_time_ms: request.body.coverTimeMs || 0,
|
||||
original_volume: request.body.originalVolume ?? 100,
|
||||
location: String(request.body.location || ''),
|
||||
trim_start_ms: mediaType === 'photo' ? 0 : request.body.trimStartMs || 0,
|
||||
trim_end_ms:
|
||||
mediaType === 'photo' ? null : request.body.trimEndMs || null,
|
||||
cover_time_ms: mediaType === 'photo' ? 0 : request.body.coverTimeMs || 0,
|
||||
original_volume:
|
||||
mediaType === 'photo' ? 0 : (request.body.originalVolume ?? 100),
|
||||
music_volume: request.body.musicVolume || 0,
|
||||
music_track: configuredTrack?.id || '',
|
||||
music_title: configuredTrack?.title || youtubeMetadata?.title || '',
|
||||
@@ -7248,6 +7359,12 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
: '',
|
||||
music_url: youtubeVideoId ? '' : configuredTrack?.url || customMusicUrl,
|
||||
music_video_id: youtubeVideoId,
|
||||
media_type: mediaType,
|
||||
media: mediaItems.map((item) => ({
|
||||
id: item.id,
|
||||
mediaType: item.mediaType,
|
||||
url: item.url,
|
||||
})),
|
||||
url: media.url,
|
||||
comments_enabled: request.body.commentsEnabled,
|
||||
is_liked: false,
|
||||
@@ -7259,8 +7376,28 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
view_count: 0,
|
||||
share_count: 0,
|
||||
created_at: Date.now(),
|
||||
})
|
||||
response.json({ success: true, data: { id: flipTokVideos[0].id } })
|
||||
}
|
||||
if (request.body.draft !== true) {
|
||||
flipTokVideos.unshift(createdVideo)
|
||||
flipTokProfile.video_count += 1
|
||||
}
|
||||
response.json({ success: true, data: { id: createdVideo.id } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:delete') {
|
||||
const index = flipTokVideos.findIndex(
|
||||
(video) => video.id === request.body.id && video.is_owner,
|
||||
)
|
||||
if (index < 0) {
|
||||
response.json({ success: false, error: 'video_not_found' })
|
||||
return
|
||||
}
|
||||
flipTokVideos.splice(index, 1)
|
||||
flipTokComments = flipTokComments.filter(
|
||||
(comment) => comment.video_id !== request.body.id,
|
||||
)
|
||||
flipTokProfile.video_count = Math.max(0, flipTokProfile.video_count - 1)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint.startsWith('fliptok:')) {
|
||||
|
||||
@@ -201,6 +201,80 @@ async function verifyStatefulActions(baseUrl) {
|
||||
.every((item) => typeof item.thumbnailUrl === 'string'),
|
||||
'gallery:list returned a test video without a thumbnail',
|
||||
)
|
||||
const flipTokPhotos = gallery
|
||||
.filter((item) => item.mediaType === 'photo')
|
||||
.slice(0, 3)
|
||||
const createdFlipTok = await expectSuccess(
|
||||
baseUrl,
|
||||
'fliptok:publish',
|
||||
{
|
||||
caption: 'Browser mock photo slideshow',
|
||||
commentsEnabled: true,
|
||||
coverTimeMs: 0,
|
||||
customMusicUrl: '',
|
||||
draft: false,
|
||||
mediaId: flipTokPhotos[0].id,
|
||||
mediaIds: flipTokPhotos.map((item) => item.id),
|
||||
mediaType: 'photo',
|
||||
musicTrack: '',
|
||||
musicVolume: 0,
|
||||
originalVolume: 0,
|
||||
trimEndMs: null,
|
||||
trimStartMs: 0,
|
||||
visibility: 'public',
|
||||
},
|
||||
true,
|
||||
)
|
||||
let flipTokFeed = await expectSuccess(
|
||||
baseUrl,
|
||||
'fliptok:feed',
|
||||
{ mode: 'for-you', offset: 0 },
|
||||
true,
|
||||
)
|
||||
const photoFlipTok = flipTokFeed.items.find(
|
||||
(item) => item.id === createdFlipTok.id,
|
||||
)
|
||||
assert.equal(photoFlipTok.media_type, 'photo')
|
||||
assert.deepEqual(
|
||||
photoFlipTok.media.map((item) => item.id),
|
||||
flipTokPhotos.map((item) => item.id),
|
||||
'fliptok:publish did not preserve photo order',
|
||||
)
|
||||
await expectSuccess(baseUrl, 'fliptok:comment', {
|
||||
body: 'Browser mock scoped comment',
|
||||
id: createdFlipTok.id,
|
||||
})
|
||||
const createdComments = await expectSuccess(
|
||||
baseUrl,
|
||||
'fliptok:comments',
|
||||
{ id: createdFlipTok.id },
|
||||
true,
|
||||
)
|
||||
assert(
|
||||
createdComments.some((comment) => comment.body.includes('scoped comment')),
|
||||
'fliptok:comment did not persist for its video',
|
||||
)
|
||||
const otherComments = await expectSuccess(
|
||||
baseUrl,
|
||||
'fliptok:comments',
|
||||
{ id: 'fliptok-1' },
|
||||
true,
|
||||
)
|
||||
assert(
|
||||
!otherComments.some((comment) => comment.body.includes('scoped comment')),
|
||||
'fliptok:comments leaked comments from another video',
|
||||
)
|
||||
await expectSuccess(baseUrl, 'fliptok:delete', { id: createdFlipTok.id })
|
||||
flipTokFeed = await expectSuccess(
|
||||
baseUrl,
|
||||
'fliptok:feed',
|
||||
{ mode: 'for-you', offset: 0 },
|
||||
true,
|
||||
)
|
||||
assert(
|
||||
!flipTokFeed.items.some((item) => item.id === createdFlipTok.id),
|
||||
'fliptok:delete kept the removed video in the feed',
|
||||
)
|
||||
await expectSuccess(
|
||||
baseUrl,
|
||||
'gallery:favorite',
|
||||
|
||||
Reference in New Issue
Block a user