ENH - modernize FlipTok and Messages

Replace the mixed legacy Konsta presentation and fixed light surfaces with the shared Sky UI system. Add FlipTok comment replies and reactions, editable profile media, follower lists, external sound metadata, and the required schema migration.
This commit is contained in:
Leon.Schmidt
2026-08-14 01:28:48 +02:00
parent e13fa6ca51
commit 332060b8e3
22 changed files with 4981 additions and 1513 deletions
+42
View File
@@ -16,6 +16,8 @@ vi.mock('@/utils/nui', () => ({
const profile: FlipTokProfile = {
account_type: 'person',
avatar_media_id: null,
avatar_url: null,
bio: '',
display_name: 'Nova',
followers: 1,
@@ -29,6 +31,7 @@ const profile: FlipTokProfile = {
}
const video: FlipTokVideo = {
avatar_url: null,
caption: 'Los Santos',
comment_count: 1,
comments_enabled: true,
@@ -44,9 +47,11 @@ const video: FlipTokVideo = {
location: '',
cover_time_ms: 0,
music_artist: '',
music_source: '',
music_title: '',
music_track: '',
music_url: '',
music_video_id: '',
music_volume: 0,
original_volume: 100,
profile_id: 7,
@@ -59,16 +64,22 @@ const video: FlipTokVideo = {
}
const comment: FlipTokComment = {
avatar_url: null,
body: 'Nice',
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'comment-1',
is_liked: false,
like_count: 0,
parent_id: null,
profile_id: 7,
reply_to_handle: null,
verified: false,
}
const activity: FlipTokActivity = {
avatar_url: null,
created_at: 1,
display_name: 'Nova',
handle: 'nova',
@@ -191,4 +202,35 @@ describe('FlipTok verification updates', () => {
expect(await store.loadVideo(video.id)).toEqual(video)
expect(nuiCall).toHaveBeenCalledWith('fliptok:video', { id: video.id })
})
it('likes comments optimistically and restores them on failure', async () => {
vi.mocked(nuiCall).mockResolvedValue({ success: false })
const store = useFlipTokStore()
const visibleComment = { ...comment }
await store.reactComment(visibleComment)
expect(nuiCall).toHaveBeenCalledWith('fliptok:comment-react', {
active: true,
id: visibleComment.id,
})
expect(visibleComment.is_liked).toBe(false)
expect(visibleComment.like_count).toBe(0)
})
it('loads the selected follower list', async () => {
const follower = { ...profile, id: 8, is_owner: false }
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: [follower],
})
const store = useFlipTokStore()
expect(await store.loadConnections(profile.id, 'followers')).toBe(true)
expect(store.connections).toEqual([follower])
expect(nuiCall).toHaveBeenCalledWith('fliptok:connections', {
mode: 'followers',
profileId: profile.id,
})
})
})
+30 -2
View File
@@ -17,6 +17,7 @@ export const useFlipTokStore = defineStore('fliptok', {
activities: [] as FlipTokActivity[],
authenticated: false,
comments: [] as FlipTokComment[],
connections: [] as FlipTokProfile[],
feed: [] as FlipTokVideo[],
isAdmin: false,
loading: false,
@@ -211,8 +212,35 @@ export const useFlipTokStore = defineStore('fliptok', {
})
this.comments = response.success && response.data ? response.data : []
},
async comment(id: string, body: string): Promise<NuiResponse> {
return nuiCall('fliptok:comment', { body, id })
async comment(
id: string,
body: string,
parentId?: string,
): Promise<NuiResponse> {
return nuiCall('fliptok:comment', { body, id, parentId })
},
async reactComment(comment: FlipTokComment): Promise<void> {
const active = !comment.is_liked
comment.is_liked = active
comment.like_count += active ? 1 : -1
const response = await nuiCall('fliptok:comment-react', {
active,
id: comment.id,
})
if (response.success) return
comment.is_liked = !active
comment.like_count += active ? -1 : 1
},
async loadConnections(
profileId: number,
mode: 'followers' | 'following',
): Promise<boolean> {
const response = await nuiCall<FlipTokProfile[]>('fliptok:connections', {
mode,
profileId,
})
this.connections = response.success && response.data ? response.data : []
return response.success
},
async loadActivities(): Promise<void> {
const response = await nuiCall<FlipTokActivity[]>('fliptok:activities')
+3 -3
View File
@@ -8,7 +8,7 @@ import type {
} from '@/types/music'
import { nuiCall } from '@/utils/nui'
type YouTubePlayer = {
export type YouTubePlayer = {
destroy: () => void
getCurrentTime: () => number
getDuration: () => number
@@ -19,7 +19,7 @@ type YouTubePlayer = {
setVolume: (volume: number) => void
}
type YouTubeApi = {
export type YouTubeApi = {
Player: new (
target: HTMLElement,
options: {
@@ -199,7 +199,7 @@ function bindAudioEvents(): void {
})
}
function loadYouTubeApi(): Promise<YouTubeApi> {
export function loadYouTubeApi(): Promise<YouTubeApi> {
if (window.YT?.Player) return Promise.resolve(window.YT)
if (youtubeApiPromise) return youtubeApiPromise
youtubeApiPromise = new Promise<YouTubeApi>((resolve, reject) => {
+35
View File
@@ -1344,18 +1344,24 @@ const defaultLocales: LocaleTree = {
create: 'Create',
activity: 'Activity',
profile: 'Profile',
navigation: 'FlipTok navigation',
emptyFeed: 'No videos yet',
emptyFeedBody: 'Follow creators or post the first FlipTok.',
searchPlaceholder: 'Search creators and videos',
clearSearch: 'Clear search',
noActivity: 'No activity yet',
followers: 'Followers',
videos: 'Videos',
emptyBio: 'No bio yet.',
editProfile: 'Edit profile',
newVideo: 'New FlipTok',
createTitle: 'Create your next FlipTok',
createBody:
'Choose a clip, set the cover and sound, then decide who can watch it.',
chooseVideo: 'Choose a video',
chooseVideoHint: 'Select one from Gallery',
changeVideo: 'Change',
caption: 'Caption',
captionPlaceholder: 'Write a caption...',
location: 'Add location',
whoCanWatch: 'Who can watch',
@@ -1374,6 +1380,10 @@ const defaultLocales: LocaleTree = {
comments: 'Comments',
noComments: 'No comments yet',
addComment: 'Add comment...',
reply: 'Reply',
replyPlaceholder: 'Write a reply...',
replyingTo: 'Replying to {handle}',
cancelReply: 'Cancel reply',
report: 'Report video',
reportReason: 'Reason',
reportDetails: 'Additional details (optional)',
@@ -1393,6 +1403,19 @@ const defaultLocales: LocaleTree = {
chooseSound: 'Choose music',
originalOnly: 'Original sound only',
noMusic: 'No music tracks are configured.',
customSound: 'Custom sound',
customSoundLink: 'YouTube or audio link',
customSoundHint:
'Paste a YouTube link or a direct HTTPS audio-file link.',
customSoundPlaceholder:
'https://youtu.be/... or https://example.com/song.mp3',
customSoundFormats:
'YouTube, YouTube Music, Shorts, MP3, M4A, AAC, OGG, OPUS, WAV, or WEBM',
useCustomSound: 'Use sound',
loadingSound: 'Loading sound',
invalidCustomSoundLink:
'Use a YouTube link or a direct public HTTPS audio-file link.',
customSoundLoadFailed: 'This sound could not be loaded.',
trimAndCover: 'Trim & cover',
trimStart: 'Start',
trimEnd: 'End',
@@ -1410,6 +1433,12 @@ const defaultLocales: LocaleTree = {
username: 'Username',
bio: 'Bio',
accountType: 'Account type',
profilePhoto: 'Profile photo',
changePhoto: 'Change photo',
chooseFromGallery: 'Gallery',
takePhoto: 'Camera',
removePhoto: 'Remove photo',
noConnections: 'No profiles to show',
authTitle: 'Your FlipTok account',
login: 'Sign In',
register: 'Register',
@@ -1456,10 +1485,13 @@ const defaultLocales: LocaleTree = {
},
errors: {
invalid_video: 'Check the video details.',
invalid_music_url:
'Use a valid YouTube or direct HTTPS audio-file link.',
invalid_media: 'Choose a video from this phone.',
invalid_comment: 'Enter a valid comment.',
comments_disabled: 'Comments are disabled.',
invalid_profile: 'Check your profile details.',
invalid_profile_image: 'Choose a valid photo from this phone.',
invalid_handle: 'Use 324 letters, numbers, dots, or underscores.',
invalid_display_name: 'Enter a display name.',
invalid_password: 'Password must be 872 characters.',
@@ -1633,6 +1665,9 @@ const defaultLocales: LocaleTree = {
officialContact: 'Official company contact',
messagingUnavailable: 'This company contact does not accept messages.',
filterUnread: 'Show Unread Messages',
filterLabel: 'Conversation Filter',
allMessages: 'All',
unreadMessages: 'Unread',
smsLabel: 'Text Message · SMS',
photo: 'Photo',
gif: 'GIF',