mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +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',
|
||||
|
||||
@@ -562,12 +562,13 @@ Locales["en"] = {
|
||||
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", 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 = "Record a clip or turn photos into a swipeable FlipTok.", chooseMedia = "Add media", chooseMediaHint = "Record a video, choose a clip, or select up to 10 photos.", recordVideo = "Record", camera = "Camera", chooseVideo = "Video", photoSlideshow = "Photo slides", changeMedia = "Change",
|
||||
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 = "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", 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...", reply = "Reply", showReplies = "View {count} replies", hideReplies = "Hide replies", replyPlaceholder = "Write a reply...", replyingTo = "Replying to {handle}", cancelReply = "Cancel reply", report = "Report video", reportReason = "Reason",
|
||||
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.",
|
||||
comments = "Comments", 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", report = "Report video", 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", dangerous = "Dangerous activity", illegal = "Illegal content", other = "Something else" },
|
||||
block = "Block creator", follow = "Follow", unfollow = "Following", backToProfile = "Back", cancel = "Cancel",
|
||||
@@ -576,7 +577,7 @@ Locales["en"] = {
|
||||
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", profilePhoto = "Profile photo", changePhoto = "Change photo", chooseFromGallery = "Photos", takePhoto = "Camera", removePhoto = "Remove photo", noConnections = "No profiles to show",
|
||||
done = "Done", savingProfile = "Saving...", displayName = "Name", username = "Username", bio = "Bio", accountType = "Account type", profilePhoto = "Profile photo", changePhoto = "Change photo", chooseFromGallery = "Photos", 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.",
|
||||
@@ -587,7 +588,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 FlipTok details.", invalid_music_url = "Use a valid YouTube or direct HTTPS audio-file link.", 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.", 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.", report_unavailable = "Discord reporting is not configured yet.", 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 FlipTok details.", invalid_music_url = "Use a valid YouTube or direct HTTPS audio-file link.", 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.", 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.", profile_not_found = "This profile is unavailable.", 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.", 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",
|
||||
|
||||
@@ -155,11 +155,31 @@ local function load_profile(profile_id, viewer_id)
|
||||
return rows[1] and hydrate_profile(rows[1], viewer_id) or nil
|
||||
end
|
||||
|
||||
local function load_accessible_video(video_id, viewer_id)
|
||||
if type(video_id) ~= "string" or video_id == "" or #video_id > 64 then return nil end
|
||||
local rows = Bridge.Database.Query([[SELECT v.`profile_id`, v.`comments_enabled`
|
||||
FROM `sky_phone_fliptok_videos` v
|
||||
WHERE v.`id` = ? AND v.`status` = 'published'
|
||||
AND (v.`profile_id` = ? OR v.`visibility` = 'public' OR
|
||||
(v.`visibility` = 'followers' AND EXISTS(
|
||||
SELECT 1 FROM `sky_phone_fliptok_follows` follow
|
||||
WHERE follow.`follower_id` = ? AND follow.`following_id` = v.`profile_id`)))
|
||||
AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` block WHERE
|
||||
(block.`blocker_id` = ? AND block.`blocked_id` = v.`profile_id`) OR
|
||||
(block.`blocked_id` = ? AND block.`blocker_id` = v.`profile_id`))
|
||||
LIMIT 1]], { video_id, viewer_id, viewer_id, viewer_id, viewer_id })
|
||||
return rows[1]
|
||||
end
|
||||
|
||||
local function notify_profile(recipient_id, actor_id, kind, video_id)
|
||||
local rows = Bridge.Database.Query([[SELECT recipient.`account_id`, actor.`display_name` AS `actor_name`
|
||||
FROM `sky_phone_fliptok_profiles` recipient
|
||||
JOIN `sky_phone_fliptok_profiles` actor ON actor.`id` = ?
|
||||
WHERE recipient.`id` = ? LIMIT 1]], { actor_id, recipient_id })
|
||||
if not rows[1] then
|
||||
Bridge.Debug("warn", "[sky_phone] FlipTok notification target or actor no longer exists.", { always = true })
|
||||
return
|
||||
end
|
||||
SkyPhone.NotifyAccountDevices(tonumber(rows[1].account_id), "sky_phone:fliptok:new", {
|
||||
actor = rows[1].actor_name,
|
||||
kind = kind,
|
||||
@@ -544,7 +564,9 @@ Bridge.Callbacks.Register("sky_phone:fliptok:publish", function(source, data)
|
||||
params = { id, current_media_id, index },
|
||||
}
|
||||
end
|
||||
Bridge.Database.Transaction(queries)
|
||||
if not Bridge.Database.Transaction(queries) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
return { success = true, data = { id = id } }
|
||||
end)
|
||||
|
||||
@@ -555,16 +577,13 @@ Bridge.Callbacks.Register("sky_phone:fliptok:react", function(source, data)
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or (data.kind ~= "like" and data.kind ~= "save") or type(data.active) ~= "boolean" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local videos = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_videos` WHERE `id` = ? AND `status` = 'published' LIMIT 1", { data.id })
|
||||
if not videos[1] then return { success = false, error = "video_not_found" } 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
|
||||
local video = load_accessible_video(data.id, profile.id)
|
||||
if not video then return { success = false, error = "video_not_found" } end
|
||||
local owner_id = tonumber(video.profile_id)
|
||||
if data.active then
|
||||
local inserted = Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_reactions` (`video_id`, `profile_id`, `kind`) VALUES (?, ?, ?)", { data.id, profile.id, data.kind })
|
||||
if affected_rows(inserted) > 0 and data.kind == "like" and owner_id ~= profile.id then
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'like')", { new_id(), videos[1].profile_id, profile.id, data.id })
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'like')", { new_id(), video.profile_id, profile.id, data.id })
|
||||
notify_profile(owner_id, profile.id, "like", data.id)
|
||||
end
|
||||
else
|
||||
@@ -576,8 +595,13 @@ end)
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:follow", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:follow", 30, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local target_id = type(data) == "table" and tonumber(data.profileId) or nil
|
||||
if not target_id or target_id == profile.id or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end
|
||||
local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ? LIMIT 1", { target_id })
|
||||
if not targets[1] then return { success = false, error = "profile_not_found" } end
|
||||
if are_profiles_blocked(profile.id, target_id) then return { success = false, error = "blocked" } end
|
||||
if data.active then
|
||||
local inserted = Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_follows` (`follower_id`, `following_id`) SELECT ?, `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ?", { profile.id, target_id })
|
||||
@@ -594,7 +618,9 @@ end)
|
||||
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
|
||||
if type(data) ~= "table" or not load_accessible_video(data.id, profile.id) then
|
||||
return { success = false, error = "video_not_found" }
|
||||
end
|
||||
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`,
|
||||
@@ -630,25 +656,24 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comment", function(source, data)
|
||||
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
|
||||
local video = load_accessible_video(data.id, profile.id)
|
||||
if not video then return { success = false, error = "video_not_found" } end
|
||||
if tonumber(video.comments_enabled) ~= 1 then return { success = false, error = "comments_disabled" } end
|
||||
local owner_id = tonumber(video.profile_id)
|
||||
if parent_id then
|
||||
local parents = Bridge.Database.Query([[SELECT c.`id`, c.`profile_id` FROM `sky_phone_fliptok_comments` c
|
||||
local parents = Bridge.Database.Query([[SELECT c.`id`, c.`parent_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
|
||||
parent_id = parents[1].parent_id or parents[1].id
|
||||
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 })
|
||||
if owner_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(), video.profile_id, profile.id, data.id })
|
||||
notify_profile(owner_id, profile.id, "comment", data.id)
|
||||
end
|
||||
return { success = true, data = { id = comment_id } }
|
||||
@@ -664,10 +689,13 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comment-react", function(source, da
|
||||
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
|
||||
local comments = Bridge.Database.Query([[SELECT c.`profile_id`, c.`video_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 not load_accessible_video(comments[1].video_id, profile.id) then
|
||||
return { success = false, error = "video_not_found" }
|
||||
end
|
||||
if are_profiles_blocked(profile.id, tonumber(comments[1].profile_id)) then
|
||||
return { success = false, error = "blocked" }
|
||||
end
|
||||
@@ -682,19 +710,23 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comment-react", function(source, da
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:view", function(source, data)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:view", 120, 60) then return { success = false, error = "rate_limited" } end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end
|
||||
if type(data) ~= "table" or not load_accessible_video(data.id, profile.id) then
|
||||
return { success = false, error = "video_not_found" }
|
||||
end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `view_count` = `view_count` + 1 WHERE `id` = ? AND `status` = 'published'", { data.id })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:share", function(source, data)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:share", 30, 60) then return { success = false, error = "rate_limited" } end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end
|
||||
if type(data) ~= "table" or not load_accessible_video(data.id, profile.id) then
|
||||
return { success = false, error = "video_not_found" }
|
||||
end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `share_count` = `share_count` + 1 WHERE `id` = ? AND `status` = 'published'", { data.id })
|
||||
return { success = true }
|
||||
end)
|
||||
@@ -711,7 +743,10 @@ Bridge.Callbacks.Register("sky_phone:fliptok:profile", function(source, data)
|
||||
end
|
||||
local target = load_profile(tonumber(rows[1].id), viewer.id)
|
||||
if not target then return { success = false, error = "profile_not_found" } end
|
||||
local videos = list_videos(viewer.id, "v.`profile_id` = ? AND (v.`visibility` = 'public' OR v.`profile_id` = ?)", { target.id, viewer.id, viewer.id, viewer.id }, 60, 0, "v.`created_at` DESC")
|
||||
local videos = list_videos(viewer.id, [[v.`profile_id` = ? AND (v.`visibility` = 'public' OR v.`profile_id` = ? OR
|
||||
(v.`visibility` = 'followers' AND EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` profile_follow
|
||||
WHERE profile_follow.`follower_id` = ? AND profile_follow.`following_id` = v.`profile_id`)))]],
|
||||
{ target.id, viewer.id, viewer.id, viewer.id, viewer.id }, 60, 0, "v.`created_at` DESC")
|
||||
return { success = true, data = { profile = target, videos = videos } }
|
||||
end)
|
||||
|
||||
@@ -856,12 +891,17 @@ end)
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:block", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:block", 20, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local target_id = type(data) == "table" and tonumber(data.profileId) or nil
|
||||
if not target_id or target_id == profile.id then return { success = false, error = "invalid_request" } end
|
||||
Bridge.Database.Transaction({
|
||||
local targets = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ? LIMIT 1", { target_id })
|
||||
if not targets[1] then return { success = false, error = "profile_not_found" } end
|
||||
if not Bridge.Database.Transaction({
|
||||
{ query = "INSERT IGNORE INTO `sky_phone_fliptok_blocks` (`blocker_id`, `blocked_id`) VALUES (?, ?)", params = { profile.id, target_id } },
|
||||
{ query = "DELETE FROM `sky_phone_fliptok_follows` WHERE (`follower_id` = ? AND `following_id` = ?) OR (`follower_id` = ? AND `following_id` = ?)", params = { profile.id, target_id, target_id, profile.id } },
|
||||
})
|
||||
}) then return { success = false, error = "request_failed" } end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user