mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 3–24 letters, numbers, dots, or underscores.',
|
||||
invalid_display_name: 'Enter a display name.',
|
||||
invalid_password: 'Password must be 8–72 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',
|
||||
|
||||
@@ -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\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
+2360
-648
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/,
|
||||
)
|
||||
})
|
||||
})
|
||||
+1022
-550
File diff suppressed because it is too large
Load Diff
+207
-12
@@ -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,
|
||||
|
||||
@@ -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 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.", 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 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.", 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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user