mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - implement FlipTok creator platform
This commit is contained in:
@@ -30,6 +30,7 @@ import { useAccountStore } from '@/stores/account'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useFlipTokStore } from '@/stores/fliptok'
|
||||
import { useMediaStore } from '@/stores/media'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
@@ -59,6 +60,8 @@ type AppMessage = {
|
||||
| MarketplaceEventData
|
||||
| MessagesEventData
|
||||
| DarkChatEventData
|
||||
| FlipTokVerificationData
|
||||
| FlipTokNotificationData
|
||||
| PhoneCall
|
||||
| PhoneNotificationInput
|
||||
| PhoneOpenPayload
|
||||
@@ -114,6 +117,20 @@ type CalendarReminderData = {
|
||||
text?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type FlipTokVerificationData = {
|
||||
profileId: number
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
type FlipTokNotificationData = {
|
||||
actor?: string
|
||||
device?: PhoneNotificationDevicePayload
|
||||
kind?: 'like' | 'comment' | 'follow' | 'verified'
|
||||
text?: string
|
||||
title?: string
|
||||
videoId?: string
|
||||
}
|
||||
const REFERENCE_VIEWPORT_WIDTH = 1920
|
||||
const REFERENCE_VIEWPORT_HEIGHT = 1080
|
||||
const PHONE_BASE_SCALE = 0.69
|
||||
@@ -129,6 +146,7 @@ const banking = useBankingStore()
|
||||
const mail = useMailStore()
|
||||
const messages = useMessagesStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const fliptok = useFlipTokStore()
|
||||
const media = useMediaStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const appStore = useAppStoreStore()
|
||||
@@ -276,6 +294,32 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
} else if (event.data?.type === 'marketplace:changed' && event.data.data) {
|
||||
const data = event.data.data as MarketplaceEventData
|
||||
if (data.counts) marketplace.setCounts(data.counts)
|
||||
} else if (
|
||||
event.data?.type === 'fliptok:verification-changed' &&
|
||||
event.data.data
|
||||
) {
|
||||
const data = event.data.data as FlipTokVerificationData
|
||||
fliptok.applyVerification(Number(data.profileId), data.verified === true)
|
||||
} else if (event.data?.type === 'fliptok:new' && event.data.data) {
|
||||
const data = event.data.data as FlipTokNotificationData
|
||||
const notification: PhoneNotificationInput = {
|
||||
appId: 'fliptok',
|
||||
subtitle: data.actor,
|
||||
text: data.text ?? phone.t('Apps.fliptok.notifications.default'),
|
||||
title: data.title ?? phone.t('Apps.fliptok.name'),
|
||||
}
|
||||
if (
|
||||
data.device &&
|
||||
(!phone.isOpen || data.device.imei !== phone.device?.imei)
|
||||
) {
|
||||
notification.device = {
|
||||
imei: data.device.imei,
|
||||
name: data.device.name,
|
||||
preferences: parsePhonePreferences(data.device.settings ?? null),
|
||||
}
|
||||
}
|
||||
notifications.show(notification)
|
||||
if (phone.isOpen) void fliptok.loadActivities()
|
||||
} else if (
|
||||
event.data?.type === 'marketplace:new-message' &&
|
||||
event.data.data
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="FlipTok">
|
||||
<defs>
|
||||
<linearGradient id="flip-bg" x1="18" y1="10" x2="112" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7471f2"/>
|
||||
<stop offset="1" stop-color="#3532a8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="flip-card" x1="48" y1="30" x2="98" y2="106" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#e8e7ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="128" height="128" rx="29" fill="url(#flip-bg)"/>
|
||||
<rect x="23" y="25" width="55" height="76" rx="14" transform="rotate(-9 23 25)" fill="#ffffff" fill-opacity=".23" stroke="#ffffff" stroke-opacity=".46" stroke-width="3"/>
|
||||
<rect x="46" y="23" width="61" height="82" rx="16" fill="url(#flip-card)"/>
|
||||
<path d="M69 50.5c0-3 3.3-4.8 5.8-3.1l21 14c2.2 1.5 2.2 4.7 0 6.2l-21 14c-2.5 1.7-5.8-.1-5.8-3.1v-28Z" fill="#403db7"/>
|
||||
<path d="M24 89c7 18 24 27 44 27 13 0 25-4 34-12" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round"/>
|
||||
<path d="m94 101 11 1-2 11" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -23,6 +23,7 @@ let renderFrameId: number | undefined
|
||||
let lastRenderAt = 0
|
||||
let recorder: MediaRecorder | null = null
|
||||
let stream: MediaStream | null = null
|
||||
let microphoneStream: MediaStream | null = null
|
||||
let chunks: RecordingChunk[] = []
|
||||
let lastChunkAt = 0
|
||||
let lastChunkTimecode: number | null = null
|
||||
@@ -95,7 +96,9 @@ function resetRecording(): void {
|
||||
|
||||
function stopTracks(): void {
|
||||
stream?.getTracks().forEach((track) => track.stop())
|
||||
microphoneStream?.getTracks().forEach((track) => track.stop())
|
||||
stream = null
|
||||
microphoneStream = null
|
||||
}
|
||||
|
||||
function cleanupRecording(): void {
|
||||
@@ -109,7 +112,7 @@ function cleanupRecording(): void {
|
||||
postRecordState(false)
|
||||
}
|
||||
|
||||
function startRecording(data: Record<string, unknown>): void {
|
||||
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
if (recorder) return
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
window.postMessage(
|
||||
@@ -127,13 +130,45 @@ function startRecording(data: Record<string, unknown>): void {
|
||||
}
|
||||
startRenderLoop()
|
||||
resetRecording()
|
||||
stream = canvasRef.value?.captureStream(captureFps) ?? null
|
||||
if (!stream) {
|
||||
const videoStream = canvasRef.value?.captureStream(captureFps) ?? null
|
||||
if (!videoStream) {
|
||||
cleanupRecording()
|
||||
return
|
||||
}
|
||||
if (data.microphoneEnabled === true) {
|
||||
try {
|
||||
microphoneStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
autoGainControl: true,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
videoStream.getTracks().forEach((track) => track.stop())
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'microphone_unavailable', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
stream = new MediaStream([
|
||||
...videoStream.getVideoTracks(),
|
||||
...(microphoneStream?.getAudioTracks() ?? []),
|
||||
])
|
||||
const mimeType = [
|
||||
'video/webm;codecs=vp8,opus',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
].find((type) => MediaRecorder.isTypeSupported(type))
|
||||
recorder = new MediaRecorder(stream, {
|
||||
mimeType: 'video/webm',
|
||||
...(mimeType ? { mimeType } : {}),
|
||||
audioBitsPerSecond: 128_000,
|
||||
videoBitsPerSecond: bitrateBps,
|
||||
})
|
||||
recorder.ondataavailable = (event) => {
|
||||
@@ -311,7 +346,7 @@ function onMessage(event: MessageEvent): void {
|
||||
type?: string
|
||||
}
|
||||
if (message.type === 'camera:recordStart') {
|
||||
startRecording(message.data ?? {})
|
||||
void startRecording(message.data ?? {})
|
||||
} else if (message.type === 'camera:recordStop') {
|
||||
void stopRecording(message.data ?? {})
|
||||
} else if (message.type === 'camera:recordCancel') {
|
||||
|
||||
@@ -118,7 +118,14 @@ describe('app registry', () => {
|
||||
PHONE_APPS.filter((app) => app.category === 'social').map(
|
||||
(app) => app.id,
|
||||
),
|
||||
).toEqual(['local-pages', 'phone', 'darkchat', 'banking', 'mail'])
|
||||
).toEqual([
|
||||
'fliptok',
|
||||
'local-pages',
|
||||
'phone',
|
||||
'darkchat',
|
||||
'banking',
|
||||
'mail',
|
||||
])
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
|
||||
@@ -52,6 +52,7 @@ import bankingIcon from '@/assets/img/app-icons/banking.svg'
|
||||
import garageIcon from '@/assets/img/app-icons/garage.svg'
|
||||
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
|
||||
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
|
||||
import flipTokIcon from '@/assets/img/app-icons/fliptok.svg'
|
||||
import type {
|
||||
LaunchablePhoneAppDefinition,
|
||||
LaunchablePhoneAppId,
|
||||
@@ -59,6 +60,20 @@ import type {
|
||||
} from '@/types/apps'
|
||||
|
||||
export const PHONE_APPS: PhoneAppDefinition[] = [
|
||||
{
|
||||
category: 'social',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/FlipTokApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 22,
|
||||
icon: markRaw(Blocks),
|
||||
iconClass: 'app-icon--fliptok',
|
||||
iconImage: flipTokIcon,
|
||||
id: 'fliptok',
|
||||
labelKey: 'Apps.fliptok.name',
|
||||
route: '/apps/fliptok',
|
||||
},
|
||||
{
|
||||
category: 'productivity',
|
||||
component: markRaw(
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useFlipTokStore } from '@/stores/fliptok'
|
||||
import type {
|
||||
FlipTokActivity,
|
||||
FlipTokComment,
|
||||
FlipTokProfile,
|
||||
FlipTokVideo,
|
||||
} from '@/types/fliptok'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(),
|
||||
}))
|
||||
|
||||
const profile: FlipTokProfile = {
|
||||
account_type: 'person',
|
||||
bio: '',
|
||||
display_name: 'Nova',
|
||||
followers: 1,
|
||||
following: 2,
|
||||
handle: 'nova',
|
||||
id: 7,
|
||||
is_following: false,
|
||||
is_owner: true,
|
||||
verified: false,
|
||||
video_count: 1,
|
||||
}
|
||||
|
||||
const video: FlipTokVideo = {
|
||||
caption: 'Los Santos',
|
||||
comment_count: 1,
|
||||
comments_enabled: true,
|
||||
created_at: 1,
|
||||
display_name: 'Nova',
|
||||
handle: 'nova',
|
||||
id: 'video-1',
|
||||
is_following: false,
|
||||
is_liked: false,
|
||||
is_owner: true,
|
||||
is_saved: false,
|
||||
like_count: 2,
|
||||
location: '',
|
||||
cover_time_ms: 0,
|
||||
music_artist: '',
|
||||
music_title: '',
|
||||
music_track: '',
|
||||
music_url: '',
|
||||
music_volume: 0,
|
||||
original_volume: 100,
|
||||
profile_id: 7,
|
||||
share_count: 0,
|
||||
trim_end_ms: null,
|
||||
trim_start_ms: 0,
|
||||
url: 'https://example.com/video.webm',
|
||||
verified: false,
|
||||
view_count: 3,
|
||||
}
|
||||
|
||||
const comment: FlipTokComment = {
|
||||
body: 'Nice',
|
||||
created_at: 1,
|
||||
display_name: 'Nova',
|
||||
handle: 'nova',
|
||||
id: 'comment-1',
|
||||
profile_id: 7,
|
||||
verified: false,
|
||||
}
|
||||
|
||||
const activity: FlipTokActivity = {
|
||||
created_at: 1,
|
||||
display_name: 'Nova',
|
||||
handle: 'nova',
|
||||
id: 'activity-1',
|
||||
kind: 'follow',
|
||||
profile_id: 7,
|
||||
read_at: null,
|
||||
verified: false,
|
||||
video_id: null,
|
||||
}
|
||||
|
||||
describe('FlipTok verification updates', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(nuiCall).mockReset()
|
||||
})
|
||||
|
||||
it('updates the badge everywhere the profile is already visible', () => {
|
||||
const store = useFlipTokStore()
|
||||
store.profile = { ...profile }
|
||||
store.feed = [{ ...video }]
|
||||
store.searchResults = [{ ...video }]
|
||||
store.comments = [{ ...comment }]
|
||||
store.activities = [{ ...activity }]
|
||||
|
||||
store.applyVerification(7, true)
|
||||
|
||||
expect(store.profile.verified).toBe(true)
|
||||
expect(store.feed[0].verified).toBe(true)
|
||||
expect(store.searchResults[0].verified).toBe(true)
|
||||
expect(store.comments[0].verified).toBe(true)
|
||||
expect(store.activities[0].verified).toBe(true)
|
||||
})
|
||||
|
||||
it('does not alter another profile', () => {
|
||||
const store = useFlipTokStore()
|
||||
store.feed = [{ ...video }]
|
||||
|
||||
store.applyVerification(99, true)
|
||||
|
||||
expect(store.feed[0].verified).toBe(false)
|
||||
})
|
||||
|
||||
it('removes a blocked creator from every visible surface', async () => {
|
||||
vi.mocked(nuiCall).mockResolvedValue({ success: true })
|
||||
const store = useFlipTokStore()
|
||||
store.feed = [{ ...video }]
|
||||
store.searchResults = [{ ...video }]
|
||||
store.profileVideos = [{ ...video }]
|
||||
store.comments = [{ ...comment }]
|
||||
store.activities = [{ ...activity }]
|
||||
store.viewedProfile = { ...profile }
|
||||
|
||||
expect(await store.blockProfile(7)).toBe(true)
|
||||
expect(store.feed).toEqual([])
|
||||
expect(store.searchResults).toEqual([])
|
||||
expect(store.profileVideos).toEqual([])
|
||||
expect(store.comments).toEqual([])
|
||||
expect(store.activities).toEqual([])
|
||||
expect(store.viewedProfile).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import type {
|
||||
FlipTokActivity,
|
||||
FlipTokComment,
|
||||
FlipTokMusicTrack,
|
||||
FlipTokPage,
|
||||
FlipTokProfile,
|
||||
FlipTokProfilePage,
|
||||
FlipTokReport,
|
||||
FlipTokVideo,
|
||||
} from '@/types/fliptok'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
export const useFlipTokStore = defineStore('fliptok', {
|
||||
state: () => ({
|
||||
activities: [] as FlipTokActivity[],
|
||||
comments: [] as FlipTokComment[],
|
||||
feed: [] as FlipTokVideo[],
|
||||
isAdmin: false,
|
||||
loading: false,
|
||||
musicTracks: [] as FlipTokMusicTrack[],
|
||||
mode: 'for-you' as 'for-you' | 'following',
|
||||
profile: null as FlipTokProfile | null,
|
||||
profileVideos: [] as FlipTokVideo[],
|
||||
reports: [] as FlipTokReport[],
|
||||
searchResults: [] as FlipTokVideo[],
|
||||
viewedProfile: null as FlipTokProfile | null,
|
||||
}),
|
||||
actions: {
|
||||
applyVerification(profileId: number, verified: boolean): void {
|
||||
if (this.profile?.id === profileId) this.profile.verified = verified
|
||||
if (this.viewedProfile?.id === profileId)
|
||||
this.viewedProfile.verified = verified
|
||||
this.feed
|
||||
.filter((video) => video.profile_id === profileId)
|
||||
.forEach((video) => {
|
||||
video.verified = verified
|
||||
})
|
||||
this.searchResults
|
||||
.filter((video) => video.profile_id === profileId)
|
||||
.forEach((video) => {
|
||||
video.verified = verified
|
||||
})
|
||||
this.profileVideos
|
||||
.filter((video) => video.profile_id === profileId)
|
||||
.forEach((video) => {
|
||||
video.verified = verified
|
||||
})
|
||||
this.comments
|
||||
.filter((comment) => comment.profile_id === profileId)
|
||||
.forEach((comment) => {
|
||||
comment.verified = verified
|
||||
})
|
||||
this.activities
|
||||
.filter((activity) => activity.profile_id === profileId)
|
||||
.forEach((activity) => {
|
||||
activity.verified = verified
|
||||
})
|
||||
},
|
||||
async bootstrap(): Promise<boolean> {
|
||||
this.loading = true
|
||||
const response = await nuiCall<{
|
||||
feed: FlipTokPage
|
||||
isAdmin: boolean
|
||||
musicTracks: FlipTokMusicTrack[]
|
||||
profile: FlipTokProfile
|
||||
}>('fliptok:bootstrap')
|
||||
this.loading = false
|
||||
if (!response.success || !response.data) return false
|
||||
this.profile = response.data.profile
|
||||
this.feed = response.data.feed.items
|
||||
this.isAdmin = response.data.isAdmin === true
|
||||
this.musicTracks = response.data.musicTracks ?? []
|
||||
return true
|
||||
},
|
||||
async loadFeed(mode?: 'for-you' | 'following'): Promise<boolean> {
|
||||
mode ??= this.mode
|
||||
this.mode = mode
|
||||
this.loading = true
|
||||
const response = await nuiCall<FlipTokPage>('fliptok:feed', {
|
||||
mode,
|
||||
offset: 0,
|
||||
})
|
||||
this.loading = false
|
||||
if (!response.success || !response.data) return false
|
||||
this.feed = response.data.items
|
||||
return true
|
||||
},
|
||||
async discover(search: string): Promise<FlipTokVideo[]> {
|
||||
const response = await nuiCall<FlipTokVideo[]>('fliptok:discover', {
|
||||
search,
|
||||
})
|
||||
this.searchResults =
|
||||
response.success && response.data ? response.data : []
|
||||
return this.searchResults
|
||||
},
|
||||
async react(video: FlipTokVideo, kind: 'like' | 'save'): Promise<void> {
|
||||
const key = kind === 'like' ? 'is_liked' : 'is_saved'
|
||||
const next = !video[key]
|
||||
video[key] = next
|
||||
if (kind === 'like') video.like_count += next ? 1 : -1
|
||||
const response = await nuiCall('fliptok:react', {
|
||||
active: next,
|
||||
id: video.id,
|
||||
kind,
|
||||
})
|
||||
if (!response.success) {
|
||||
video[key] = !next
|
||||
if (kind === 'like') video.like_count += next ? -1 : 1
|
||||
}
|
||||
},
|
||||
async follow(video: FlipTokVideo): Promise<void> {
|
||||
const next = !video.is_following
|
||||
const response = await nuiCall('fliptok:follow', {
|
||||
active: next,
|
||||
profileId: video.profile_id,
|
||||
})
|
||||
if (response.success)
|
||||
this.feed
|
||||
.filter((item) => item.profile_id === video.profile_id)
|
||||
.forEach((item) => {
|
||||
item.is_following = next
|
||||
})
|
||||
},
|
||||
async followProfile(profile: FlipTokProfile): Promise<void> {
|
||||
const next = !profile.is_following
|
||||
const response = await nuiCall('fliptok:follow', {
|
||||
active: next,
|
||||
profileId: profile.id,
|
||||
})
|
||||
if (!response.success) return
|
||||
profile.is_following = next
|
||||
profile.followers += next ? 1 : -1
|
||||
this.feed
|
||||
.filter((item) => item.profile_id === profile.id)
|
||||
.forEach((item) => {
|
||||
item.is_following = next
|
||||
})
|
||||
},
|
||||
async loadProfile(query: {
|
||||
handle?: string
|
||||
profileId?: number
|
||||
}): Promise<boolean> {
|
||||
const response = await nuiCall<FlipTokProfilePage>(
|
||||
'fliptok:profile',
|
||||
query,
|
||||
)
|
||||
if (!response.success || !response.data) return false
|
||||
this.viewedProfile = response.data.profile
|
||||
this.profileVideos = response.data.videos
|
||||
return true
|
||||
},
|
||||
showOwnProfile(): void {
|
||||
this.viewedProfile = null
|
||||
this.profileVideos = this.feed.filter((item) => item.is_owner)
|
||||
},
|
||||
async blockProfile(profileId: number): Promise<boolean> {
|
||||
const response = await nuiCall('fliptok:block', { profileId })
|
||||
if (!response.success) return false
|
||||
this.feed = this.feed.filter((video) => video.profile_id !== profileId)
|
||||
this.searchResults = this.searchResults.filter(
|
||||
(video) => video.profile_id !== profileId,
|
||||
)
|
||||
this.comments = this.comments.filter(
|
||||
(comment) => comment.profile_id !== profileId,
|
||||
)
|
||||
this.activities = this.activities.filter(
|
||||
(activity) => activity.profile_id !== profileId,
|
||||
)
|
||||
this.profileVideos = this.profileVideos.filter(
|
||||
(video) => video.profile_id !== profileId,
|
||||
)
|
||||
if (this.viewedProfile?.id === profileId) this.viewedProfile = null
|
||||
return true
|
||||
},
|
||||
async loadComments(id: string): Promise<void> {
|
||||
const response = await nuiCall<FlipTokComment[]>('fliptok:comments', {
|
||||
id,
|
||||
})
|
||||
this.comments = response.success && response.data ? response.data : []
|
||||
},
|
||||
async comment(id: string, body: string): Promise<NuiResponse> {
|
||||
return nuiCall('fliptok:comment', { body, id })
|
||||
},
|
||||
async loadActivities(): Promise<void> {
|
||||
const response = await nuiCall<FlipTokActivity[]>('fliptok:activities')
|
||||
this.activities = response.success && response.data ? response.data : []
|
||||
if (response.success) await nuiCall('fliptok:mark-activities')
|
||||
},
|
||||
async loadReports(): Promise<boolean> {
|
||||
const response = await nuiCall<FlipTokReport[]>('fliptok:admin-reports')
|
||||
this.reports = response.success && response.data ? response.data : []
|
||||
return response.success
|
||||
},
|
||||
async resolveReport(
|
||||
id: string,
|
||||
action: 'dismiss' | 'remove',
|
||||
): Promise<boolean> {
|
||||
const response = await nuiCall('fliptok:admin-resolve-report', {
|
||||
action,
|
||||
id,
|
||||
})
|
||||
if (response.success) await this.loadReports()
|
||||
return response.success
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -40,6 +40,121 @@ const namespaceQueues = new Map<string, Promise<void>>()
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
Apps: {
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
sounds: 'Sound',
|
||||
chooseSound: 'Choose music',
|
||||
originalOnly: 'Original sound only',
|
||||
noMusic: 'No music tracks are configured.',
|
||||
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',
|
||||
cancel: 'Cancel',
|
||||
done: 'Done',
|
||||
displayName: 'Name',
|
||||
username: 'Username',
|
||||
bio: 'Bio',
|
||||
accountType: 'Account type',
|
||||
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.',
|
||||
handle_taken: 'This username is already taken.',
|
||||
video_not_found: 'This video is unavailable.',
|
||||
rate_limited: 'Too many actions. Try again shortly.',
|
||||
not_authenticated: 'Sign in to iFruit first.',
|
||||
blocked: 'This account is blocked.',
|
||||
not_authorized: 'You do not have moderation access.',
|
||||
report_not_found: 'This report is no longer open.',
|
||||
default: 'FlipTok could not complete the request.',
|
||||
},
|
||||
},
|
||||
darkchat: {
|
||||
name: 'DarkChat',
|
||||
newMessage: 'New DarkChat message from {sender}',
|
||||
@@ -1014,6 +1129,8 @@ const defaultLocales: LocaleTree = {
|
||||
portrait: 'Switch to portrait',
|
||||
photo: 'Photo',
|
||||
video: 'Video',
|
||||
microphoneOn: 'Microphone on',
|
||||
microphoneOff: 'Microphone muted',
|
||||
focusHelp: 'Space for movement',
|
||||
returnHelp: 'Space to return',
|
||||
uploading: '{count} uploading',
|
||||
@@ -1031,6 +1148,8 @@ const defaultLocales: LocaleTree = {
|
||||
invalid_upload: 'The upload could not be verified.',
|
||||
invalid_upload_token: 'The upload session is no longer valid.',
|
||||
missing_config: 'Camera uploads are not configured.',
|
||||
microphone_unavailable:
|
||||
'Allow microphone access or mute the microphone before recording.',
|
||||
not_found: 'The media item no longer exists.',
|
||||
operation_in_progress:
|
||||
'Another media operation is already in progress.',
|
||||
@@ -1488,6 +1607,7 @@ const defaultLocales: LocaleTree = {
|
||||
send: 'Send',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
use: 'Use',
|
||||
},
|
||||
Notifications: { now: 'now' },
|
||||
LockScreen: {
|
||||
|
||||
@@ -26,6 +26,7 @@ export type PhoneAppId =
|
||||
| 'neon-drop'
|
||||
| 'citymarkt'
|
||||
| 'local-pages'
|
||||
| 'fliptok'
|
||||
|
||||
export type LaunchablePhoneAppId = PhoneAppId
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
export type FlipTokProfile = {
|
||||
account_type: 'person' | 'business' | 'organization' | 'media' | 'event'
|
||||
bio: string
|
||||
display_name: string
|
||||
followers: number
|
||||
following: number
|
||||
handle: string
|
||||
id: number
|
||||
is_following: boolean
|
||||
is_owner: boolean
|
||||
verified: boolean
|
||||
video_count: number
|
||||
}
|
||||
|
||||
export type FlipTokVideo = {
|
||||
caption: string
|
||||
comment_count: number
|
||||
comments_enabled: boolean
|
||||
created_at: number
|
||||
display_name: string
|
||||
handle: string
|
||||
id: string
|
||||
is_following: boolean
|
||||
is_liked: boolean
|
||||
is_owner: boolean
|
||||
is_saved: boolean
|
||||
like_count: number
|
||||
location: string
|
||||
cover_time_ms: number
|
||||
music_artist: string
|
||||
music_title: string
|
||||
music_track: string
|
||||
music_url: string
|
||||
music_volume: number
|
||||
original_volume: number
|
||||
profile_id: number
|
||||
share_count: number
|
||||
trim_end_ms: number | null
|
||||
trim_start_ms: number
|
||||
url: string
|
||||
verified: boolean
|
||||
view_count: number
|
||||
}
|
||||
|
||||
export type FlipTokMusicTrack = {
|
||||
artist: string
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type FlipTokReport = {
|
||||
caption: string
|
||||
created_at: number
|
||||
creator_display_name: string
|
||||
creator_handle: string
|
||||
details: string
|
||||
id: string
|
||||
reason: 'spam' | 'harassment' | 'dangerous' | 'illegal' | 'other'
|
||||
reporter_display_name: string
|
||||
reporter_handle: string
|
||||
url: string
|
||||
video_id: string
|
||||
}
|
||||
|
||||
export type FlipTokProfilePage = {
|
||||
profile: FlipTokProfile
|
||||
videos: FlipTokVideo[]
|
||||
}
|
||||
|
||||
export type FlipTokComment = {
|
||||
body: string
|
||||
created_at: number
|
||||
display_name: string
|
||||
handle: string
|
||||
id: string
|
||||
profile_id: number
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export type FlipTokActivity = {
|
||||
created_at: number
|
||||
display_name: string
|
||||
handle: string
|
||||
id: string
|
||||
kind: 'like' | 'comment' | 'follow' | 'verified'
|
||||
profile_id: number
|
||||
read_at: string | null
|
||||
verified: boolean
|
||||
video_id: string | null
|
||||
}
|
||||
|
||||
export type FlipTokPage = {
|
||||
hasMore: boolean
|
||||
items: FlipTokVideo[]
|
||||
offset: number
|
||||
}
|
||||
@@ -68,6 +68,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
'neon-drop': { enabled: true, sounds: true },
|
||||
citymarkt: { enabled: true, sounds: true },
|
||||
'local-pages': { enabled: true, sounds: true },
|
||||
fliptok: { enabled: true, sounds: true },
|
||||
camera: { enabled: true, sounds: true },
|
||||
clock: { enabled: true, sounds: true },
|
||||
calendar: { enabled: true, sounds: true },
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
kFab,
|
||||
kNavbar,
|
||||
kPage,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
} from 'konsta/vue'
|
||||
import { kFab, kNavbar, kPage, kSegmented, kSegmentedButton } from 'konsta/vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Images,
|
||||
Mic,
|
||||
MicOff,
|
||||
RefreshCw,
|
||||
RotateCcwSquare,
|
||||
Video,
|
||||
@@ -45,6 +41,7 @@ const requestedMessageMedia = computed<MediaType | null>(() => {
|
||||
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
|
||||
const selectedZoom = ref<(typeof zoomLevels)[number]>(1)
|
||||
const flashEnabled = ref(false)
|
||||
const microphoneEnabled = ref(true)
|
||||
const frontCamera = ref(false)
|
||||
const shutterActive = ref(false)
|
||||
const focused = ref(true)
|
||||
@@ -63,6 +60,8 @@ let recordingTimer: number | undefined
|
||||
let gameView: GameView | null = null
|
||||
let renderFrameId: number | undefined
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let wheelDelta = 0
|
||||
let wheelResetTimer: number | undefined
|
||||
|
||||
const pendingCount = computed(
|
||||
() =>
|
||||
@@ -88,6 +87,12 @@ const flashColors = computed(() => ({
|
||||
? 'text-yellow-500 dark:text-yellow-300'
|
||||
: controlColors.textIos,
|
||||
}))
|
||||
const microphoneColors = computed(() => ({
|
||||
...controlColors,
|
||||
textIos: microphoneEnabled.value
|
||||
? 'text-white'
|
||||
: 'text-red-400',
|
||||
}))
|
||||
|
||||
function correlationId(): string {
|
||||
return `${Date.now()}-${crypto.randomUUID()}`
|
||||
@@ -156,15 +161,31 @@ async function requestPhoto(): Promise<void> {
|
||||
|
||||
function startRecording(): void {
|
||||
if (savingVideo.value) return
|
||||
if (isDevelopment) {
|
||||
recording.value = true
|
||||
recordingStartedAt.value = Date.now()
|
||||
updateRecordingTimer()
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
recordingTimer = window.setInterval(updateRecordingTimer, 250)
|
||||
return
|
||||
}
|
||||
window.postMessage(
|
||||
{
|
||||
data: { bitrateKbps: videoBitrateKbps.value },
|
||||
data: {
|
||||
bitrateKbps: videoBitrateKbps.value,
|
||||
microphoneEnabled: microphoneEnabled.value,
|
||||
},
|
||||
type: 'camera:recordStart',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
|
||||
function toggleMicrophone(): void {
|
||||
if (recording.value || savingVideo.value) return
|
||||
microphoneEnabled.value = !microphoneEnabled.value
|
||||
}
|
||||
|
||||
function stopRecording(): void {
|
||||
if (!recording.value || savingVideo.value) return
|
||||
const id = correlationId()
|
||||
@@ -172,6 +193,8 @@ function stopRecording(): void {
|
||||
if (isDevelopment) {
|
||||
recording.value = false
|
||||
savingVideo.value = true
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
recordingTimer = undefined
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
@@ -247,6 +270,26 @@ function setZoom(zoom: (typeof zoomLevels)[number]): void {
|
||||
void nuiCall('camera:setZoom', { zoom })
|
||||
}
|
||||
|
||||
function zoomWithWheel(event: WheelEvent): void {
|
||||
wheelDelta += event.deltaY
|
||||
if (wheelResetTimer !== undefined) window.clearTimeout(wheelResetTimer)
|
||||
wheelResetTimer = window.setTimeout(() => {
|
||||
wheelDelta = 0
|
||||
wheelResetTimer = undefined
|
||||
}, 140)
|
||||
if (Math.abs(wheelDelta) < 35) return
|
||||
|
||||
const currentIndex = zoomLevels.indexOf(selectedZoom.value)
|
||||
const nextIndex = Math.min(
|
||||
zoomLevels.length - 1,
|
||||
Math.max(0, currentIndex + (wheelDelta < 0 ? 1 : -1)),
|
||||
)
|
||||
wheelDelta = 0
|
||||
const nextZoom = zoomLevels[nextIndex]
|
||||
if (nextZoom !== undefined && nextZoom !== selectedZoom.value)
|
||||
setZoom(nextZoom)
|
||||
}
|
||||
|
||||
function resizeGameView(entry?: ResizeObserverEntry): void {
|
||||
if (!gameCanvas.value || !gameView) return
|
||||
const width = entry?.contentRect.width ?? gameCanvas.value.offsetWidth
|
||||
@@ -379,6 +422,7 @@ onBeforeUnmount(() => {
|
||||
if (shutterTimer !== undefined) window.clearTimeout(shutterTimer)
|
||||
if (noticeTimer !== undefined) window.clearTimeout(noticeTimer)
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
if (wheelResetTimer !== undefined) window.clearTimeout(wheelResetTimer)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('message', onMessage)
|
||||
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
|
||||
@@ -402,7 +446,7 @@ onBeforeUnmount(() => {
|
||||
:class="{ 'camera-page--landscape': phone.cameraLandscape }"
|
||||
:aria-label="phone.t('Apps.camera.name')"
|
||||
>
|
||||
<div class="camera-viewport">
|
||||
<div class="camera-viewport" @wheel.prevent="zoomWithWheel">
|
||||
<canvas
|
||||
v-if="!isDevelopment"
|
||||
ref="gameCanvas"
|
||||
@@ -423,29 +467,53 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<header class="camera-topbar">
|
||||
<button
|
||||
v-if="requestedMessageMedia"
|
||||
class="camera-picker-back"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="cancelMediaSelection"
|
||||
>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<k-fab
|
||||
v-if="!requestedMessageMedia"
|
||||
component="button"
|
||||
type="button"
|
||||
class="camera-control"
|
||||
:colors="flashColors"
|
||||
:aria-label="phone.t('Apps.camera.flash')"
|
||||
@click="toggleFlash"
|
||||
>
|
||||
<template #icon>
|
||||
<Zap v-if="flashEnabled" :size="19" />
|
||||
<ZapOff v-else :size="19" />
|
||||
</template>
|
||||
</k-fab>
|
||||
<div class="camera-topbar-actions">
|
||||
<button
|
||||
v-if="requestedMessageMedia"
|
||||
class="camera-picker-back"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="cancelMediaSelection"
|
||||
>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<k-fab
|
||||
v-else
|
||||
component="button"
|
||||
type="button"
|
||||
class="camera-control"
|
||||
:colors="flashColors"
|
||||
:aria-label="phone.t('Apps.camera.flash')"
|
||||
@click="toggleFlash"
|
||||
>
|
||||
<template #icon>
|
||||
<Zap v-if="flashEnabled" :size="19" />
|
||||
<ZapOff v-else :size="19" />
|
||||
</template>
|
||||
</k-fab>
|
||||
<k-fab
|
||||
v-if="mode === 'video'"
|
||||
component="button"
|
||||
type="button"
|
||||
class="camera-control"
|
||||
:colors="microphoneColors"
|
||||
:disabled="recording || savingVideo"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
microphoneEnabled
|
||||
? 'Apps.camera.microphoneOn'
|
||||
: 'Apps.camera.microphoneOff',
|
||||
)
|
||||
"
|
||||
:aria-pressed="microphoneEnabled"
|
||||
@click="toggleMicrophone"
|
||||
>
|
||||
<template #icon>
|
||||
<Mic v-if="microphoneEnabled" :size="19" />
|
||||
<MicOff v-else :size="19" />
|
||||
</template>
|
||||
</k-fab>
|
||||
</div>
|
||||
<span
|
||||
v-if="noticeText"
|
||||
class="camera-focus-pill camera-focus-pill--notice"
|
||||
@@ -455,11 +523,7 @@ onBeforeUnmount(() => {
|
||||
<span v-else-if="pendingCount" class="camera-upload-pill">
|
||||
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
|
||||
</span>
|
||||
<span v-else class="camera-focus-pill">
|
||||
{{
|
||||
phone.t(focused ? 'Apps.camera.focusHelp' : 'Apps.camera.returnHelp')
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="camera-topbar-spacer" aria-hidden="true"></span>
|
||||
<k-fab
|
||||
component="button"
|
||||
type="button"
|
||||
@@ -595,7 +659,7 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.camera-page {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -684,9 +748,20 @@ onBeforeUnmount(() => {
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
grid-template-columns: auto minmax(0, 1fr) 44px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.camera-topbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.camera-topbar-spacer {
|
||||
min-width: 0;
|
||||
}
|
||||
.camera-topbar .camera-control {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.camera-control {
|
||||
--color-primary: transparent;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,9 @@ const requestedMessageMedia = computed<GalleryFilter | null>(() => {
|
||||
return value === 'photo' || value === 'video' ? value : null
|
||||
})
|
||||
const multipleSelection = computed(
|
||||
() => requestedMessageMedia.value !== null && (messageMedia.request?.maxSelection ?? 1) > 1,
|
||||
() =>
|
||||
requestedMessageMedia.value !== null &&
|
||||
(messageMedia.request?.maxSelection ?? 1) > 1,
|
||||
)
|
||||
const selectedMediaIds = ref<number[]>([])
|
||||
const media = ref<PhoneMedia[]>([])
|
||||
@@ -210,14 +212,14 @@ function openMedia(entry: PhoneMedia): void {
|
||||
if (multipleSelection.value) {
|
||||
const index = selectedMediaIds.value.indexOf(entry.id)
|
||||
if (index >= 0) selectedMediaIds.value.splice(index, 1)
|
||||
else if (selectedMediaIds.value.length < (messageMedia.request?.maxSelection ?? 1)) {
|
||||
else if (
|
||||
selectedMediaIds.value.length <
|
||||
(messageMedia.request?.maxSelection ?? 1)
|
||||
) {
|
||||
selectedMediaIds.value.push(entry.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
const returnPath = messageMedia.complete(entry)
|
||||
if (returnPath) void router.replace(returnPath)
|
||||
return
|
||||
}
|
||||
landscapeViewer.value = false
|
||||
phone.setCameraLandscape(false)
|
||||
@@ -226,6 +228,12 @@ function openMedia(entry: PhoneMedia): void {
|
||||
imagePan.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
function completeSingleSelection(): void {
|
||||
if (!selected.value) return
|
||||
const returnPath = messageMedia.complete(selected.value)
|
||||
if (returnPath) void router.replace(returnPath)
|
||||
}
|
||||
|
||||
function completeMultipleSelection(): void {
|
||||
const selectedMedia = selectedMediaIds.value.flatMap((id) => {
|
||||
const entry = media.value.find((item) => item.id === id)
|
||||
@@ -405,7 +413,9 @@ onBeforeUnmount(() => {
|
||||
v-for="entry in media"
|
||||
:key="entry.id"
|
||||
class="gallery-tile"
|
||||
:class="{ 'gallery-tile--selected': selectedMediaIds.includes(entry.id) }"
|
||||
:class="{
|
||||
'gallery-tile--selected': selectedMediaIds.includes(entry.id),
|
||||
}"
|
||||
type="button"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
@@ -499,6 +509,14 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<template #right>
|
||||
<k-link
|
||||
v-if="requestedMessageMedia"
|
||||
component="button"
|
||||
@click="completeSingleSelection"
|
||||
>
|
||||
{{ phone.t('Common.use') }}
|
||||
</k-link>
|
||||
<k-link
|
||||
v-else
|
||||
component="button"
|
||||
icon-only
|
||||
class="text-red-500"
|
||||
|
||||
@@ -23,6 +23,126 @@ let draft = null
|
||||
let mockBankBalance = 24787
|
||||
let mockCashBalance = 2350
|
||||
let nextBankTransactionId = 7
|
||||
const flipTokProfile = {
|
||||
id: 1,
|
||||
handle: 'skyline',
|
||||
display_name: 'Skyline',
|
||||
bio: 'Life around Los Santos.',
|
||||
account_type: 'media',
|
||||
verified: true,
|
||||
is_following: false,
|
||||
is_owner: true,
|
||||
followers: 18400,
|
||||
following: 128,
|
||||
video_count: 2,
|
||||
}
|
||||
const flipTokMusicTracks = [
|
||||
{
|
||||
id: 'night-drive',
|
||||
title: 'Night Drive',
|
||||
artist: 'Los Santos Radio',
|
||||
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
|
||||
},
|
||||
]
|
||||
let flipTokVideos = [
|
||||
{
|
||||
id: 'fliptok-1',
|
||||
profile_id: 2,
|
||||
handle: 'novals',
|
||||
display_name: 'Nova',
|
||||
verified: true,
|
||||
caption: 'A quiet minute above Vinewood. #LosSantos',
|
||||
location: 'Vinewood Hills',
|
||||
trim_start_ms: 0,
|
||||
trim_end_ms: null,
|
||||
cover_time_ms: 1200,
|
||||
original_volume: 100,
|
||||
music_volume: 0,
|
||||
music_track: '',
|
||||
music_title: '',
|
||||
music_artist: '',
|
||||
music_url: '',
|
||||
url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
|
||||
comments_enabled: true,
|
||||
is_liked: false,
|
||||
is_saved: false,
|
||||
is_following: false,
|
||||
is_owner: false,
|
||||
like_count: 12840,
|
||||
comment_count: 384,
|
||||
view_count: 245100,
|
||||
share_count: 932,
|
||||
created_at: Date.now() - 3600000,
|
||||
},
|
||||
{
|
||||
id: 'fliptok-2',
|
||||
profile_id: 1,
|
||||
handle: 'skyline',
|
||||
display_name: 'Skyline',
|
||||
verified: true,
|
||||
caption: 'Tonight in the city.',
|
||||
location: 'Downtown Los Santos',
|
||||
trim_start_ms: 800,
|
||||
trim_end_ms: 12000,
|
||||
cover_time_ms: 2200,
|
||||
original_volume: 70,
|
||||
music_volume: 25,
|
||||
music_track: 'night-drive',
|
||||
music_title: 'Night Drive',
|
||||
music_artist: 'Los Santos Radio',
|
||||
music_url: flipTokMusicTracks[0].url,
|
||||
url: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
|
||||
comments_enabled: true,
|
||||
is_liked: true,
|
||||
is_saved: true,
|
||||
is_following: false,
|
||||
is_owner: true,
|
||||
like_count: 4921,
|
||||
comment_count: 97,
|
||||
view_count: 88300,
|
||||
share_count: 220,
|
||||
created_at: Date.now() - 7200000,
|
||||
},
|
||||
]
|
||||
let flipTokComments = [
|
||||
{
|
||||
id: 'comment-1',
|
||||
profile_id: 2,
|
||||
handle: 'nova',
|
||||
display_name: 'Nova',
|
||||
verified: true,
|
||||
body: 'This view is perfect.',
|
||||
created_at: Date.now() - 300000,
|
||||
},
|
||||
]
|
||||
let flipTokActivities = [
|
||||
{
|
||||
id: 'activity-1',
|
||||
profile_id: 2,
|
||||
handle: 'nova',
|
||||
display_name: 'Nova',
|
||||
verified: true,
|
||||
kind: 'like',
|
||||
video_id: 'fliptok-2',
|
||||
read_at: null,
|
||||
created_at: Date.now() - 240000,
|
||||
},
|
||||
]
|
||||
let flipTokReports = [
|
||||
{
|
||||
id: 'report-1',
|
||||
video_id: 'fliptok-1',
|
||||
reason: 'dangerous',
|
||||
details: 'Please review the driving shown in this clip.',
|
||||
created_at: Date.now() - 600000,
|
||||
caption: flipTokVideos[0].caption,
|
||||
url: flipTokVideos[0].url,
|
||||
reporter_handle: 'skyline',
|
||||
reporter_display_name: 'Skyline',
|
||||
creator_handle: 'novals',
|
||||
creator_display_name: 'Nova',
|
||||
},
|
||||
]
|
||||
const mockBankTransactions = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -1052,6 +1172,204 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
playerName: 'Alex Morgan',
|
||||
transactions: mockBankTransactions,
|
||||
})
|
||||
if (endpoint === 'fliptok:bootstrap') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
profile: flipTokProfile,
|
||||
feed: { items: flipTokVideos, offset: 0, hasMore: false },
|
||||
isAdmin: true,
|
||||
musicTracks: flipTokMusicTracks,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:feed') {
|
||||
const items =
|
||||
request.body.mode === 'following'
|
||||
? flipTokVideos.filter((video) => video.is_following)
|
||||
: flipTokVideos
|
||||
response.json({ success: true, data: { items, offset: 0, hasMore: false } })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:discover') {
|
||||
const search = String(request.body.search ?? '').toLowerCase()
|
||||
response.json({
|
||||
success: true,
|
||||
data: flipTokVideos.filter((video) =>
|
||||
`${video.handle} ${video.display_name} ${video.caption}`
|
||||
.toLowerCase()
|
||||
.includes(search),
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:react') {
|
||||
const video = flipTokVideos.find((item) => item.id === request.body.id)
|
||||
if (video) {
|
||||
const key = request.body.kind === 'like' ? 'is_liked' : 'is_saved'
|
||||
video[key] = request.body.active
|
||||
}
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:follow') {
|
||||
flipTokVideos
|
||||
.filter((video) => video.profile_id === request.body.profileId)
|
||||
.forEach((video) => {
|
||||
video.is_following = request.body.active
|
||||
})
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:share') {
|
||||
const video = flipTokVideos.find((item) => item.id === request.body.id)
|
||||
if (video) video.share_count += 1
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:comments') {
|
||||
response.json({ success: true, data: flipTokComments })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:comment') {
|
||||
flipTokComments.unshift({
|
||||
id: `comment-${Date.now()}`,
|
||||
profile_id: 1,
|
||||
handle: flipTokProfile.handle,
|
||||
display_name: flipTokProfile.display_name,
|
||||
verified: flipTokProfile.verified,
|
||||
body: request.body.body,
|
||||
created_at: Date.now(),
|
||||
})
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:activities') {
|
||||
response.json({ success: true, data: flipTokActivities })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:profile') {
|
||||
const profileId = Number(request.body.profileId || 0)
|
||||
const handle = String(request.body.handle || '').toLowerCase()
|
||||
const own = profileId === flipTokProfile.id || handle === flipTokProfile.handle
|
||||
const videos = own
|
||||
? flipTokVideos.filter((video) => video.profile_id === flipTokProfile.id)
|
||||
: flipTokVideos.filter((video) =>
|
||||
profileId ? video.profile_id === profileId : video.handle === handle,
|
||||
)
|
||||
const first = videos[0]
|
||||
if (!first && !own) {
|
||||
response.json({ success: false, error: 'profile_not_found' })
|
||||
return
|
||||
}
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
profile: own
|
||||
? { ...flipTokProfile }
|
||||
: {
|
||||
id: first.profile_id,
|
||||
handle: first.handle,
|
||||
display_name: first.display_name,
|
||||
bio: 'Creator in Los Santos.',
|
||||
account_type: 'person',
|
||||
verified: first.verified,
|
||||
is_following: first.is_following,
|
||||
is_owner: false,
|
||||
followers: 12840,
|
||||
following: 91,
|
||||
video_count: videos.length,
|
||||
},
|
||||
videos,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:block') {
|
||||
const profileId = Number(request.body.profileId)
|
||||
flipTokVideos = flipTokVideos.filter((video) => video.profile_id !== profileId)
|
||||
flipTokComments = flipTokComments.filter((comment) => comment.profile_id !== profileId)
|
||||
flipTokActivities = flipTokActivities.filter((activity) => activity.profile_id !== profileId)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:admin-reports') {
|
||||
response.json({ success: true, data: flipTokReports })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:admin-resolve-report') {
|
||||
const selected = flipTokReports.find((report) => report.id === request.body.id)
|
||||
if (selected && request.body.action === 'remove')
|
||||
flipTokVideos = flipTokVideos.filter((video) => video.id !== selected.video_id)
|
||||
flipTokReports = flipTokReports.filter((report) =>
|
||||
request.body.action === 'remove'
|
||||
? report.video_id !== selected?.video_id
|
||||
: report.id !== request.body.id,
|
||||
)
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:update-profile') {
|
||||
Object.assign(flipTokProfile, {
|
||||
handle: request.body.handle,
|
||||
display_name: request.body.displayName,
|
||||
bio: request.body.bio,
|
||||
account_type: request.body.accountType,
|
||||
})
|
||||
response.json({ success: true, data: flipTokProfile })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'fliptok:publish') {
|
||||
const media = mockMedia.find(
|
||||
(item) => item.id === request.body.mediaId && item.mediaType === 'video',
|
||||
)
|
||||
if (!media) {
|
||||
response.json({ success: false, error: 'invalid_media' })
|
||||
return
|
||||
}
|
||||
flipTokVideos.unshift({
|
||||
id: `fliptok-${Date.now()}`,
|
||||
profile_id: 1,
|
||||
handle: flipTokProfile.handle,
|
||||
display_name: flipTokProfile.display_name,
|
||||
verified: flipTokProfile.verified,
|
||||
caption: request.body.caption,
|
||||
location: request.body.location,
|
||||
trim_start_ms: request.body.trimStartMs || 0,
|
||||
trim_end_ms: request.body.trimEndMs || null,
|
||||
cover_time_ms: request.body.coverTimeMs || 0,
|
||||
original_volume: request.body.originalVolume ?? 100,
|
||||
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 || '',
|
||||
url: media.url,
|
||||
comments_enabled: request.body.commentsEnabled,
|
||||
is_liked: false,
|
||||
is_saved: false,
|
||||
is_following: false,
|
||||
is_owner: true,
|
||||
like_count: 0,
|
||||
comment_count: 0,
|
||||
view_count: 0,
|
||||
share_count: 0,
|
||||
created_at: Date.now(),
|
||||
})
|
||||
response.json({ success: true, data: { id: flipTokVideos[0].id } })
|
||||
return
|
||||
}
|
||||
if (endpoint.startsWith('fliptok:')) {
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'banking:overview') {
|
||||
response.json({ success: true, data: bankingOverview() })
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user