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:43 +02:00
parent e13fa6ca51
commit 332060b8e3
22 changed files with 4981 additions and 1513 deletions
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./EasyShareSheet.vue', import.meta.url),
'utf8',
)
describe('EasyShareSheet Sky UI contract', () => {
it('uses the first-party bottom sheet without Konsta markup', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).not.toMatch(/<\/?k-[a-z]/)
expect(source).toContain('<SkySheet')
expect(source).toContain('<SkyList')
expect(source).toContain('<SkyGlass')
expect(source).toContain('.easyshare-host :deep(.sky-sheet__panel)')
expect(source).toMatch(/--easyshare-solid-surface:\s*#1c1c1d/)
expect(source).toMatch(
/background:\s*var\(--easyshare-solid-surface\)\s*!important/,
)
expect(source).toMatch(/--easyshare-list-surface:\s*#2c2c2e/)
expect(source).toMatch(
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
)
})
})
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./MessageContactBubble.vue', import.meta.url),
'utf8',
)
describe('MessageContactBubble Sky UI contract', () => {
it('uses Sky buttons without direct Konsta markup', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).not.toMatch(/<\/?k-[a-z]/)
expect(source).toContain('<SkyButton')
})
it('inherits light and dark colors from Sky theme tokens', () => {
expect(source).toContain('var(--sky-text)')
expect(source).toContain('var(--sky-surface)')
expect(source).toContain('var(--sky-surface-muted)')
expect(source).toContain('var(--sky-muted)')
expect(source).toContain('var(--sky-hairline)')
expect(source).not.toContain(':global(.phone-app.dark)')
})
})
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { kButton } from 'konsta/vue'
import {
Check,
ChevronRight,
@@ -10,6 +9,7 @@ import {
import { computed, ref, watch } from 'vue'
import type { SmsSharedContact } from '@/types/messages'
import { SkyButton } from '@/ui'
const props = defineProps<{
addLabel: string
@@ -61,12 +61,13 @@ const initials = computed(() =>
<ChevronRight :size="20" class="message-contact-card__chevron" />
</div>
<div class="message-contact-card__actions">
<k-button rounded tonal @click.stop="emit('message')">
<SkyButton rounded small tonal @click.stop="emit('message')">
<MessageCircle :size="17" />
{{ messageLabel }}
</k-button>
<k-button
</SkyButton>
<SkyButton
rounded
small
tonal
:disabled="saved"
@click.stop="emit('save')"
@@ -74,7 +75,7 @@ const initials = computed(() =>
<Check v-if="saved" :size="17" />
<UserPlus v-else :size="17" />
{{ saved ? savedLabel : addLabel }}
</k-button>
</SkyButton>
</div>
</div>
</template>
@@ -84,11 +85,15 @@ const initials = computed(() =>
width: min(258px, 100%);
width: min(258px, 72cqw);
overflow: hidden;
border: 1px solid rgb(255 255 255 / 48%);
border: 1px solid var(--sky-hairline);
border-radius: 21px;
color: #151517;
background: linear-gradient(155deg, rgb(255 255 255 / 96%), #edf5ff);
box-shadow: 0 8px 24px rgb(22 63 112 / 14%);
color: var(--sky-text);
background: linear-gradient(
155deg,
var(--sky-surface-tint),
var(--sky-surface)
);
box-shadow: 0 8px 24px rgb(0 0 0 / 16%);
}
.message-contact-card__identity {
@@ -107,9 +112,9 @@ const initials = computed(() =>
border-radius: 50%;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, #64d2ff, #0a84ff);
box-shadow: 0 4px 14px rgb(10 132 255 / 24%);
color: #ffffff;
background: linear-gradient(145deg, var(--sky-success), #248a3d);
box-shadow: 0 4px 14px rgb(52 199 89 / 24%);
}
.message-contact-card__avatar img {
@@ -143,23 +148,23 @@ const initials = computed(() =>
.message-contact-card__identity small {
margin-top: 3px;
color: #6e6e73;
color: var(--sky-muted);
font-size: 11.5px;
}
.message-contact-card__chevron {
color: #8e8e93;
color: var(--sky-muted);
}
.message-contact-card__actions {
display: grid;
gap: 1px;
padding: 8px;
border-top: 1px solid rgb(60 60 67 / 12%);
background: rgb(255 255 255 / 58%);
border-top: 1px solid var(--sky-hairline);
background: var(--sky-surface-muted);
}
.message-contact-card__actions :deep(.button) {
.message-contact-card__actions :deep(.sky-button) {
width: 100%;
margin: 0;
min-height: 36px;
@@ -167,20 +172,4 @@ const initials = computed(() =>
padding-inline: 14px;
font-size: 13px;
}
:global(.phone-app.dark) .message-contact-card {
color: #f7f7f7;
border-color: rgb(255 255 255 / 10%);
background: linear-gradient(155deg, #34363b, #242a33);
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
}
:global(.phone-app.dark) .message-contact-card__identity small {
color: #aeaeb2;
}
:global(.phone-app.dark) .message-contact-card__actions {
border-top-color: rgb(255 255 255 / 10%);
background: rgb(0 0 0 / 10%);
}
</style>
+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',
+11
View File
@@ -1,5 +1,7 @@
export type FlipTokProfile = {
account_type: 'person' | 'business' | 'organization' | 'media' | 'event'
avatar_media_id: number | null
avatar_url: string | null
bio: string
display_name: string
followers: number
@@ -13,6 +15,7 @@ export type FlipTokProfile = {
}
export type FlipTokVideo = {
avatar_url: string | null
caption: string
comment_count: number
comments_enabled: boolean
@@ -28,9 +31,11 @@ export type FlipTokVideo = {
location: string
cover_time_ms: number
music_artist: string
music_source: '' | 'audio' | 'youtube'
music_title: string
music_track: string
music_url: string
music_video_id: string
music_volume: number
original_volume: number
profile_id: number
@@ -69,16 +74,22 @@ export type FlipTokProfilePage = {
}
export type FlipTokComment = {
avatar_url: string | null
body: string
created_at: number
display_name: string
handle: string
id: string
is_liked: boolean
like_count: number
parent_id: string | null
profile_id: number
reply_to_handle: string | null
verified: boolean
}
export type FlipTokActivity = {
avatar_url: string | null
created_at: number
display_name: string
handle: string
@@ -0,0 +1,119 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./FlipTokApp.vue', import.meta.url),
'utf8',
)
const serverSource = readFileSync(
new URL('../../../../sky_phone/source/server/fliptok.lua', import.meta.url),
'utf8',
)
const migrationSource = readFileSync(
new URL(
'../../../../sky_phone/source/server/db_migrate.lua',
import.meta.url,
),
'utf8',
)
const youtubeSource = readFileSync(
new URL('../../../../sky_phone/source/server/media_metadata.lua', import.meta.url),
'utf8',
)
describe('FlipTokApp Sky UI contract', () => {
it('uses first-party Sky UI without direct Konsta markup', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).not.toMatch(/<\/?k-[a-z]/)
expect(source).toContain('<SkyAppPage')
expect(source).toContain('<SkyNavbar')
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('<SkySearchbar')
expect(source).toContain('<SkyPillNavigation')
expect(source).toContain('<SkyMessagebar')
})
it('uses the SkyUI navigation pill and native navbar back action', () => {
expect(source).toMatch(
/<SkySegmented[\s\S]*?:item-count="5"[\s\S]*?navigation/,
)
expect(source).toContain(':show-back="Boolean(store.viewedProfile)"')
expect(source).toContain('back-appearance="surface"')
expect(source).toMatch(/\.fliptok-navbar\s*\{[^}]*transform:\s*none/s)
})
it('shows timestamps and supports replies and likes on comments', () => {
expect(source).not.toContain('formatTimestamp(video.created_at)')
expect(source).toContain('formatTimestamp(thread.comment.created_at)')
expect(source).toContain('@click="startReply(thread.comment)"')
expect(source).toContain('@click="reactCommentWithPulse(thread.comment)"')
expect(source).toContain('comment-like-pulse')
expect(source).toContain('@keyframes comment-heart-pop')
expect(source).toContain('class="comment-row comment-row--reply"')
expect(source).toContain('form.comments-composer')
})
it('keeps moderation and profile actions compact and inside Sky UI', () => {
expect(source).toContain('class="moderation-button"')
expect(source).toContain('<ShieldCheck />')
expect(source).toContain('.moderation-button__surface')
expect(source).toMatch(/\.profile-actions\s*\{[^}]*max-width:\s*286px/s)
})
it('exposes follower lists and editable profile photos', () => {
expect(source).toContain("openConnections('following')")
expect(source).toContain("openConnections('followers')")
expect(source).toContain("openProfileMedia('photos')")
expect(source).toContain("openProfileMedia('camera')")
expect(source).toContain('@click="removeProfilePhoto"')
expect(source).toContain('profilePhotoSheetOpen')
expect(source).toContain('avatarMediaId')
})
it('shows a transient follow confirmation', () => {
expect(source).toContain('followFeedbackIds')
expect(source).toContain('follow-dot--confirmed')
expect(source).toContain('@click="followFromFeed(video)"')
})
it('supports validated custom audio links in the composer', () => {
expect(source).toContain('function validCustomMusicUrl')
expect(source).toContain('customMusicDraftUrl')
expect(source).toContain('customMusicLoadFailed')
expect(source).toContain('type="url"')
expect(source).toContain('@click="chooseCustomMusic"')
expect(source).toContain(
"customMusicUrl: musicTrack.value ? '' : customMusicUrl.value",
)
expect(source).toContain('v-if="composerMusicUrl"')
expect(source).toContain('function parseYoutubeVideoId')
expect(source).toContain("'fliptok:music-metadata'")
expect(source).toContain('playFlipTokYoutube')
})
it('validates and persists custom audio links on the server', () => {
expect(serverSource).toContain('local function valid_custom_music_url')
expect(serverSource).toContain('error = "invalid_music_url"')
expect(serverSource).toContain('v.`custom_music_url`')
expect(serverSource).toContain('video.custom_music_url = nil')
expect(migrationSource).toContain('name = "custom_music_url"')
expect(migrationSource).toContain('name = "custom_music_title"')
expect(migrationSource).toContain('name = "custom_music_artist"')
expect(migrationSource).toContain('collation = "ascii_bin"')
expect(youtubeSource).toContain('function SkyPhoneYouTube.ParseId')
expect(youtubeSource).toContain('function SkyPhoneYouTube.FetchMetadata')
expect(youtubeSource).toContain('www.youtube.com/oembed')
})
it('keeps report and profile editing surfaces rounded and safe', () => {
expect(source).toContain('class="report-sheet__actions"')
expect(source).toContain('class="profile-account-list"')
expect(source).toMatch(
/\.report-sheet\s*\{[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\)/s,
)
expect(source).toMatch(
/\.profile-form-list,[\s\S]*?\.profile-account-list\s*\{[^}]*border-radius:\s*var\(--sky-radius-card\)/,
)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./MessagesApp.vue', import.meta.url),
'utf8',
)
describe('MessagesApp Sky UI contract', () => {
it('uses first-party Sky UI without direct Konsta markup', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).not.toMatch(/<\/?k-[a-z]/)
expect(source).toContain('<SkyAppPage')
expect(source).toContain('<SkyNavbar')
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('<SkySearchbar')
expect(source).toContain('<SkySegmented')
expect(source).toContain('<SkyMessages')
expect(source).toContain('<SkyMessagebar')
expect(source).toContain('<SkyPillNavigation')
})
it('keeps inbox search focused on text and exposes clear filters', () => {
const inboxStart = source.indexOf('messages-sky-inbox')
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
expect(inbox).toContain('<SkySearchbar')
expect(inbox).not.toContain('messages-inbox-search__voice')
expect(inbox).toContain('Apps.messages.allMessages')
expect(inbox).toContain('Apps.messages.unreadMessages')
})
it('uses one compact scroll region and an in-flow composer in threads', () => {
expect(source).toContain('class="messages-sky-thread-scroll"')
expect(source).toContain('class="messages-bubbles"')
expect(source).toContain('class="messages-sky-composer-pill"')
expect(source).toContain('class="messages-sky-messagebar"')
expect(source).toMatch(
/\.messages-bubbles\s*\{[^}]*min-height:\s*100%[^}]*justify-content:\s*flex-end/s,
)
expect(source).toMatch(
/\.messages-sky-composer-shell\s*\{[^}]*flex:\s*none/s,
)
expect(source).toMatch(
/\.messages-sky-composer-pill\s*\{[^}]*border-radius:\s*var\(--sky-radius-pill\)/s,
)
})
it('provides a full-size back target and a compact recipient row', () => {
expect(source).toContain('back-appearance="plain"')
expect(source).toContain('class="messages-recipient-field"')
expect(source).toContain('layout="inline"')
expect(source).toMatch(
/\.messages-recipient-field :deep\(\.sky-field\)\s*\{[^}]*min-height:\s*52px/s,
)
})
it('keeps the SMS compose action in the inbox navbar', () => {
const inboxStart = source.indexOf('messages-sky-inbox')
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
expect(inbox).toContain('@click="beginCompose"')
expect(inbox).toContain('<SquarePen')
expect(inbox).not.toContain('messages-sky-compose-navigation')
})
it('keeps SMS conversations and recipients in flat iMessage-style lists', () => {
expect(source).toMatch(
/v-if="filteredConversations.length"\s+flush\s+class="messages-sky-conversation-list"/,
)
expect(source).toMatch(
/class="messages-recipient-field"\s+density="compact"\s+flush/,
)
expect(source).toMatch(
/v-if="contactSuggestions.length"\s+class="messages-contact-list"\s+flush/,
)
})
})
File diff suppressed because it is too large Load Diff
+207 -12
View File
@@ -413,6 +413,8 @@ function crewLinkBootstrap(testScenario = '') {
}
const flipTokProfile = {
id: 1,
avatar_media_id: null,
avatar_url: null,
handle: 'skyline',
display_name: 'Skyline',
bio: 'Life around Los Santos.',
@@ -433,8 +435,97 @@ const flipTokMusicTracks = [
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
},
]
const flipTokMusicExtensions = new Set([
'aac',
'm4a',
'mp3',
'oga',
'ogg',
'opus',
'wav',
'webm',
])
function validFlipTokMusicUrl(value) {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > 2048 ||
/[\s\u0000-\u001f\u007f]/.test(value)
) {
return false
}
try {
const url = new URL(value)
const host = url.hostname.toLowerCase()
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.port ||
!host.includes('.') ||
host === 'localhost' ||
host.endsWith('.local') ||
host.endsWith('.internal') ||
/^\d+\.\d+\.\d+\.\d+$/.test(host) ||
!/^[a-z0-9.-]+$/.test(host) ||
host
.split('.')
.some(
(label) =>
!label ||
label.length > 63 ||
label.startsWith('-') ||
label.endsWith('-'),
)
) {
return false
}
const extension = url.pathname.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase()
return Boolean(extension && flipTokMusicExtensions.has(extension))
} catch {
return false
}
}
function parseFlipTokYoutubeId(value) {
if (typeof value !== 'string' || value.length > 500) return ''
try {
const url = new URL(value)
if (url.protocol !== 'https:' || url.username || url.password || url.port) {
return ''
}
const host = url.hostname.toLowerCase()
let videoId = ''
if (host === 'youtu.be' || host === 'www.youtu.be') {
videoId = url.pathname.split('/')[1] || ''
} else if (
[
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'music.youtube.com',
'youtube-nocookie.com',
'www.youtube-nocookie.com',
].includes(host)
) {
videoId =
url.searchParams.get('v') ||
url.pathname.match(/^\/(?:shorts|embed|live)\/([a-z0-9_-]+)/i)?.[1] ||
''
}
return /^[a-z0-9_-]{11}$/i.test(videoId) ? videoId : ''
} catch {
return ''
}
}
function mockYoutubeMetadata(videoId) {
if (videoId === 'dQw4w9WgXcQ') {
return { artist: 'Rick Astley', title: 'Never Gonna Give You Up' }
}
return { artist: 'YouTube', title: `YouTube ${videoId}` }
}
let flipTokVideos = [
{
avatar_url: null,
id: 'fliptok-1',
profile_id: 2,
handle: 'novals',
@@ -450,7 +541,9 @@ let flipTokVideos = [
music_track: '',
music_title: '',
music_artist: '',
music_source: '',
music_url: '',
music_video_id: '',
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
comments_enabled: true,
is_liked: false,
@@ -464,6 +557,7 @@ let flipTokVideos = [
created_at: Date.now() - 3600000,
},
{
avatar_url: null,
id: 'fliptok-2',
profile_id: 1,
handle: 'skyline',
@@ -479,7 +573,9 @@ let flipTokVideos = [
music_track: 'night-drive',
music_title: 'Night Drive',
music_artist: 'Los Santos Radio',
music_source: 'audio',
music_url: flipTokMusicTracks[0].url,
music_video_id: '',
url: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
comments_enabled: true,
is_liked: true,
@@ -495,8 +591,13 @@ let flipTokVideos = [
]
let flipTokComments = [
{
avatar_url: null,
id: 'comment-1',
is_liked: false,
like_count: 7,
parent_id: null,
profile_id: 2,
reply_to_handle: null,
handle: 'nova',
display_name: 'Nova',
verified: true,
@@ -506,6 +607,7 @@ let flipTokComments = [
]
let flipTokActivities = [
{
avatar_url: null,
id: 'activity-1',
profile_id: 2,
handle: 'nova',
@@ -6433,15 +6535,33 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'fliptok:comment') {
flipTokComments.unshift({
const comment = {
avatar_url: flipTokProfile.avatar_url,
id: `comment-${Date.now()}`,
is_liked: false,
like_count: 0,
parent_id: request.body.parentId || null,
profile_id: 1,
reply_to_handle: request.body.parentId
? flipTokComments.find((item) => item.id === request.body.parentId)
?.handle || null
: null,
handle: flipTokProfile.handle,
display_name: flipTokProfile.display_name,
verified: flipTokProfile.verified,
body: request.body.body,
created_at: Date.now(),
})
}
flipTokComments.push(comment)
response.json({ success: true, data: { id: comment.id } })
return
}
if (endpoint === 'fliptok:comment-react') {
const comment = flipTokComments.find((item) => item.id === request.body.id)
if (comment) {
comment.is_liked = request.body.active === true
comment.like_count += comment.is_liked ? 1 : -1
}
response.json({ success: true })
return
}
@@ -6516,6 +6636,8 @@ app.post('/api/:endpoint', (request, response) => {
display_name: first.display_name,
bio: 'Creator in Los Santos.',
account_type: 'person',
avatar_media_id: null,
avatar_url: first.avatar_url ?? null,
verified: first.verified,
is_following: first.is_following,
is_owner: false,
@@ -6528,6 +6650,38 @@ app.post('/api/:endpoint', (request, response) => {
})
return
}
if (endpoint === 'fliptok:connections') {
const creatorVideo = flipTokVideos.find((video) => !video.is_owner)
const creator = creatorVideo
? {
account_type: 'person',
avatar_media_id: null,
avatar_url: creatorVideo.avatar_url ?? null,
bio: 'Creator in Los Santos.',
display_name: creatorVideo.display_name,
followers: 12840,
following: 91,
handle: creatorVideo.handle,
id: creatorVideo.profile_id,
is_following: creatorVideo.is_following,
is_owner: false,
verified: creatorVideo.verified,
video_count: 1,
}
: null
response.json({
success: true,
data:
request.body.mode === 'followers'
? creator
? [creator]
: []
: creator
? [creator, { ...flipTokProfile }]
: [{ ...flipTokProfile }],
})
return
}
if (endpoint === 'fliptok:block') {
const profileId = Number(request.body.profileId)
flipTokVideos = flipTokVideos.filter(
@@ -6568,10 +6722,30 @@ app.post('/api/:endpoint', (request, response) => {
display_name: request.body.displayName,
bio: request.body.bio,
account_type: request.body.accountType,
avatar_media_id: request.body.avatarMediaId || null,
avatar_url:
mockMedia.find((item) => item.id === request.body.avatarMediaId)?.url ||
null,
})
response.json({ success: true, data: flipTokProfile })
return
}
if (endpoint === 'fliptok:music-metadata') {
const videoId = parseFlipTokYoutubeId(String(request.body.url || '').trim())
if (!videoId) {
response.json({ success: false, error: 'invalid_music_url' })
return
}
response.json({
success: true,
data: {
...mockYoutubeMetadata(videoId),
url: `https://www.youtube.com/watch?v=${videoId}`,
videoId,
},
})
return
}
if (endpoint === 'fliptok:publish') {
const media = mockMedia.find(
(item) => item.id === request.body.mediaId && item.mediaType === 'video',
@@ -6580,7 +6754,26 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: false, error: 'invalid_media' })
return
}
const musicTrack = String(request.body.musicTrack || '')
const configuredTrack = flipTokMusicTracks.find(
(track) => track.id === musicTrack,
)
const customMusicUrl = String(request.body.customMusicUrl || '').trim()
const youtubeVideoId = parseFlipTokYoutubeId(customMusicUrl)
const youtubeMetadata = youtubeVideoId
? mockYoutubeMetadata(youtubeVideoId)
: null
if (
(musicTrack && !configuredTrack) ||
(customMusicUrl &&
(configuredTrack ||
(!youtubeVideoId && !validFlipTokMusicUrl(customMusicUrl))))
) {
response.json({ success: false, error: 'invalid_music_url' })
return
}
flipTokVideos.unshift({
avatar_url: flipTokProfile.avatar_url,
id: `fliptok-${Date.now()}`,
profile_id: 1,
handle: flipTokProfile.handle,
@@ -6593,16 +6786,18 @@ app.post('/api/:endpoint', (request, response) => {
cover_time_ms: request.body.coverTimeMs || 0,
original_volume: request.body.originalVolume ?? 100,
music_volume: request.body.musicVolume || 0,
music_track: request.body.musicTrack || '',
music_title:
flipTokMusicTracks.find((track) => track.id === request.body.musicTrack)
?.title || '',
music_artist:
flipTokMusicTracks.find((track) => track.id === request.body.musicTrack)
?.artist || '',
music_url:
flipTokMusicTracks.find((track) => track.id === request.body.musicTrack)
?.url || '',
music_track: configuredTrack?.id || '',
music_title: configuredTrack?.title || youtubeMetadata?.title || '',
music_artist: configuredTrack?.artist || youtubeMetadata?.artist || '',
music_source: configuredTrack
? 'audio'
: youtubeVideoId
? 'youtube'
: customMusicUrl
? 'audio'
: '',
music_url: youtubeVideoId ? '' : configuredTrack?.url || customMusicUrl,
music_video_id: youtubeVideoId,
url: media.url,
comments_enabled: request.body.commentsEnabled,
is_liked: false,
+10 -8
View File
@@ -452,21 +452,23 @@ Locales["en"] = {
},
fliptok = {
name = "FlipTok", loading = "Loading FlipTok", following = "Following", forYou = "For You", verified = "Verified account",
originalSound = "original sound", save = "Save", home = "Home", discover = "Discover", create = "Create", activity = "Activity", profile = "Profile",
emptyFeed = "No videos yet", emptyFeedBody = "Follow creators or post the first FlipTok.", searchPlaceholder = "Search creators and videos",
originalSound = "original sound", save = "Save", home = "Home", discover = "Discover", create = "Create", activity = "Activity", profile = "Profile", navigation = "FlipTok navigation",
emptyFeed = "No videos yet", emptyFeedBody = "Follow creators or post the first FlipTok.", searchPlaceholder = "Search creators and videos", clearSearch = "Clear search",
noActivity = "No activity yet", followers = "Followers", videos = "Videos", emptyBio = "No bio yet.", editProfile = "Edit profile",
newVideo = "New FlipTok", chooseVideo = "Choose a video", chooseVideoHint = "Select one from Gallery", changeVideo = "Change",
captionPlaceholder = "Write a caption...", location = "Add location", whoCanWatch = "Who can watch", public = "Everyone",
newVideo = "New FlipTok", createTitle = "Create your next FlipTok", createBody = "Choose a clip, set the cover and sound, then decide who can watch it.", chooseVideo = "Choose a video", chooseVideoHint = "Select one from Gallery", changeVideo = "Change",
caption = "Caption", captionPlaceholder = "Write a caption...", location = "Add location", whoCanWatch = "Who can watch", public = "Everyone",
followersOnly = "Followers", private = "Only me", allowComments = "Allow comments", saveDraft = "Drafts", publishing = "Posting...", post = "Post",
draftSaved = "Draft saved.", published = "Your FlipTok is live.", linkCopied = "Video link copied.", reported = "Report submitted.", blocked = "Creator blocked.",
comments = "Comments", noComments = "No comments yet", addComment = "Add comment...", report = "Report video", reportReason = "Reason",
comments = "Comments", noComments = "No comments yet", addComment = "Add comment...", reply = "Reply", replyPlaceholder = "Write a reply...", replyingTo = "Replying to {handle}", cancelReply = "Cancel reply", report = "Report video", reportReason = "Reason",
reportDetails = "Additional details (optional)", submitReport = "Submit report",
reportReasons = { spam = "Spam or misleading", harassment = "Harassment or bullying", dangerous = "Dangerous activity", illegal = "Illegal content", other = "Something else" },
block = "Block creator", follow = "Follow", unfollow = "Following", backToProfile = "Back", cancel = "Cancel",
sounds = "Sound", chooseSound = "Choose music", originalOnly = "Original sound only", noMusic = "No music tracks are configured.",
customSound = "Custom sound", customSoundLink = "YouTube or audio link", customSoundHint = "Paste a YouTube link or a direct HTTPS audio-file link.", customSoundPlaceholder = "https://youtu.be/... or https://example.com/song.mp3",
customSoundFormats = "YouTube, YouTube Music, Shorts, MP3, M4A, AAC, OGG, OPUS, WAV, or WEBM", useCustomSound = "Use sound", loadingSound = "Loading sound", invalidCustomSoundLink = "Use a YouTube link or a direct public HTTPS audio-file link.", customSoundLoadFailed = "This sound could not be loaded.",
trimAndCover = "Trim & cover", trimStart = "Start", trimEnd = "End", coverFrame = "Cover", originalVolume = "Original sound", musicVolume = "Music",
moderation = "Moderation", reports = "Open reports", noReports = "No open reports", removeVideo = "Remove video", dismissReport = "Dismiss",
done = "Done", displayName = "Name", username = "Username", bio = "Bio", accountType = "Account type",
done = "Done", displayName = "Name", username = "Username", bio = "Bio", accountType = "Account type", profilePhoto = "Profile photo", changePhoto = "Change photo", chooseFromGallery = "Gallery", takePhoto = "Camera", removePhoto = "Remove photo", noConnections = "No profiles to show",
authTitle = "Your FlipTok account", login = "Sign In", register = "Register", createAccount = "Create Account", logout = "Sign Out",
loginBody = "Sign in to continue with your videos, follows, and saved posts.", registerBody = "Create a private FlipTok login for this profile.",
password = "Password", confirmPassword = "Confirm password", passwordsMismatch = "The passwords do not match.",
@@ -477,7 +479,7 @@ Locales["en"] = {
accountTypes = { person = "Person", business = "Business", organization = "Organization", media = "Media", event = "Event" },
activityKinds = { like = "liked your video", comment = "commented on your video", follow = "started following you", verified = "verification changed" },
notifications = { like = "{actor} liked your video.", comment = "{actor} commented on your video.", follow = "{actor} started following you.", verified = "Your FlipTok account is now verified.", default = "You have new FlipTok activity." },
errors = { invalid_video = "Check the video details.", invalid_media = "Choose a video from this phone.", invalid_comment = "Enter a valid comment.", comments_disabled = "Comments are disabled.", invalid_profile = "Check your profile details.", invalid_handle = "Use 324 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 872 characters.", invalid_credentials = "Username or password is incorrect.", already_registered = "This Sky Cloud account already owns a registered FlipTok profile.", handle_taken = "This username is already taken.", video_not_found = "This video is unavailable.", blocked = "This account is blocked.", not_authorized = "You do not have moderation access.", report_not_found = "This report is no longer open.", rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to Sky Cloud first.", default = "FlipTok could not complete the request." },
errors = { invalid_video = "Check the video details.", invalid_music_url = "Use a valid YouTube or direct HTTPS audio-file link.", invalid_media = "Choose a video from this phone.", invalid_comment = "Enter a valid comment.", comments_disabled = "Comments are disabled.", invalid_profile = "Check your profile details.", invalid_profile_image = "Choose a valid photo from this phone.", invalid_handle = "Use 324 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 872 characters.", invalid_credentials = "Username or password is incorrect.", already_registered = "This Sky Cloud account already owns a registered FlipTok profile.", handle_taken = "This username is already taken.", video_not_found = "This video is unavailable.", blocked = "This account is blocked.", not_authorized = "You do not have moderation access.", report_not_found = "This report is no longer open.", rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to Sky Cloud first.", default = "FlipTok could not complete the request." },
},
darkchat = {
name = "DarkChat", newMessage = "New DarkChat message from {sender}", privateNotification = "New DarkChat message",
@@ -508,7 +510,7 @@ Locales["en"] = {
name = "Messages", newMessage = "New message from {sender}", compose = "New Message",
search = "Search", to = "To:", message = "Message", send = "Send", details = "Details",
officialContact = "Official company contact", messagingUnavailable = "This company contact does not accept messages.",
filterUnread = "Show Unread Messages", smsLabel = "Text Message · SMS",
filterUnread = "Show Unread Messages", filterLabel = "Conversation Filter", allMessages = "All", unreadMessages = "Unread", smsLabel = "Text Message · SMS",
photo = "Photo", gif = "GIF", video = "Video", contact = "Contact", attachPhoto = "Attach Photo", takePhoto = "Take Photo",
attachGif = "Attach GIF", attachVideo = "Attach Video", photos = "Photos", gifs = "GIFs", videos = "Videos",
contacts = "Contacts", shareContact = "Share Contact", noContactsToShare = "Save a contact first to share it here.", contactSaved = "Contact Saved",
+1
View File
@@ -65,6 +65,7 @@ server_scripts {
'source/server/custom_apps.lua',
'source/server/custom_app_compat.lua',
'source/server/db_migrate.lua',
'source/server/media_metadata.lua',
'source/server/phone.lua',
'source/server/companies.lua',
'source/server/custom_app_storage.lua',
+3
View File
@@ -91,14 +91,17 @@ local server_callbacks = {
"fliptok:feed",
"fliptok:video",
"fliptok:discover",
"fliptok:music-metadata",
"fliptok:publish",
"fliptok:react",
"fliptok:follow",
"fliptok:comments",
"fliptok:comment",
"fliptok:comment-react",
"fliptok:view",
"fliptok:share",
"fliptok:profile",
"fliptok:connections",
"fliptok:update-profile",
"fliptok:activities",
"fliptok:mark-activities",
+32 -2
View File
@@ -1533,6 +1533,7 @@ local schema = {
{ name = "display_name", type = "VARCHAR(40) NOT NULL" },
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
{ name = "account_type", type = "ENUM('person', 'business', 'organization', 'media', 'event') NOT NULL DEFAULT 'person'" },
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
{ name = "verified", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
@@ -1542,7 +1543,10 @@ local schema = {
{ name = "uniq_sky_phone_fliptok_account", columns = "(`account_id`)" },
{ name = "uniq_sky_phone_fliptok_handle", columns = "(`handle`)" },
},
foreignKeys = {{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
@@ -1590,6 +1594,9 @@ local schema = {
{ name = "original_volume", type = "TINYINT UNSIGNED NOT NULL DEFAULT 100" },
{ name = "music_volume", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "music_track", type = "VARCHAR(64) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "custom_music_url", type = "VARCHAR(2048) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_bin" },
{ name = "custom_music_title", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
{ name = "custom_music_artist", type = "VARCHAR(120) NOT NULL DEFAULT ''" },
{ name = "status", type = "ENUM('draft', 'published', 'removed') NOT NULL DEFAULT 'published'" },
{ name = "view_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "share_count", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
@@ -1646,15 +1653,38 @@ local schema = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "video_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "parent_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "body", type = "VARCHAR(300) NOT NULL" },
{ name = "status", type = "ENUM('visible', 'removed') NOT NULL DEFAULT 'visible'" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {{ name = "idx_sky_phone_fliptok_comments", columns = "(`video_id`, `created_at`)" }},
indexes = {
{ name = "idx_sky_phone_fliptok_comments", columns = "(`video_id`, `created_at`)" },
{ name = "idx_sky_phone_fliptok_comment_parent", columns = "(`parent_id`, `created_at`)" },
},
foreignKeys = {
{ column = "video_id", references = "`sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE" },
{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
{ column = "parent_id", references = "`sky_phone_fliptok_comments` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_fliptok_comment_reactions",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "comment_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_fliptok_comment_reaction", columns = "(`comment_id`, `profile_id`)" },
},
foreignKeys = {
{ column = "comment_id", references = "`sky_phone_fliptok_comments` (`id`) ON DELETE CASCADE" },
{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
+214 -22
View File
@@ -5,6 +5,16 @@ local report_reasons = { spam = true, harassment = true, dangerous = true, illeg
local report_actions = { dismiss = true, remove = true }
local music_tracks = {}
local music_track_list = {}
local custom_music_extensions = {
aac = true,
m4a = true,
mp3 = true,
oga = true,
ogg = true,
opus = true,
wav = true,
webm = true,
}
local password_pepper = GetConvar(Config.FlipTok.PasswordPepperConvar, "")
if password_pepper == "" then
@@ -39,6 +49,37 @@ local function valid_text(value, minimum, maximum)
return length and length >= minimum and length <= maximum
end
local function valid_custom_music_url(value)
if type(value) ~= "string" or value == "" or #value > Config.Media.UrlMaxLength
or value:find("[^\33-\126]")
then
return false
end
local authority = value:match("^https://([^/%?#]+)")
if not authority or authority:find("@", 1, true) then return false end
local host = authority:match("^([^:]+)")
local port = host and authority:sub(#host + 1) or ""
if not host or (port ~= "" and port ~= ":443") then return false end
host = host:lower()
if host == "localhost" or host:match("%.localhost$") or host:match("%.local$") or host:match("%.internal$")
or host:match("^%d+%.%d+%.%d+%.%d+$") or not host:find("%.")
or host:find("[^a-z0-9%.%-]")
then
return false
end
for label in host:gmatch("[^.]+") do
if #label > 63 or label:match("^%-") or label:match("%-$") then return false end
end
local path = value:match("^https://[^/]+(/[^?#]*)") or ""
local extension = path:match("%.([%w]+)$")
return extension and custom_music_extensions[extension:lower()] == true or false
end
local function affected_rows(result)
if type(result) == "number" then return result end
if type(result) == "table" then return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0 end
@@ -91,6 +132,7 @@ end
local function hydrate_profile(profile, viewer_id)
profile.id = tonumber(profile.id)
profile.avatar_media_id = tonumber(profile.avatar_media_id)
profile.verified = tonumber(profile.verified) == 1
profile.is_following = viewer_id and tonumber(profile.is_following) == 1 or false
profile.is_owner = viewer_id and profile.id == viewer_id or false
@@ -102,12 +144,14 @@ end
local function load_profile(profile_id, viewer_id)
local rows = Bridge.Database.Query([[
SELECT p.*,
SELECT p.*, avatar.`url` AS `avatar_url`,
EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = p.`id`) AS `is_following`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_follows` f WHERE f.`following_id` = p.`id`) AS `followers`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_follows` f WHERE f.`follower_id` = p.`id`) AS `following`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_videos` v WHERE v.`profile_id` = p.`id` AND v.`status` = 'published') AS `video_count`
FROM `sky_phone_fliptok_profiles` p WHERE p.`id` = ? LIMIT 1
FROM `sky_phone_fliptok_profiles` p
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE p.`id` = ? LIMIT 1
]], { viewer_id, profile_id })
return rows[1] and hydrate_profile(rows[1], viewer_id) or nil
end
@@ -143,9 +187,16 @@ local function hydrate_videos(rows)
video.original_volume = tonumber(video.original_volume) or 100
video.music_volume = tonumber(video.music_volume) or 0
local track = music_tracks[video.music_track]
video.music_title = track and track.title or ""
video.music_artist = track and track.artist or ""
video.music_url = track and track.url or ""
local custom_music_url = type(video.custom_music_url) == "string" and video.custom_music_url or ""
local youtube_id = SkyPhoneYouTube.ParseId(custom_music_url)
video.music_title = track and track.title or video.custom_music_title or ""
video.music_artist = track and track.artist or video.custom_music_artist or ""
video.music_url = track and track.url or (youtube_id and "" or custom_music_url)
video.music_source = track and "audio" or (youtube_id and "youtube" or (custom_music_url ~= "" and "audio" or ""))
video.music_video_id = youtube_id or ""
video.custom_music_url = nil
video.custom_music_title = nil
video.custom_music_artist = nil
video.created_at = (tonumber(video.created_at_unix) or 0) * 1000
video.created_at_unix = nil
end
@@ -160,7 +211,9 @@ local function list_videos(viewer_id, where_clause, values, limit, offset, ranki
return hydrate_videos(Bridge.Database.Query(([[
SELECT v.`id`, v.`profile_id`, v.`caption`, v.`location`, v.`comments_enabled`, v.`view_count`, v.`share_count`,
v.`trim_start_ms`, v.`trim_end_ms`, v.`cover_time_ms`, v.`original_volume`, v.`music_volume`, v.`music_track`,
v.`custom_music_url`, v.`custom_music_title`, v.`custom_music_artist`,
m.`url`, UNIX_TIMESTAMP(v.`created_at`) AS `created_at_unix`, p.`handle`, p.`display_name`, p.`verified`,
avatar.`url` AS `avatar_url`,
(v.`profile_id` = ?) AS `is_owner`,
EXISTS(SELECT 1 FROM `sky_phone_fliptok_reactions` r WHERE r.`video_id` = v.`id` AND r.`profile_id` = ? AND r.`kind` = 'like') AS `is_liked`,
EXISTS(SELECT 1 FROM `sky_phone_fliptok_reactions` r WHERE r.`video_id` = v.`id` AND r.`profile_id` = ? AND r.`kind` = 'save') AS `is_saved`,
@@ -170,6 +223,7 @@ local function list_videos(viewer_id, where_clause, values, limit, offset, ranki
FROM `sky_phone_fliptok_videos` v
JOIN `sky_phone_fliptok_profiles` p ON p.`id` = v.`profile_id`
JOIN `sky_phone_media` m ON m.`id` = v.`media_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE v.`status` = 'published' AND %s
AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE
(b.`blocker_id` = ? AND b.`blocked_id` = v.`profile_id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = v.`profile_id`))
@@ -339,6 +393,25 @@ Bridge.Callbacks.Register("sky_phone:fliptok:discover", function(source, data)
return { success = true, data = rows }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:music-metadata", function(source, data)
local profile, error_response = require_profile(source)
if not profile then return error_response end
if not SkyPhone.AllowOperation(source, "fliptok:music-metadata", 12, 60) then
return { success = false, error = "rate_limited" }
end
local value = trim(type(data) == "table" and data.url or nil)
local video_id = SkyPhoneYouTube.ParseId(value)
if not video_id then return { success = false, error = "invalid_music_url" } end
local metadata = SkyPhoneYouTube.FetchMetadata(video_id)
return { success = true, data = {
url = SkyPhoneYouTube.WatchUrl(video_id),
videoId = video_id,
title = metadata and metadata.title or "YouTube " .. video_id,
artist = metadata and metadata.artist or "YouTube",
} }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:publish", function(source, data)
local profile, error_response = require_profile(source)
if not profile then return error_response end
@@ -354,6 +427,9 @@ Bridge.Callbacks.Register("sky_phone:fliptok:publish", function(source, data)
local original_volume = math.floor(tonumber(data.originalVolume) or 100)
local music_volume = math.floor(tonumber(data.musicVolume) or 0)
local music_track = type(data.musicTrack) == "string" and data.musicTrack or ""
local custom_music_url = trim(data.customMusicUrl) or ""
local custom_music_title = ""
local custom_music_artist = ""
if not media_id or media_id < 1 or media_id ~= math.floor(media_id)
or not valid_text(caption, 0, Config.FlipTok.CaptionMaxLength)
or not valid_text(location, 0, 80) or not visibilities[visibility]
@@ -364,17 +440,31 @@ Bridge.Callbacks.Register("sky_phone:fliptok:publish", function(source, data)
or original_volume < 0 or original_volume > 100 or music_volume < 0 or music_volume > 100
or (music_track ~= "" and not music_tracks[music_track])
then return { success = false, error = "invalid_video" } end
if music_track == "" then music_volume = 0 end
if custom_music_url ~= "" then
if music_track ~= "" then return { success = false, error = "invalid_music_url" } end
local youtube_id = SkyPhoneYouTube.ParseId(custom_music_url)
if youtube_id then
local metadata = SkyPhoneYouTube.FetchMetadata(youtube_id)
custom_music_url = SkyPhoneYouTube.WatchUrl(youtube_id)
custom_music_title = metadata and metadata.title or "YouTube " .. youtube_id
custom_music_artist = metadata and metadata.artist or "YouTube"
elseif not valid_custom_music_url(custom_music_url) then
return { success = false, error = "invalid_music_url" }
end
end
if music_track == "" and custom_music_url == "" then music_volume = 0 end
if not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "video") then
return { success = false, error = "invalid_media" }
end
local id = new_id()
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_videos`
(`id`, `profile_id`, `media_id`, `caption`, `location`, `visibility`, `comments_enabled`, `trim_start_ms`, `trim_end_ms`,
`cover_time_ms`, `original_volume`, `music_volume`, `music_track`, `status`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]], {
`cover_time_ms`, `original_volume`, `music_volume`, `music_track`, `custom_music_url`, `custom_music_title`,
`custom_music_artist`, `status`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]], {
id, profile.id, media_id, caption, location, visibility, data.commentsEnabled and 1 or 0, trim_start_ms, trim_end_ms,
cover_time_ms, original_volume, music_volume, music_track, data.draft == true and "draft" or "published",
cover_time_ms, original_volume, music_volume, music_track, custom_music_url, custom_music_title, custom_music_artist,
data.draft == true and "draft" or "published",
})
return { success = true, data = { id = id } }
end)
@@ -426,14 +516,30 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comments", function(source, data)
local profile, error_response = require_profile(source)
if not profile then return error_response end
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end
local rows = Bridge.Database.Query([[SELECT c.`id`, c.`body`, UNIX_TIMESTAMP(c.`created_at`) * 1000 AS `created_at`,
p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`
FROM `sky_phone_fliptok_comments` c JOIN `sky_phone_fliptok_profiles` p ON p.`id` = c.`profile_id`
local rows = Bridge.Database.Query([[SELECT c.`id`, c.`parent_id`, c.`body`, UNIX_TIMESTAMP(c.`created_at`) * 1000 AS `created_at`,
p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`, avatar.`url` AS `avatar_url`,
parent_author.`handle` AS `reply_to_handle`,
EXISTS(SELECT 1 FROM `sky_phone_fliptok_comment_reactions` reaction
WHERE reaction.`comment_id` = c.`id` AND reaction.`profile_id` = ?) AS `is_liked`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_comment_reactions` reaction
WHERE reaction.`comment_id` = c.`id`) AS `like_count`
FROM `sky_phone_fliptok_comments` c
JOIN `sky_phone_fliptok_profiles` p ON p.`id` = c.`profile_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
LEFT JOIN `sky_phone_fliptok_comments` parent ON parent.`id` = c.`parent_id`
LEFT JOIN `sky_phone_fliptok_profiles` parent_author ON parent_author.`id` = parent.`profile_id`
WHERE c.`video_id` = ? AND c.`status` = 'visible'
AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE
(b.`blocker_id` = ? AND b.`blocked_id` = c.`profile_id`) OR (b.`blocked_id` = ? AND b.`blocker_id` = c.`profile_id`))
ORDER BY c.`created_at` DESC LIMIT 100]], { data.id, profile.id, profile.id })
for _, row in ipairs(rows) do row.verified = tonumber(row.verified) == 1 end
ORDER BY COALESCE(parent.`created_at`, c.`created_at`) DESC,
c.`parent_id` IS NOT NULL, c.`created_at` ASC LIMIT 100]], {
profile.id, data.id, profile.id, profile.id,
})
for _, row in ipairs(rows) do
row.verified = tonumber(row.verified) == 1
row.is_liked = tonumber(row.is_liked) == 1
row.like_count = tonumber(row.like_count) or 0
end
return { success = true, data = rows }
end)
@@ -442,18 +548,57 @@ Bridge.Callbacks.Register("sky_phone:fliptok:comment", function(source, data)
if not profile then return error_response end
if not SkyPhone.AllowOperation(source, "fliptok:comment", 20, 60) then return { success = false, error = "rate_limited" } end
local body = type(data) == "table" and trim(data.body) or nil
local parent_id = type(data) == "table" and trim(data.parentId) or nil
if type(data) ~= "table" or type(data.id) ~= "string" or not valid_text(body, 1, Config.FlipTok.CommentMaxLength) then return { success = false, error = "invalid_comment" } end
if parent_id and #parent_id ~= 36 then return { success = false, error = "invalid_comment" } end
local videos = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_videos` WHERE `id` = ? AND `status` = 'published' AND `comments_enabled` = 1 LIMIT 1", { data.id })
if not videos[1] then return { success = false, error = "comments_disabled" } end
local owner_id = tonumber(videos[1].profile_id)
if owner_id ~= profile.id and are_profiles_blocked(profile.id, owner_id) then
return { success = false, error = "blocked" }
end
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `body`) VALUES (?, ?, ?, ?)", { new_id(), data.id, profile.id, body })
if parent_id then
local parents = Bridge.Database.Query([[SELECT c.`id`, c.`profile_id` FROM `sky_phone_fliptok_comments` c
WHERE c.`id` = ? AND c.`video_id` = ? AND c.`status` = 'visible' LIMIT 1]], { parent_id, data.id })
if not parents[1] or are_profiles_blocked(profile.id, tonumber(parents[1].profile_id)) then
return { success = false, error = "invalid_comment" }
end
end
local comment_id = new_id()
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `parent_id`, `body`) VALUES (?, ?, ?, ?, ?)", {
comment_id, data.id, profile.id, parent_id, body,
})
if tonumber(videos[1].profile_id) ~= profile.id then
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'comment')", { new_id(), videos[1].profile_id, profile.id, data.id })
notify_profile(owner_id, profile.id, "comment", data.id)
end
return { success = true, data = { id = comment_id } }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:comment-react", function(source, data)
local profile, error_response = require_profile(source)
if not profile then return error_response end
if not SkyPhone.AllowOperation(source, "fliptok:comment-react", 60, 60) then
return { success = false, error = "rate_limited" }
end
local comment_id = type(data) == "table" and data.id or nil
if type(comment_id) ~= "string" or #comment_id ~= 36 or type(data.active) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local comments = Bridge.Database.Query([[SELECT c.`profile_id` FROM `sky_phone_fliptok_comments` c
JOIN `sky_phone_fliptok_videos` v ON v.`id` = c.`video_id`
WHERE c.`id` = ? AND c.`status` = 'visible' AND v.`status` = 'published' LIMIT 1]], { comment_id })
if not comments[1] then return { success = false, error = "invalid_comment" } end
if are_profiles_blocked(profile.id, tonumber(comments[1].profile_id)) then
return { success = false, error = "blocked" }
end
if data.active then
Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_fliptok_comment_reactions`
(`comment_id`, `profile_id`) VALUES (?, ?)]], { comment_id, profile.id })
else
Bridge.Database.Query([[DELETE FROM `sky_phone_fliptok_comment_reactions`
WHERE `comment_id` = ? AND `profile_id` = ?]], { comment_id, profile.id })
end
return { success = true }
end)
@@ -491,19 +636,65 @@ Bridge.Callbacks.Register("sky_phone:fliptok:profile", function(source, data)
return { success = true, data = { profile = target, videos = videos } }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:connections", function(source, data)
local viewer, error_response = require_profile(source)
if not viewer then return error_response end
local target_id = type(data) == "table" and tonumber(data.profileId) or nil
local mode = type(data) == "table" and data.mode or nil
if not target_id or (mode ~= "followers" and mode ~= "following") then
return { success = false, error = "invalid_request" }
end
if target_id ~= viewer.id and are_profiles_blocked(viewer.id, target_id) then
return { success = false, error = "profile_not_found" }
end
local join_column = mode == "followers" and "f.`follower_id`" or "f.`following_id`"
local filter_column = mode == "followers" and "f.`following_id`" or "f.`follower_id`"
local rows = Bridge.Database.Query(([[SELECT p.*, avatar.`url` AS `avatar_url`,
EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` own_follow
WHERE own_follow.`follower_id` = ? AND own_follow.`following_id` = p.`id`) AS `is_following`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_follows` followers WHERE followers.`following_id` = p.`id`) AS `followers`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_follows` following WHERE following.`follower_id` = p.`id`) AS `following`,
(SELECT COUNT(*) FROM `sky_phone_fliptok_videos` video WHERE video.`profile_id` = p.`id` AND video.`status` = 'published') AS `video_count`
FROM `sky_phone_fliptok_follows` f
JOIN `sky_phone_fliptok_profiles` p ON p.`id` = %s
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE %s = ? AND NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` block WHERE
(block.`blocker_id` = ? AND block.`blocked_id` = p.`id`) OR
(block.`blocked_id` = ? AND block.`blocker_id` = p.`id`))
ORDER BY p.`display_name`, p.`handle` LIMIT 200]]):format(join_column, filter_column), {
viewer.id, target_id, viewer.id, viewer.id,
})
for _, row in ipairs(rows) do hydrate_profile(row, viewer.id) end
return { success = true, data = rows }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:update-profile", function(source, data)
local profile, error_response = require_profile(source)
if not profile then return error_response end
local handle = type(data) == "table" and trim(data.handle) or nil
local display_name = type(data) == "table" and trim(data.displayName) or nil
local bio = type(data) == "table" and trim(data.bio) or nil
local account_type = type(data) == "table" and data.accountType or nil
if not handle or not handle:match("^[a-z0-9._]+$") or not valid_text(handle, 3, 24)
if type(data) ~= "table" then return { success = false, error = "invalid_request" } end
local handle = normalize_handle(data.handle)
local display_name = trim(data.displayName)
local bio = trim(data.bio)
local account_type = data.accountType
if not handle
or not valid_text(display_name, 1, 40) or not valid_text(bio, 0, Config.FlipTok.BioMaxLength) or not account_types[account_type]
then return { success = false, error = "invalid_profile" } end
local avatar_media_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil
if data.avatarMediaId ~= nil then
local requested_avatar_id = tonumber(data.avatarMediaId)
if not requested_avatar_id or requested_avatar_id < 0 or requested_avatar_id ~= math.floor(requested_avatar_id) then
return { success = false, error = "invalid_profile_image" }
end
if requested_avatar_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, requested_avatar_id, "photo") then
return { success = false, error = "invalid_profile_image" }
end
avatar_media_id = requested_avatar_id > 0 and requested_avatar_id or nil
end
local duplicate = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? AND `id` <> ? LIMIT 1", { handle, profile.id })
if duplicate[1] then return { success = false, error = "handle_taken" } end
Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `handle` = ?, `display_name` = ?, `bio` = ?, `account_type` = ? WHERE `id` = ?", { handle, display_name, bio, account_type, profile.id })
Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `handle` = ?, `display_name` = ?, `bio` = ?, `account_type` = ?, `avatar_media_id` = ? WHERE `id` = ?", {
handle, display_name, bio, account_type, avatar_media_id, profile.id,
})
return { success = true, data = load_profile(profile.id, profile.id) }
end)
@@ -511,8 +702,9 @@ Bridge.Callbacks.Register("sky_phone:fliptok:activities", function(source)
local profile, error_response = require_profile(source)
if not profile then return error_response end
local rows = Bridge.Database.Query([[SELECT n.`id`, n.`kind`, n.`video_id`, n.`read_at`, UNIX_TIMESTAMP(n.`created_at`) * 1000 AS `created_at`,
p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`
p.`id` AS `profile_id`, p.`handle`, p.`display_name`, p.`verified`, avatar.`url` AS `avatar_url`
FROM `sky_phone_fliptok_notifications` n JOIN `sky_phone_fliptok_profiles` p ON p.`id` = n.`actor_id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
WHERE n.`recipient_id` = ?
AND (n.`kind` = 'verified' OR NOT EXISTS(SELECT 1 FROM `sky_phone_fliptok_blocks` b WHERE
(b.`blocker_id` = n.`recipient_id` AND b.`blocked_id` = n.`actor_id`) OR
@@ -0,0 +1,84 @@
SkyPhoneYouTube = {}
local function trim(value)
return type(value) == "string" and value:match("^%s*(.-)%s*$") or nil
end
local function truncate_text(value, maximum)
local length = type(value) == "string" and utf8.len(value) or nil
if not length or length <= maximum then return value end
local boundary = utf8.offset(value, maximum + 1)
return boundary and value:sub(1, boundary - 1) or value
end
function SkyPhoneYouTube.ParseId(value)
if type(value) ~= "string" or #value > 500 or value:find("[^\33-\126]") then return nil end
local authority, path = value:match("^https://([^/]+)(/.*)$")
if not authority or authority:find("@", 1, true) then return nil end
local host = authority:lower():gsub(":443$", "")
local id
if host == "youtu.be" or host == "www.youtu.be" then
id = path:match("^/([%w_-]+)")
elseif host == "youtube.com"
or host == "www.youtube.com"
or host == "m.youtube.com"
or host == "music.youtube.com"
or host == "youtube-nocookie.com"
or host == "www.youtube-nocookie.com"
then
id = path:match("[?&]v=([%w_-]+)")
or path:match("^/shorts/([%w_-]+)")
or path:match("^/embed/([%w_-]+)")
or path:match("^/live/([%w_-]+)")
end
return id and #id == 11 and id or nil
end
function SkyPhoneYouTube.WatchUrl(video_id)
return "https://www.youtube.com/watch?v=" .. video_id
end
function SkyPhoneYouTube.FetchMetadata(video_id)
local request = promise.new()
local settled = false
local function resolve(value)
if settled then return end
settled = true
request:resolve(value)
end
PerformHttpRequest(
"https://www.youtube.com/oembed?format=json&url=" .. SkyPhoneYouTube.WatchUrl(video_id),
function(status, body)
if status ~= 200 or type(body) ~= "string" then
resolve(nil)
return
end
local success, decoded = pcall(json.decode, body)
if not success or type(decoded) ~= "table" then
resolve(nil)
return
end
local title = trim(decoded.title)
local artist = trim(decoded.author_name)
if not title or title == "" or not artist or artist == "" then
resolve(nil)
return
end
resolve({
title = truncate_text(title, 160),
artist = truncate_text(artist, 120),
})
end,
"GET",
"",
{ ["Accept"] = "application/json" }
)
SetTimeout(Config.Music.MetadataTimeoutMs, function()
resolve(nil)
end)
return Citizen.Await(request)
end
+2 -83
View File
@@ -24,15 +24,6 @@ local function text_length(value)
return type(value) == "string" and utf8.len(value) or nil
end
local function truncate_text(value, maximum)
local length = text_length(value)
if not length or length <= maximum then
return value
end
local boundary = utf8.offset(value, maximum + 1)
return boundary and value:sub(1, boundary - 1) or value
end
local function optional_text(value)
local normalized = trim(value)
return normalized ~= "" and normalized or nil
@@ -414,78 +405,6 @@ local function bootstrap(account_id, imei)
}
end
local function parse_youtube_id(value)
if type(value) ~= "string" or #value > 500 then
return nil
end
local host, path = value:match("^https?://([^/]+)(/.*)$")
if not host or not path then
return nil
end
host = host:lower():gsub(":443$", "")
local id
if host == "youtu.be" or host == "www.youtu.be" then
id = path:match("^/([%w_-]+)")
elseif host == "youtube.com"
or host == "www.youtube.com"
or host == "m.youtube.com"
or host == "music.youtube.com"
or host == "youtube-nocookie.com"
or host == "www.youtube-nocookie.com"
then
id = path:match("[?&]v=([%w_-]+)")
or path:match("^/shorts/([%w_-]+)")
or path:match("^/embed/([%w_-]+)")
or path:match("^/live/([%w_-]+)")
end
return id and #id == 11 and id or nil
end
local function fetch_youtube_metadata(video_id)
local request = promise.new()
local settled = false
local function resolve(value)
if settled then
return
end
settled = true
request:resolve(value)
end
PerformHttpRequest(
"https://www.youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v=" .. video_id,
function(status, body)
if status ~= 200 or type(body) ~= "string" then
resolve(nil)
return
end
local success, decoded = pcall(json.decode, body)
if not success or type(decoded) ~= "table" then
resolve(nil)
return
end
local title = trim(decoded.title)
local artist = trim(decoded.author_name)
if not text_length(title) or not text_length(artist) then
resolve(nil)
return
end
resolve({
title = truncate_text(title, 160),
artist = truncate_text(artist, 120),
})
end,
"GET",
"",
{ ["Accept"] = "application/json" }
)
SetTimeout(Config.Music.MetadataTimeoutMs, function()
resolve(nil)
end)
return Citizen.Await(request)
end
local function owned_playlist(account_id, imei, playlist_id)
local condition, owner_params = owner_condition(account_id, imei)
local params = { playlist_id }
@@ -525,7 +444,7 @@ Bridge.Callbacks.Register("sky_phone:music:add-youtube", function(source, data)
return error_response
end
local payload = type(data) == "table" and data or {}
local video_id = parse_youtube_id(payload.url)
local video_id = SkyPhoneYouTube.ParseId(payload.url)
if not video_id then
return { success = false, error = "invalid_youtube_url" }
end
@@ -557,7 +476,7 @@ Bridge.Callbacks.Register("sky_phone:music:add-youtube", function(source, data)
end
local metadata = (not custom_title or not custom_artist)
and fetch_youtube_metadata(video_id)
and SkyPhoneYouTube.FetchMetadata(video_id)
or nil
local title = custom_title or metadata and metadata.title or "YouTube " .. video_id
local artist = custom_artist or metadata and metadata.artist or "YouTube"
+17 -2
View File
@@ -423,10 +423,12 @@ CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_profiles` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `account_id` BIGINT UNSIGNED NOT NULL,
`handle` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, `display_name` VARCHAR(40) NOT NULL,
`bio` VARCHAR(160) NOT NULL DEFAULT '', `account_type` ENUM('person','business','organization','media','event') NOT NULL DEFAULT 'person',
`avatar_media_id` BIGINT UNSIGNED NULL,
`verified` TINYINT(1) NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_fliptok_account` (`account_id`), UNIQUE KEY `uniq_sky_phone_fliptok_handle` (`handle`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_credentials` (
@@ -454,6 +456,8 @@ CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_videos` (
`trim_start_ms` INT UNSIGNED NOT NULL DEFAULT 0, `trim_end_ms` INT UNSIGNED NULL, `cover_time_ms` INT UNSIGNED NOT NULL DEFAULT 0,
`original_volume` TINYINT UNSIGNED NOT NULL DEFAULT 100, `music_volume` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`music_track` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '',
`custom_music_url` VARCHAR(2048) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
`custom_music_title` VARCHAR(160) NOT NULL DEFAULT '', `custom_music_artist` VARCHAR(120) NOT NULL DEFAULT '',
`status` ENUM('draft','published','removed') NOT NULL DEFAULT 'published', `view_count` INT UNSIGNED NOT NULL DEFAULT 0,
`share_count` INT UNSIGNED NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -480,9 +484,20 @@ CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_follows` (
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_comments` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `video_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`profile_id` BIGINT UNSIGNED NOT NULL, `body` VARCHAR(300) NOT NULL, `status` ENUM('visible','removed') NOT NULL DEFAULT 'visible',
`profile_id` BIGINT UNSIGNED NOT NULL, `parent_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`body` VARCHAR(300) NOT NULL, `status` ENUM('visible','removed') NOT NULL DEFAULT 'visible',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_sky_phone_fliptok_comments` (`video_id`,`created_at`),
KEY `idx_sky_phone_fliptok_comment_parent` (`parent_id`,`created_at`),
FOREIGN KEY (`video_id`) REFERENCES `sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`parent_id`) REFERENCES `sky_phone_fliptok_comments` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_comment_reactions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `comment_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`profile_id` BIGINT UNSIGNED NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_fliptok_comment_reaction` (`comment_id`,`profile_id`),
FOREIGN KEY (`comment_id`) REFERENCES `sky_phone_fliptok_comments` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;