mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Merge branch 'dev' into feature/funk
This commit is contained in:
@@ -1,5 +1,25 @@
|
||||
# sky_phone
|
||||
|
||||
## FlipTok verification
|
||||
|
||||
FlipTok verification is server-authoritative and limited to the framework groups configured in
|
||||
`Config.FlipTok.AdminGroups`; no command ACE is required. Use
|
||||
`/fliptokverify <@handle> [on|off]`. Without `on` or `off`, the current blue-check state is toggled.
|
||||
The command name is configurable through `Config.FlipTok.VerifyCommand`.
|
||||
Verification command access uses `Config.FlipTok.AdminGroups`. The report moderation overview is
|
||||
server-authoritative and independently restricted through `Config.FlipTok.ReportAdminGroups`.
|
||||
|
||||
## FlipTok music
|
||||
|
||||
Licensed music can be exposed in the composer through `Config.FlipTok.MusicTracks`. Keep the IDs
|
||||
stable because published videos store the selected ID; URLs must be directly playable by the NUI.
|
||||
|
||||
```lua
|
||||
MusicTracks = {
|
||||
{ Id = "night-drive", Title = "Night Drive", Artist = "Sky Radio", Url = "https://cdn.example.com/night-drive.ogg" },
|
||||
}
|
||||
```
|
||||
|
||||
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
|
||||
|
||||
An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data.
|
||||
|
||||
@@ -31,6 +31,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'
|
||||
@@ -60,6 +61,8 @@ type AppMessage = {
|
||||
| MarketplaceEventData
|
||||
| MessagesEventData
|
||||
| DarkChatEventData
|
||||
| FlipTokVerificationData
|
||||
| FlipTokNotificationData
|
||||
| PhoneCall
|
||||
| PhoneNotificationInput
|
||||
| PhoneOpenPayload
|
||||
@@ -115,6 +118,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
|
||||
@@ -130,6 +147,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()
|
||||
@@ -277,6 +295,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 |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -364,14 +364,16 @@ button {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
padding: 19px 42px 0;
|
||||
height: 48px;
|
||||
padding: 17px 30px 0;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.25px;
|
||||
text-shadow: 0 1px 3px #0009;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -382,10 +384,10 @@ button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
width: 118px;
|
||||
height: 45px;
|
||||
padding: 11px 42px 0 0;
|
||||
gap: 6px;
|
||||
width: 120px;
|
||||
height: 48px;
|
||||
padding: 17px 30px 0 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -40,13 +40,13 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<Plane
|
||||
v-if="phone.preferences.settings.airplaneMode"
|
||||
:size="12"
|
||||
:size="18"
|
||||
:stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Signal
|
||||
v-else-if="phone.preferences.settings.cellularEnabled"
|
||||
:size="12"
|
||||
:size="19"
|
||||
:stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -55,11 +55,11 @@ onBeforeUnmount(() => {
|
||||
phone.preferences.settings.wifiEnabled &&
|
||||
!phone.preferences.settings.airplaneMode
|
||||
"
|
||||
:size="13"
|
||||
:size="20"
|
||||
:stroke-width="2.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<BatteryMedium :size="16" :stroke-width="2.4" />
|
||||
<BatteryMedium :size="26" :stroke-width="2.4" aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -124,7 +124,15 @@ describe('app registry', () => {
|
||||
PHONE_APPS.filter((app) => app.category === 'social').map(
|
||||
(app) => app.id,
|
||||
),
|
||||
).toEqual(['local-pages', 'radio', 'phone', 'darkchat', 'banking', 'mail'])
|
||||
).toEqual([
|
||||
'fliptok',
|
||||
'radio',
|
||||
'local-pages',
|
||||
'phone',
|
||||
'darkchat',
|
||||
'banking',
|
||||
'mail',
|
||||
])
|
||||
expect(
|
||||
PHONE_APPS.filter((app) => app.dockOrder !== null)
|
||||
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
|
||||
|
||||
@@ -54,6 +54,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,
|
||||
@@ -61,6 +62,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}',
|
||||
@@ -1070,6 +1185,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',
|
||||
@@ -1087,6 +1204,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.',
|
||||
@@ -1544,6 +1663,7 @@ const defaultLocales: LocaleTree = {
|
||||
send: 'Send',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
use: 'Use',
|
||||
},
|
||||
Notifications: { now: 'now' },
|
||||
LockScreen: {
|
||||
|
||||
@@ -27,6 +27,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"
|
||||
|
||||
@@ -47,6 +47,126 @@ const radioData = {
|
||||
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,
|
||||
@@ -1134,6 +1254,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
|
||||
|
||||
@@ -268,6 +268,18 @@ Config.LocalPages = {
|
||||
CityMarktSharesPerDay = 1,
|
||||
}
|
||||
|
||||
Config.FlipTok = {
|
||||
PageSize = 12,
|
||||
CaptionMaxLength = 500,
|
||||
CommentMaxLength = 300,
|
||||
BioMaxLength = 160,
|
||||
MaxVideoDurationMs = 300000,
|
||||
MusicTracks = {},
|
||||
VerifyCommand = "fliptokverify",
|
||||
AdminGroups = { "admin" },
|
||||
ReportAdminGroups = { "admin" },
|
||||
}
|
||||
|
||||
Config.Calendar = {
|
||||
TitleMaxLength = 120,
|
||||
NoteMaxLength = 2000,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
Locales["en"] = {
|
||||
CommandDescription = "Open your phone.",
|
||||
FlipTokCommand = {
|
||||
usage = "Usage: /{command} <@handle> [on|off]",
|
||||
noPermission = "You do not have permission to manage FlipTok verification.",
|
||||
notFound = "FlipTok profile @{handle} was not found.",
|
||||
updated = "FlipTok @{handle} is now {state}.",
|
||||
verified = "verified",
|
||||
unverified = "unverified",
|
||||
},
|
||||
DeviceErrors = {
|
||||
phone_slot_missing = "The used phone could not be identified. Make sure phones are not stacked.",
|
||||
phone_stacked = "Phones cannot be stacked.",
|
||||
@@ -15,7 +23,7 @@ Locales["en"] = {
|
||||
},
|
||||
Nui = {
|
||||
Common = {
|
||||
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause",
|
||||
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
|
||||
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
|
||||
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
|
||||
},
|
||||
@@ -64,6 +72,28 @@ Locales["en"] = {
|
||||
},
|
||||
},
|
||||
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", cancel = "Cancel",
|
||||
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",
|
||||
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.", 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 iFruit first.", default = "FlipTok could not complete the request." },
|
||||
},
|
||||
darkchat = {
|
||||
name = "DarkChat", newMessage = "New DarkChat message from {sender}", privateNotification = "New DarkChat message",
|
||||
signInBody = "DarkChat identities are linked to your private iFruit account.", signInHint = "Sign in through Settings to continue.",
|
||||
@@ -341,7 +371,7 @@ Locales["en"] = {
|
||||
},
|
||||
camera = {
|
||||
name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape",
|
||||
portrait = "Switch to portrait", photo = "Photo", video = "Video",
|
||||
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",
|
||||
saving = "Saving video...", openGallery = "Open Gallery", takePhoto = "Take photo",
|
||||
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Gallery.",
|
||||
@@ -350,6 +380,7 @@ Locales["en"] = {
|
||||
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
|
||||
invalid_media_type = "The uploaded media type is invalid.", 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.", owner_changed = "The active phone account changed during upload.",
|
||||
operation_in_progress = "Another media operation is already in progress.",
|
||||
rate_limited = "Too many media actions. Try again shortly.", request_failed = "The camera request failed.",
|
||||
|
||||
@@ -36,6 +36,7 @@ server_scripts {
|
||||
'@oxmysql/lib/MySQL.lua',
|
||||
'config/config.lua',
|
||||
'config/media.lua',
|
||||
'config/locales/*.lua',
|
||||
'source/bridge/server/database.lua',
|
||||
'source/bridge/server/migrations.lua',
|
||||
'source/bridge/server/callbacks.lua',
|
||||
@@ -56,6 +57,7 @@ server_scripts {
|
||||
'source/server/garage.lua',
|
||||
'source/server/marketplace.lua',
|
||||
'source/server/pages.lua',
|
||||
'source/server/fliptok.lua',
|
||||
'source/server/calendar.lua',
|
||||
'source/server/radio.lua',
|
||||
}
|
||||
|
||||
@@ -21,6 +21,20 @@ function Bridge.Framework.GetIdentifier(source)
|
||||
return player and player.identifier or nil
|
||||
end
|
||||
|
||||
function Bridge.Framework.HasAdminGroup(source, groups)
|
||||
local player = get_player(source)
|
||||
if not player then
|
||||
return false
|
||||
end
|
||||
local player_group = player.getGroup()
|
||||
for _, group in ipairs(groups) do
|
||||
if player_group == group then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Bridge.Framework.GetMoney(source, account)
|
||||
local player = get_player(source)
|
||||
if not player then
|
||||
|
||||
@@ -21,6 +21,14 @@ function Bridge.Framework.GetIdentifier(source)
|
||||
return player and player.PlayerData and tostring(player.PlayerData.citizenid) or nil
|
||||
end
|
||||
|
||||
function Bridge.Framework.HasAdminGroup(source, groups)
|
||||
local player = get_player(source)
|
||||
if not player then
|
||||
return false
|
||||
end
|
||||
return QBCore.Functions.HasPermission(source, groups)
|
||||
end
|
||||
|
||||
function Bridge.Framework.GetMoney(source, account)
|
||||
local player = get_player(source)
|
||||
return player and player.PlayerData and player.PlayerData.money[account] or nil
|
||||
|
||||
@@ -19,6 +19,14 @@ function Bridge.Framework.GetIdentifier(source)
|
||||
return player and player.PlayerData and tostring(player.PlayerData.citizenid) or nil
|
||||
end
|
||||
|
||||
function Bridge.Framework.HasAdminGroup(source, groups)
|
||||
local player = get_player(source)
|
||||
if not player then
|
||||
return false
|
||||
end
|
||||
return exports.qbx_core:HasGroup(source, groups)
|
||||
end
|
||||
|
||||
function Bridge.Framework.GetMoney(source, account)
|
||||
return exports.qbx_core:GetMoney(tonumber(source), account)
|
||||
end
|
||||
|
||||
@@ -60,6 +60,25 @@ local server_callbacks = {
|
||||
"pages:share-citymarkt",
|
||||
"pages:react",
|
||||
"pages:delete",
|
||||
"fliptok:bootstrap",
|
||||
"fliptok:feed",
|
||||
"fliptok:discover",
|
||||
"fliptok:publish",
|
||||
"fliptok:react",
|
||||
"fliptok:follow",
|
||||
"fliptok:comments",
|
||||
"fliptok:comment",
|
||||
"fliptok:view",
|
||||
"fliptok:share",
|
||||
"fliptok:profile",
|
||||
"fliptok:update-profile",
|
||||
"fliptok:activities",
|
||||
"fliptok:mark-activities",
|
||||
"fliptok:report",
|
||||
"fliptok:admin-reports",
|
||||
"fliptok:admin-resolve-report",
|
||||
"fliptok:block",
|
||||
"fliptok:delete",
|
||||
"calendar:list",
|
||||
"calendar:create",
|
||||
"calendar:update",
|
||||
@@ -388,6 +407,22 @@ RegisterNetEvent("sky_phone:marketplace:changed", function(data)
|
||||
SendNUIMessage({ type = "marketplace:changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:fliptok:command-feedback", function(data)
|
||||
Bridge.Framework.Notify("FlipTok", data.message, data.notificationType, 5000)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:fliptok:verification-changed", function(data)
|
||||
SendNUIMessage({ type = "fliptok:verification-changed", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:fliptok:new", function(data)
|
||||
local fliptok_locale = get_locale().Nui.Apps.fliptok
|
||||
local notification_text = fliptok_locale.notifications[data.kind] or fliptok_locale.notifications.default
|
||||
data.title = fliptok_locale.name
|
||||
data.text = notification_text:gsub("{actor}", tostring(data.actor or ""))
|
||||
SendNUIMessage({ type = "fliptok:new", data = data })
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:marketplace:new-message", function(data)
|
||||
local marketplace_locale = get_locale().Nui.Apps.citymarkt
|
||||
data.title = marketplace_locale.name
|
||||
|
||||
@@ -881,6 +881,166 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_profiles",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "handle", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ 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 = "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" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ 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" }},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_videos",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "caption", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
|
||||
{ name = "location", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
|
||||
{ name = "visibility", type = "ENUM('public', 'followers', 'private') NOT NULL DEFAULT 'public'" },
|
||||
{ name = "comments_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" },
|
||||
{ name = "trim_start_ms", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "trim_end_ms", type = "INT UNSIGNED NULL" },
|
||||
{ name = "cover_time_ms", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ 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 = "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" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_fliptok_feed", columns = "(`status`, `visibility`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_fliptok_profile", columns = "(`profile_id`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "profile_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_reactions",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "video_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "kind", type = "ENUM('like', 'save') NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_fliptok_reaction", columns = "(`video_id`, `profile_id`, `kind`)" }},
|
||||
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" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_follows",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "follower_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "following_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_fliptok_follow", columns = "(`follower_id`, `following_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "follower_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "following_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_comments",
|
||||
columns = {
|
||||
{ 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 = "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`)" }},
|
||||
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" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_notifications",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "recipient_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "actor_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "video_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "kind", type = "ENUM('like', 'comment', 'follow', 'verified') NOT NULL" },
|
||||
{ name = "read_at", type = "DATETIME NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {{ name = "idx_sky_phone_fliptok_activity", columns = "(`recipient_id`, `created_at`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "recipient_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "actor_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "video_id", references = "`sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_reports",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reporter_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "video_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reason", type = "ENUM('spam', 'harassment', 'dangerous', 'illegal', 'other') NOT NULL" },
|
||||
{ name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
|
||||
{ name = "status", type = "ENUM('open', 'reviewed', 'dismissed') NOT NULL DEFAULT 'open'" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_fliptok_report", columns = "(`reporter_id`, `video_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "reporter_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "video_id", references = "`sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_fliptok_blocks",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "blocker_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "blocked_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {{ name = "uniq_sky_phone_fliptok_block", columns = "(`blocker_id`, `blocked_id`)" }},
|
||||
foreignKeys = {
|
||||
{ column = "blocker_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "blocked_id", references = "`sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local account_types = { person = true, business = true, organization = true, media = true, event = true }
|
||||
local visibilities = { public = true, followers = true, private = true }
|
||||
local report_reasons = { spam = true, harassment = true, dangerous = true, illegal = true, other = true }
|
||||
local report_actions = { dismiss = true, remove = true }
|
||||
local music_tracks = {}
|
||||
local music_track_list = {}
|
||||
|
||||
for _, track in ipairs(Config.FlipTok.MusicTracks) do
|
||||
local id = tostring(track.Id or track.id or "")
|
||||
local title = tostring(track.Title or track.title or "")
|
||||
local artist = tostring(track.Artist or track.artist or "")
|
||||
local url = tostring(track.Url or track.url or "")
|
||||
if id == "" or title == "" or artist == "" or url == "" then
|
||||
error("[sky_phone] Every configured FlipTok music track requires Id, Title, Artist, and Url.")
|
||||
end
|
||||
local item = { id = id, title = title, artist = artist, url = url }
|
||||
music_tracks[id] = item
|
||||
music_track_list[#music_track_list + 1] = item
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
if type(value) ~= "string" then return nil end
|
||||
return value:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function valid_text(value, minimum, maximum)
|
||||
local length = type(value) == "string" and utf8.len(value) or nil
|
||||
return length and length >= minimum and length <= maximum
|
||||
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
|
||||
return 0
|
||||
end
|
||||
|
||||
local function are_profiles_blocked(first_id, second_id)
|
||||
return Bridge.Database.Query([[SELECT `id` FROM `sky_phone_fliptok_blocks` WHERE
|
||||
(`blocker_id` = ? AND `blocked_id` = ?) OR (`blocker_id` = ? AND `blocked_id` = ?) LIMIT 1]], {
|
||||
first_id, second_id, second_id, first_id,
|
||||
})[1] ~= nil
|
||||
end
|
||||
|
||||
local function new_id()
|
||||
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
if not rows[1] or type(rows[1].id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a FlipTok id.")
|
||||
end
|
||||
return rows[1].id
|
||||
end
|
||||
|
||||
local function profile_for_account(account)
|
||||
local rows = Bridge.Database.Query("SELECT * FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
|
||||
if rows[1] then return rows[1] end
|
||||
|
||||
local base = account.email:match("^([^@]+)") or "user"
|
||||
base = base:lower():gsub("[^a-z0-9._]", ""):sub(1, 16)
|
||||
if #base < 3 then base = "user" end
|
||||
local handle = base
|
||||
local suffix = 0
|
||||
while Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? LIMIT 1", { handle })[1] do
|
||||
suffix = suffix + 1
|
||||
handle = (base:sub(1, 18) .. tostring(account.id) .. tostring(suffix)):sub(1, 24)
|
||||
end
|
||||
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_profiles`
|
||||
(`account_id`, `handle`, `display_name`) VALUES (?, ?, ?)]], { account.id, handle, base })
|
||||
return Bridge.Database.Query("SELECT * FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })[1]
|
||||
end
|
||||
|
||||
local function require_profile(source)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return nil, error_response end
|
||||
return profile_for_account(account), nil
|
||||
end
|
||||
|
||||
local function hydrate_profile(profile, viewer_id)
|
||||
profile.id = tonumber(profile.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
|
||||
profile.followers = tonumber(profile.followers) or 0
|
||||
profile.following = tonumber(profile.following) or 0
|
||||
profile.video_count = tonumber(profile.video_count) or 0
|
||||
return profile
|
||||
end
|
||||
|
||||
local function load_profile(profile_id, viewer_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT p.*,
|
||||
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
|
||||
]], { viewer_id, profile_id })
|
||||
return rows[1] and hydrate_profile(rows[1], viewer_id) or nil
|
||||
end
|
||||
|
||||
local function notify_profile(recipient_id, actor_id, kind, video_id)
|
||||
local rows = Bridge.Database.Query([[SELECT recipient.`account_id`, actor.`display_name` AS `actor_name`
|
||||
FROM `sky_phone_fliptok_profiles` recipient
|
||||
JOIN `sky_phone_fliptok_profiles` actor ON actor.`id` = ?
|
||||
WHERE recipient.`id` = ? LIMIT 1]], { actor_id, recipient_id })
|
||||
SkyPhone.NotifyAccountDevices(tonumber(rows[1].account_id), "sky_phone:fliptok:new", {
|
||||
actor = rows[1].actor_name,
|
||||
kind = kind,
|
||||
videoId = video_id,
|
||||
})
|
||||
end
|
||||
|
||||
local function hydrate_videos(rows)
|
||||
for _, video in ipairs(rows) do
|
||||
video.profile_id = tonumber(video.profile_id)
|
||||
video.verified = tonumber(video.verified) == 1
|
||||
video.comments_enabled = tonumber(video.comments_enabled) == 1
|
||||
video.is_liked = tonumber(video.is_liked) == 1
|
||||
video.is_saved = tonumber(video.is_saved) == 1
|
||||
video.is_following = tonumber(video.is_following) == 1
|
||||
video.is_owner = tonumber(video.is_owner) == 1
|
||||
video.like_count = tonumber(video.like_count) or 0
|
||||
video.comment_count = tonumber(video.comment_count) or 0
|
||||
video.view_count = tonumber(video.view_count) or 0
|
||||
video.share_count = tonumber(video.share_count) or 0
|
||||
video.trim_start_ms = tonumber(video.trim_start_ms) or 0
|
||||
video.trim_end_ms = tonumber(video.trim_end_ms)
|
||||
video.cover_time_ms = tonumber(video.cover_time_ms) or 0
|
||||
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 ""
|
||||
video.created_at = (tonumber(video.created_at_unix) or 0) * 1000
|
||||
video.created_at_unix = nil
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function list_videos(viewer_id, where_clause, values, limit, offset, ranking)
|
||||
local parameters = { viewer_id, viewer_id, viewer_id, viewer_id }
|
||||
for _, value in ipairs(values) do parameters[#parameters + 1] = value end
|
||||
parameters[#parameters + 1] = limit
|
||||
parameters[#parameters + 1] = offset
|
||||
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`,
|
||||
m.`url`, UNIX_TIMESTAMP(v.`created_at`) AS `created_at_unix`, p.`handle`, p.`display_name`, p.`verified`,
|
||||
(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`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` f WHERE f.`follower_id` = ? AND f.`following_id` = v.`profile_id`) AS `is_following`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_fliptok_reactions` r WHERE r.`video_id` = v.`id` AND r.`kind` = 'like') AS `like_count`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_fliptok_comments` c WHERE c.`video_id` = v.`id` AND c.`status` = 'visible') AS `comment_count`
|
||||
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`
|
||||
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`))
|
||||
ORDER BY %s LIMIT ? OFFSET ?
|
||||
]]):format(where_clause, ranking), parameters))
|
||||
end
|
||||
|
||||
local function feed(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
data = type(data) == "table" and data or {}
|
||||
local offset = math.max(0, math.floor(tonumber(data.offset) or 0))
|
||||
local limit = Config.FlipTok.PageSize
|
||||
local where = "v.`visibility` = 'public'"
|
||||
local ranking = "(v.`view_count` + v.`share_count` * 8 + (SELECT COUNT(*) FROM `sky_phone_fliptok_reactions` rr WHERE rr.`video_id` = v.`id`) * 4) DESC, v.`created_at` DESC"
|
||||
if data.mode == "following" then
|
||||
where = "v.`visibility` IN ('public', 'followers') AND EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` ff WHERE ff.`follower_id` = ? AND ff.`following_id` = v.`profile_id`)"
|
||||
ranking = "v.`created_at` DESC"
|
||||
end
|
||||
local values = data.mode == "following" and { profile.id, profile.id, profile.id } or { profile.id, profile.id }
|
||||
local rows = list_videos(profile.id, where, values, limit + 1, offset, ranking)
|
||||
local has_more = #rows > limit
|
||||
if has_more then rows[#rows] = nil end
|
||||
return { success = true, data = { items = rows, offset = offset, hasMore = has_more } }
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:bootstrap", function(source)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local result = feed(source, { mode = "for-you", offset = 0 })
|
||||
if not result.success then return result end
|
||||
return { success = true, data = {
|
||||
profile = load_profile(profile.id, profile.id),
|
||||
feed = result.data,
|
||||
isAdmin = Bridge.Framework.HasAdminGroup(source, Config.FlipTok.ReportAdminGroups),
|
||||
musicTracks = music_track_list,
|
||||
} }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:feed", feed)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:discover", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local search = type(data) == "table" and trim(data.search) or ""
|
||||
if search and utf8.len(search) > 50 then return { success = false, error = "invalid_request" } end
|
||||
local pattern = "%" .. (search or "") .. "%"
|
||||
local rows = list_videos(profile.id, "v.`visibility` = 'public' AND (p.`handle` LIKE ? OR p.`display_name` LIKE ? OR v.`caption` LIKE ?)", { pattern, pattern, pattern, profile.id, profile.id }, Config.FlipTok.PageSize, 0, "v.`created_at` DESC")
|
||||
return { success = true, data = rows }
|
||||
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
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:publish", 6, 60) then return { success = false, error = "rate_limited" } end
|
||||
if type(data) ~= "table" then return { success = false, error = "invalid_video" } end
|
||||
local media_id = tonumber(data.mediaId)
|
||||
local caption = trim(data.caption) or ""
|
||||
local location = trim(data.location) or ""
|
||||
local visibility = data.visibility or "public"
|
||||
local trim_start_ms = math.floor(tonumber(data.trimStartMs) or 0)
|
||||
local trim_end_ms = data.trimEndMs ~= nil and math.floor(tonumber(data.trimEndMs) or -1) or nil
|
||||
local cover_time_ms = math.floor(tonumber(data.coverTimeMs) or 0)
|
||||
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 ""
|
||||
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]
|
||||
or type(data.commentsEnabled) ~= "boolean"
|
||||
or trim_start_ms < 0 or trim_start_ms > Config.FlipTok.MaxVideoDurationMs
|
||||
or (trim_end_ms and (trim_end_ms <= trim_start_ms or trim_end_ms > Config.FlipTok.MaxVideoDurationMs))
|
||||
or cover_time_ms < trim_start_ms or (trim_end_ms and cover_time_ms > trim_end_ms)
|
||||
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 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)]], {
|
||||
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",
|
||||
})
|
||||
return { success = true, data = { id = id } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok: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:react", 60, 60) then return { success = false, error = "rate_limited" } end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or (data.kind ~= "like" and data.kind ~= "save") or type(data.active) ~= "boolean" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local videos = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_videos` WHERE `id` = ? AND `status` = 'published' LIMIT 1", { data.id })
|
||||
if not videos[1] then return { success = false, error = "video_not_found" } end
|
||||
local owner_id = tonumber(videos[1].profile_id)
|
||||
if owner_id ~= profile.id and are_profiles_blocked(profile.id, owner_id) then
|
||||
return { success = false, error = "blocked" }
|
||||
end
|
||||
if data.active then
|
||||
local inserted = Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_reactions` (`video_id`, `profile_id`, `kind`) VALUES (?, ?, ?)", { data.id, profile.id, data.kind })
|
||||
if affected_rows(inserted) > 0 and data.kind == "like" and owner_id ~= profile.id then
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) VALUES (?, ?, ?, ?, 'like')", { new_id(), videos[1].profile_id, profile.id, data.id })
|
||||
notify_profile(owner_id, profile.id, "like", data.id)
|
||||
end
|
||||
else
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_fliptok_reactions` WHERE `video_id` = ? AND `profile_id` = ? AND `kind` = ?", { data.id, profile.id, data.kind })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:follow", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local target_id = type(data) == "table" and tonumber(data.profileId) or nil
|
||||
if not target_id or target_id == profile.id or type(data.active) ~= "boolean" then return { success = false, error = "invalid_request" } end
|
||||
if are_profiles_blocked(profile.id, target_id) then return { success = false, error = "blocked" } end
|
||||
if data.active then
|
||||
local inserted = Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_follows` (`follower_id`, `following_id`) SELECT ?, `id` FROM `sky_phone_fliptok_profiles` WHERE `id` = ?", { profile.id, target_id })
|
||||
if affected_rows(inserted) > 0 then
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `kind`) VALUES (?, ?, ?, 'follow')", { new_id(), target_id, profile.id })
|
||||
notify_profile(target_id, profile.id, "follow")
|
||||
end
|
||||
else
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_fliptok_follows` WHERE `follower_id` = ? AND `following_id` = ?", { profile.id, target_id })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:comments", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end
|
||||
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`
|
||||
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
|
||||
return { success = true, data = rows }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:comment", 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", 20, 60) then return { success = false, error = "rate_limited" } end
|
||||
local body = type(data) == "table" and trim(data.body) 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
|
||||
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 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 }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:view", function(source, data)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:view", 120, 60) then return { success = false, error = "rate_limited" } end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `view_count` = `view_count` + 1 WHERE `id` = ? AND `status` = 'published'", { data.id })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:share", function(source, data)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:share", 30, 60) then return { success = false, error = "rate_limited" } end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then return { success = false, error = "invalid_request" } end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `share_count` = `share_count` + 1 WHERE `id` = ? AND `status` = 'published'", { data.id })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:profile", function(source, data)
|
||||
local viewer, error_response = require_profile(source)
|
||||
if not viewer then return error_response end
|
||||
local handle = type(data) == "table" and trim(data.handle) or nil
|
||||
local id = type(data) == "table" and tonumber(data.profileId) or viewer.id
|
||||
local rows = handle and Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? LIMIT 1", { handle }) or { { id = id } }
|
||||
if not rows[1] then return { success = false, error = "profile_not_found" } end
|
||||
if tonumber(rows[1].id) ~= viewer.id and are_profiles_blocked(viewer.id, tonumber(rows[1].id)) then
|
||||
return { success = false, error = "profile_not_found" }
|
||||
end
|
||||
local target = load_profile(tonumber(rows[1].id), viewer.id)
|
||||
if not target then return { success = false, error = "profile_not_found" } end
|
||||
local videos = list_videos(viewer.id, "v.`profile_id` = ? AND (v.`visibility` = 'public' OR v.`profile_id` = ?)", { target.id, viewer.id, viewer.id, viewer.id }, 60, 0, "v.`created_at` DESC")
|
||||
return { success = true, data = { profile = target, videos = videos } }
|
||||
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)
|
||||
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 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 })
|
||||
return { success = true, data = load_profile(profile.id, profile.id) }
|
||||
end)
|
||||
|
||||
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`
|
||||
FROM `sky_phone_fliptok_notifications` n JOIN `sky_phone_fliptok_profiles` p ON p.`id` = n.`actor_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
|
||||
(b.`blocked_id` = n.`recipient_id` AND b.`blocker_id` = n.`actor_id`)))
|
||||
ORDER BY n.`created_at` DESC LIMIT 100]], { profile.id })
|
||||
for _, row in ipairs(rows) do row.verified = tonumber(row.verified) == 1 end
|
||||
return { success = true, data = rows }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:mark-activities", function(source)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_notifications` SET `read_at` = NOW() WHERE `recipient_id` = ? AND `read_at` IS NULL", { profile.id })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:report", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "fliptok:report", 10, 60) then return { success = false, error = "rate_limited" } end
|
||||
local reason = type(data) == "table" and data.reason or nil
|
||||
local details = type(data) == "table" and trim(data.details) or ""
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or not report_reasons[reason] or not valid_text(details, 0, 500) then return { success = false, error = "invalid_report" } end
|
||||
local videos = Bridge.Database.Query([[SELECT v.`profile_id` FROM `sky_phone_fliptok_videos` v
|
||||
WHERE v.`id` = ? AND v.`status` = 'published'
|
||||
AND (v.`profile_id` = ? OR v.`visibility` = 'public' OR
|
||||
(v.`visibility` = 'followers' AND EXISTS(SELECT 1 FROM `sky_phone_fliptok_follows` f
|
||||
WHERE f.`follower_id` = ? AND f.`following_id` = v.`profile_id`)))
|
||||
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`))
|
||||
LIMIT 1]], { data.id, profile.id, profile.id, profile.id, profile.id })
|
||||
if not videos[1] then return { success = false, error = "video_not_found" } end
|
||||
Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_fliptok_reports` (`id`, `reporter_id`, `video_id`, `reason`, `details`) VALUES (?, ?, ?, ?, ?)", { new_id(), profile.id, data.id, reason, details })
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:admin-reports", function(source)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
if not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.ReportAdminGroups) then
|
||||
return { success = false, error = "not_authorized" }
|
||||
end
|
||||
local rows = Bridge.Database.Query([[SELECT r.`id`, r.`video_id`, r.`reason`, r.`details`,
|
||||
UNIX_TIMESTAMP(r.`created_at`) * 1000 AS `created_at`, v.`caption`, m.`url`,
|
||||
reporter.`handle` AS `reporter_handle`, reporter.`display_name` AS `reporter_display_name`,
|
||||
creator.`handle` AS `creator_handle`, creator.`display_name` AS `creator_display_name`
|
||||
FROM `sky_phone_fliptok_reports` r
|
||||
JOIN `sky_phone_fliptok_videos` v ON v.`id` = r.`video_id`
|
||||
JOIN `sky_phone_media` m ON m.`id` = v.`media_id`
|
||||
JOIN `sky_phone_fliptok_profiles` reporter ON reporter.`id` = r.`reporter_id`
|
||||
JOIN `sky_phone_fliptok_profiles` creator ON creator.`id` = v.`profile_id`
|
||||
WHERE r.`status` = 'open' ORDER BY r.`created_at` ASC LIMIT 200]], {})
|
||||
return { success = true, data = rows }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:admin-resolve-report", function(source, data)
|
||||
local _, error_response = require_profile(source)
|
||||
if error_response then return error_response end
|
||||
if not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.ReportAdminGroups) then
|
||||
return { success = false, error = "not_authorized" }
|
||||
end
|
||||
local id = type(data) == "table" and data.id or nil
|
||||
local action = type(data) == "table" and data.action or nil
|
||||
if type(id) ~= "string" or not report_actions[action] then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local reports = Bridge.Database.Query("SELECT `video_id` FROM `sky_phone_fliptok_reports` WHERE `id` = ? AND `status` = 'open' LIMIT 1", { id })
|
||||
if not reports[1] then return { success = false, error = "report_not_found" } end
|
||||
if action == "remove" then
|
||||
Bridge.Database.Transaction({
|
||||
{ query = "UPDATE `sky_phone_fliptok_videos` SET `status` = 'removed' WHERE `id` = ?", params = { reports[1].video_id } },
|
||||
{ query = "UPDATE `sky_phone_fliptok_reports` SET `status` = 'reviewed' WHERE `video_id` = ? AND `status` = 'open'", params = { reports[1].video_id } },
|
||||
})
|
||||
else
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_reports` SET `status` = 'dismissed' WHERE `id` = ?", { id })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:block", function(source, data)
|
||||
local profile, error_response = require_profile(source)
|
||||
if not profile then return error_response end
|
||||
local target_id = type(data) == "table" and tonumber(data.profileId) or nil
|
||||
if not target_id or target_id == profile.id then return { success = false, error = "invalid_request" } end
|
||||
Bridge.Database.Transaction({
|
||||
{ query = "INSERT IGNORE INTO `sky_phone_fliptok_blocks` (`blocker_id`, `blocked_id`) VALUES (?, ?)", params = { profile.id, target_id } },
|
||||
{ query = "DELETE FROM `sky_phone_fliptok_follows` WHERE (`follower_id` = ? AND `following_id` = ?) OR (`follower_id` = ? AND `following_id` = ?)", params = { profile.id, target_id, target_id, profile.id } },
|
||||
})
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:fliptok:delete", 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 = "video_not_found" } end
|
||||
local result = Bridge.Database.Query("UPDATE `sky_phone_fliptok_videos` SET `status` = 'removed' WHERE `id` = ? AND `profile_id` = ?", { data.id, profile.id })
|
||||
local affected = type(result) == "number" and result or type(result) == "table" and tonumber(result.affectedRows) or 0
|
||||
return affected > 0 and { success = true } or { success = false, error = "video_not_found" }
|
||||
end)
|
||||
|
||||
RegisterCommand(Config.FlipTok.VerifyCommand, function(source, arguments)
|
||||
local command_locale = (Locales[Config.Bridge.Locale] or Locales["en"]).FlipTokCommand
|
||||
local function command_message(template, values)
|
||||
return template:gsub("{(%w+)}", function(key) return values[key] or "" end)
|
||||
end
|
||||
local function send_command_feedback(message, notification_type)
|
||||
if source == 0 then
|
||||
print(message)
|
||||
return
|
||||
end
|
||||
|
||||
TriggerClientEvent("sky_phone:fliptok:command-feedback", source, {
|
||||
message = message,
|
||||
notificationType = notification_type,
|
||||
})
|
||||
end
|
||||
if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.FlipTok.AdminGroups) then
|
||||
send_command_feedback(command_locale.noPermission, "error")
|
||||
print(("[sky_phone] Player %d attempted to use the FlipTok verification command without an admin group."):format(source))
|
||||
return
|
||||
end
|
||||
local handle = type(arguments[1]) == "string" and arguments[1]:lower():gsub("^@", "") or ""
|
||||
local requested = type(arguments[2]) == "string" and arguments[2]:lower() or nil
|
||||
if handle == "" or (requested and requested ~= "on" and requested ~= "off") then
|
||||
local message = command_message(command_locale.usage, { command = Config.FlipTok.VerifyCommand })
|
||||
send_command_feedback(message, "error")
|
||||
return
|
||||
end
|
||||
local rows = Bridge.Database.Query("SELECT `id`, `verified` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? LIMIT 1", { handle })
|
||||
if not rows[1] then
|
||||
local message = command_message(command_locale.notFound, { handle = handle })
|
||||
send_command_feedback(message, "error")
|
||||
return
|
||||
end
|
||||
local verified
|
||||
if requested then
|
||||
verified = requested == "on"
|
||||
else
|
||||
verified = tonumber(rows[1].verified) ~= 1
|
||||
end
|
||||
Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `verified` = ? WHERE `id` = ?", { verified and 1 or 0, rows[1].id })
|
||||
if verified then
|
||||
Bridge.Database.Query("INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `kind`) VALUES (?, ?, ?, 'verified')", {
|
||||
new_id(), rows[1].id, rows[1].id,
|
||||
})
|
||||
notify_profile(tonumber(rows[1].id), tonumber(rows[1].id), "verified")
|
||||
end
|
||||
TriggerClientEvent("sky_phone:fliptok:verification-changed", -1, {
|
||||
profileId = tonumber(rows[1].id),
|
||||
verified = verified,
|
||||
})
|
||||
local message = command_message(command_locale.updated, {
|
||||
handle = handle,
|
||||
state = verified and command_locale.verified or command_locale.unverified,
|
||||
})
|
||||
send_command_feedback(message, "success")
|
||||
end, false)
|
||||
end)
|
||||
@@ -252,3 +252,78 @@ CREATE TABLE IF NOT EXISTS `sky_phone_radio_profiles` (
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`identifier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
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',
|
||||
`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
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_videos` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `profile_id` BIGINT UNSIGNED NOT NULL, `media_id` BIGINT UNSIGNED NOT NULL,
|
||||
`caption` VARCHAR(500) NOT NULL DEFAULT '', `location` VARCHAR(80) NOT NULL DEFAULT '',
|
||||
`visibility` ENUM('public','followers','private') NOT NULL DEFAULT 'public', `comments_enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`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 '',
|
||||
`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,
|
||||
PRIMARY KEY (`id`), KEY `idx_sky_phone_fliptok_feed` (`status`,`visibility`,`created_at`), KEY `idx_sky_phone_fliptok_profile` (`profile_id`,`created_at`),
|
||||
FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_reactions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `video_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`profile_id` BIGINT UNSIGNED NOT NULL, `kind` ENUM('like','save') NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_fliptok_reaction` (`video_id`,`profile_id`,`kind`),
|
||||
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
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_follows` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `follower_id` BIGINT UNSIGNED NOT NULL, `following_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_sky_phone_fliptok_follow` (`follower_id`,`following_id`),
|
||||
FOREIGN KEY (`follower_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`following_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
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',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_sky_phone_fliptok_comments` (`video_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
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_notifications` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `recipient_id` BIGINT UNSIGNED NOT NULL, `actor_id` BIGINT UNSIGNED NOT NULL,
|
||||
`video_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, `kind` ENUM('like','comment','follow','verified') NOT NULL,
|
||||
`read_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`),
|
||||
KEY `idx_sky_phone_fliptok_activity` (`recipient_id`,`created_at`),
|
||||
FOREIGN KEY (`recipient_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`actor_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`video_id`) REFERENCES `sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_reports` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `reporter_id` BIGINT UNSIGNED NOT NULL,
|
||||
`video_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `reason` ENUM('spam','harassment','dangerous','illegal','other') NOT NULL,
|
||||
`details` VARCHAR(500) NOT NULL DEFAULT '', `status` ENUM('open','reviewed','dismissed') NOT NULL DEFAULT 'open',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_fliptok_report` (`reporter_id`,`video_id`),
|
||||
FOREIGN KEY (`reporter_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`video_id`) REFERENCES `sky_phone_fliptok_videos` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_fliptok_blocks` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `blocker_id` BIGINT UNSIGNED NOT NULL, `blocked_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_sky_phone_fliptok_block` (`blocker_id`,`blocked_id`),
|
||||
FOREIGN KEY (`blocker_id`) REFERENCES `sky_phone_fliptok_profiles` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`blocked_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