MERGE - resolve dev integration conflicts

This commit is contained in:
Dominik
2026-08-09 05:36:10 +02:00
54 changed files with 8188 additions and 307 deletions
+37
View File
@@ -1,5 +1,42 @@
# 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" },
}
```
## FlipTok accounts
FlipTok profiles use their own username and password login. Registration requires a linked iFruit
account once so an existing creator profile, videos, followers, and verification can be claimed
without data loss. Login sessions are stored per phone IMEI and survive resource or server restarts;
signing out removes only that device session.
Set a private, stable password pepper in `server.cfg` before players register. Changing it later
invalidates every existing FlipTok password:
```cfg
set sky_phone_fliptok_password_pepper "replace-with-a-long-random-secret"
```
Passwords are stored as salted hashes. The pepper is read server-side from the convar and is never
included in the NUI bundle.
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.
+178 -6
View File
@@ -14,6 +14,7 @@ import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
import PhonePasscode from '@/components/PhonePasscode.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
@@ -30,6 +31,7 @@ import { useMailStore } from '@/stores/mail'
import { useMessagesStore } from '@/stores/messages'
import { useDarkChatStore } from '@/stores/darkchat'
import { useFlareStore } from '@/stores/flare'
import { useFlipTokStore } from '@/stores/fliptok'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppStoreStore } from '@/stores/app-store'
@@ -60,6 +62,8 @@ type AppMessage = {
| MessagesEventData
| DarkChatEventData
| FlareEventData
| FlipTokVerificationData
| FlipTokNotificationData
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
@@ -124,6 +128,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
@@ -140,6 +158,7 @@ const mail = useMailStore()
const messages = useMessagesStore()
const darkchat = useDarkChatStore()
const flare = useFlareStore()
const fliptok = useFlipTokStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
const appStore = useAppStoreStore()
@@ -155,6 +174,13 @@ const appTransitionName = computed(() =>
)
const isLocked = ref(false)
const isUnlocking = ref(false)
const passcodeBusy = ref(false)
const passcodeError = ref('')
const passcodeResetKey = ref(0)
const passcodeRetrySeconds = ref(0)
const passcodeVisible = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
const simPicker = ref<SimPickerPayload | null>(null)
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
@@ -180,6 +206,7 @@ const phoneFrameImage = computed(
)
let clockTicker: ReturnType<typeof setInterval> | undefined
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
function getViewportScale(): number {
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
@@ -197,12 +224,17 @@ function hydratePhone(payload: PhoneOpenPayload): void {
media.hydrate(payload.device?.data.media?.payload)
appStore.hydrate(payload.device?.data.apps?.payload)
widgets.hydrate(payload.device?.data.widgets?.payload)
void mail.bootstrap(payload.account?.email ?? '')
if (payload.account?.email) void marketplace.loadCounts()
}
function loadUnlockedPhoneData(): void {
if (unlockedServicesLoaded.value) return
unlockedServicesLoaded.value = true
void mail.bootstrap(account.email)
if (account.email) void marketplace.loadCounts()
else marketplace.setCounts({ active: 0, unread: 0 })
void calls.bootstrap()
void messages.loadConversations()
if (payload.account?.email) void darkchat.bootstrap()
if (account.email) void darkchat.bootstrap()
}
async function hydrateDevelopmentPhone(): Promise<void> {
@@ -274,6 +306,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
@@ -442,8 +500,11 @@ function onMessage(event: MessageEvent<AppMessage>): void {
) {
calls.applyCallState(event.data.data as PhoneCall)
controlCenterOpened.value = false
isLocked.value = false
isUnlocking.value = false
if (!phone.security.enabled) {
isLocked.value = false
isUnlocking.value = false
loadUnlockedPhoneData()
}
window.setTimeout(() => void router.push('/apps/phone'), 0)
} else if (event.data?.type === 'sim:picker' && event.data.data) {
simPicker.value = event.data.data as unknown as SimPickerPayload
@@ -470,14 +531,78 @@ function updateViewportScale(): void {
viewportScale.value = getViewportScale()
}
function unlockPhone(): void {
function finishUnlock(): void {
if (!isLocked.value) return
isUnlocking.value = true
isLocked.value = false
passcodeVisible.value = false
passcodeError.value = ''
unlockTimer = window.setTimeout(() => {
isUnlocking.value = false
}, 720)
if (pendingUnlockRoute.value) {
const routePath = pendingUnlockRoute.value
pendingUnlockRoute.value = null
window.setTimeout(() => void router.push(routePath), 0)
}
loadUnlockedPhoneData()
}
function unlockPhone(): void {
if (!isLocked.value) return
if (phone.security.enabled) {
passcodeError.value = ''
passcodeVisible.value = true
return
}
finishUnlock()
}
function cancelPasscode(): void {
if (passcodeBusy.value) return
passcodeVisible.value = false
passcodeError.value = ''
pendingUnlockRoute.value = null
}
function startPasscodeLock(seconds: number): void {
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
passcodeRetrySeconds.value = Math.max(1, Math.ceil(seconds))
passcodeLockTimer = window.setInterval(() => {
passcodeRetrySeconds.value = Math.max(0, passcodeRetrySeconds.value - 1)
if (passcodeRetrySeconds.value === 0 && passcodeLockTimer !== undefined) {
window.clearInterval(passcodeLockTimer)
passcodeLockTimer = undefined
passcodeError.value = ''
}
}, 1000)
}
async function submitUnlockPasscode(passcode: string): Promise<void> {
if (passcodeBusy.value || passcodeRetrySeconds.value > 0) return
passcodeBusy.value = true
const response = await phone.unlockWithPasscode(passcode)
passcodeBusy.value = false
if (response.success) {
finishUnlock()
return
}
passcodeResetKey.value += 1
if (response.error === 'passcode_locked') {
startPasscodeLock(response.data?.retryAfter ?? 30)
passcodeError.value = phone.t('LockScreen.passcode.locked', {
seconds: String(response.data?.retryAfter ?? 30),
})
return
}
if (response.error === 'rate_limited') {
passcodeError.value = phone.t('LockScreen.passcode.rateLimited')
return
}
passcodeError.value = phone.t('LockScreen.passcode.incorrect')
}
function toggleControlCenter(): void {
@@ -486,6 +611,11 @@ function toggleControlCenter(): void {
}
function unlockCamera(): void {
if (phone.security.enabled) {
pendingUnlockRoute.value = '/apps/camera'
unlockPhone()
return
}
unlockPhone()
window.setTimeout(() => void router.push('/apps/camera'), 0)
}
@@ -574,12 +704,33 @@ watch(
controlCenterOpened.value = false
isLocked.value = false
isUnlocking.value = false
passcodeVisible.value = false
passcodeBusy.value = false
passcodeError.value = ''
pendingUnlockRoute.value = null
unlockedServicesLoaded.value = false
if (passcodeLockTimer !== undefined) {
window.clearInterval(passcodeLockTimer)
passcodeLockTimer = undefined
}
return
}
isLocked.value = true
unlockedServicesLoaded.value = false
controlCenterOpened.value = false
weather.start()
isUnlocking.value = false
passcodeVisible.value = false
passcodeBusy.value = false
passcodeError.value = ''
passcodeResetKey.value += 1
passcodeRetrySeconds.value = Math.max(
0,
(phone.security.lockedUntil ?? 0) - Math.floor(Date.now() / 1000),
)
if (passcodeRetrySeconds.value > 0) {
startPasscodeLock(passcodeRetrySeconds.value)
}
phone.setLaunchOrigin(null)
void router.replace('/')
},
@@ -596,6 +747,7 @@ onBeforeUnmount(() => {
weather.stop()
if (clockTicker) clearInterval(clockTicker)
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale)
@@ -687,6 +839,26 @@ onBeforeUnmount(() => {
@unlock="unlockPhone"
/>
</Transition>
<Transition name="lock-screen">
<PhonePasscode
v-if="isLocked && passcodeVisible"
:busy="passcodeBusy"
:disabled="passcodeRetrySeconds > 0"
:error="passcodeError"
:length="phone.security.length ?? 6"
:reset-key="passcodeResetKey"
:subtitle="
passcodeRetrySeconds > 0
? phone.t('LockScreen.passcode.tryAgain', {
seconds: String(passcodeRetrySeconds),
})
: phone.t('LockScreen.passcode.unlockSubtitle')
"
:title="phone.t('LockScreen.passcode.enter')"
@cancel="cancelPasscode"
@complete="submitUnlockPasscode"
/>
</Transition>
<PhoneNotifications
:notification="notifications.current"
@close="notifications.dismissCurrent()"
@@ -1,16 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg" x1="16" y1="8" x2="112" y2="120" gradientUnits="userSpaceOnUse">
<stop stop-color="#3b82f6"/>
<stop offset="0.52" stop-color="#1857d9"/>
<stop offset="1" stop-color="#081b55"/>
</linearGradient>
<linearGradient id="shine" x1="26" y1="18" x2="99" y2="106" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity=".95"/>
<stop offset="1" stop-color="#cfe1ff" stop-opacity=".82"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="url(#bg)"/>
<circle cx="103" cy="22" r="35" fill="#6aa4ff" opacity=".2"/>
<path d="M26 52 64 29l38 23v8H26v-8Zm7 15h10v25H33V67Zm21 0h10v25H54V67Zm21 0h10v25H75V67Zm21 0h-1v25h-9V67h10ZM25 98h78v10H25V98Z" fill="url(#shine)"/>
</svg>

Before

Width:  |  Height:  |  Size: 846 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="g" x1="18" y1="12" x2="108" y2="116" gradientUnits="userSpaceOnUse">
<stop stop-color="#9f7aea"/>
<stop offset="1" stop-color="#5b21b6"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="#050507"/>
<path d="M24 30c0-8 6-14 14-14h52c8 0 14 6 14 14v42c0 8-6 14-14 14H65L43 105c-3 3-8 1-8-4V86c-6-2-11-7-11-14V30Z" fill="url(#g)"/>
<path d="M49 51a15 15 0 0 1 30 0v5h3c3 0 5 2 5 5v14c0 3-2 5-5 5H46c-3 0-5-2-5-5V61c0-3 2-5 5-5h3v-5Zm8 5h14v-5a7 7 0 0 0-14 0v5Z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 622 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Garage">
<defs>
<linearGradient id="garage-bg" x1="18" y1="8" x2="112" y2="122" gradientUnits="userSpaceOnUse">
<stop stop-color="#62ddff"/>
<stop offset="0.5" stop-color="#287cff"/>
<stop offset="1" stop-color="#1643aa"/>
</linearGradient>
<linearGradient id="garage-car" x1="39" y1="53" x2="92" y2="94" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff"/>
<stop offset="1" stop-color="#dbeaff"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="url(#garage-bg)"/>
<path d="M25 56 64 29l39 27v44a5 5 0 0 1-5 5H30a5 5 0 0 1-5-5V56Z" fill="#fff" fill-opacity=".22" stroke="#fff" stroke-width="5" stroke-linejoin="round"/>
<path d="M37 68h54v37H37V68Z" fill="#1759c8" fill-opacity=".72"/>
<path d="m45 82 5-13a6 6 0 0 1 6-4h17a6 6 0 0 1 6 4l5 13 5 5v9a4 4 0 0 1-4 4h-3a4 4 0 0 1-4-4H51a4 4 0 0 1-4 4h-3a4 4 0 0 1-4-4v-9l5-5Z" fill="url(#garage-car)"/>
<path d="M49 81h30l-4-10a3 3 0 0 0-3-2H56a3 3 0 0 0-3 2l-4 10Z" fill="#4f9cff"/>
<circle cx="50" cy="90" r="4" fill="#287cff"/>
<circle cx="78" cy="90" r="4" fill="#287cff"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Settings">
<defs>
<linearGradient id="settings-bg" x1="18" y1="8" x2="111" y2="121" gradientUnits="userSpaceOnUse">
<stop stop-color="#c8ccd3"/>
<stop offset="0.48" stop-color="#858b96"/>
<stop offset="1" stop-color="#505660"/>
</linearGradient>
<linearGradient id="settings-gear" x1="38" y1="31" x2="89" y2="98" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff"/>
<stop offset="1" stop-color="#e7e9ee"/>
</linearGradient>
<filter id="settings-shadow" x="15" y="15" width="98" height="98" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#20242b" flood-opacity=".32"/>
</filter>
</defs>
<rect width="128" height="128" rx="29" fill="url(#settings-bg)"/>
<path d="M12 34C30 17 52 10 80 13c18 2 30 9 38 18v-2C118 13 105 0 89 0H29C13 0 0 13 0 29v25c2-7 6-14 12-20Z" fill="#ffffff" fill-opacity=".18"/>
<g fill="url(#settings-gear)" filter="url(#settings-shadow)">
<rect x="57" y="20" width="14" height="27" rx="6"/>
<rect x="57" y="81" width="14" height="27" rx="6"/>
<rect x="81" y="57" width="27" height="14" rx="6"/>
<rect x="20" y="57" width="27" height="14" rx="6"/>
<rect x="76" y="25" width="14" height="29" rx="6" transform="rotate(45 83 39.5)"/>
<rect x="38" y="74" width="14" height="29" rx="6" transform="rotate(45 45 88.5)"/>
<rect x="74" y="76" width="29" height="14" rx="6" transform="rotate(45 88.5 83)"/>
<rect x="25" y="38" width="29" height="14" rx="6" transform="rotate(45 39.5 45)"/>
<circle cx="64" cy="64" r="34"/>
</g>
<circle cx="64" cy="64" r="15" fill="#626873"/>
<circle cx="64" cy="64" r="11" fill="#747a85" stroke="#ffffff" stroke-opacity=".35" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+25 -47
View File
@@ -4153,29 +4153,23 @@ button {
align-items: center;
gap: 9px;
}
.messages-inbox-toolbar label {
height: 40px;
padding: 0 12px;
display: flex;
flex: 1;
align-items: center;
gap: 7px;
border: 1px solid #ededf0;
border-radius: 22px;
background: rgb(255 255 255 / 94%);
box-shadow: 0 7px 25px rgb(0 0 0 / 8%);
color: #9b9ba0;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
.messages-inbox-toolbar input {
.messages-inbox-search {
position: relative;
min-width: 0;
height: 40px;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: #111;
font-size: 13px;
}
.messages-inbox-search__voice {
position: absolute;
z-index: 45;
top: 50%;
right: 8px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: currentColor;
transform: translateY(-50%);
}
.messages-inbox-toolbar > button {
width: 40px;
@@ -4380,11 +4374,9 @@ button {
}
.messages-chat-header__name strong {
min-width: 0;
overflow: hidden;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.messages-chat-header__name svg {
@@ -5166,7 +5158,6 @@ button {
backdrop-filter: blur(22px) saturate(175%);
-webkit-backdrop-filter: blur(22px) saturate(175%);
}
.messages-inbox-toolbar label,
.messages-edit-toolbar,
.messages-messagebar,
.messages-chat-header__name {
@@ -5533,7 +5524,6 @@ button {
.phone-app.dark .messages-contact-details__fields span {
color: #98989f;
}
.phone-app.dark .messages-inbox-toolbar label,
.phone-app.dark .messages-edit-toolbar,
.phone-app.dark .messages-messagebar,
.phone-app.dark .messages-chat-header__name {
@@ -5543,7 +5533,6 @@ button {
inset 0 1px 0 rgb(255 255 255 / 10%),
0 8px 26px rgb(0 0 0 / 32%);
}
.phone-app.dark .messages-inbox-toolbar input,
.phone-app.dark .messages-messagebar textarea,
.phone-app.dark .messages-gif-search input,
.phone-app.dark .messages-full-emoji-picker__search input {
@@ -5636,6 +5625,11 @@ button {
}
.messages-thread-page .messages-chat-header__contact {
top: 4px;
left: 50%;
width: 280px;
min-width: 280px;
margin-left: -140px;
transform: none;
}
.messages-thread-page .messages-avatar--header {
width: 64px;
@@ -5644,7 +5638,8 @@ button {
}
.messages-thread-page .messages-chat-header__name {
min-width: 0;
max-width: 230px;
width: max-content;
max-width: calc(100% - 8px);
height: 32px;
padding: 0 11px;
justify-content: center;
@@ -6047,29 +6042,13 @@ button {
height: 50px;
gap: 10px;
}
.messages-inbox-page .messages-inbox-toolbar label {
.messages-inbox-page .messages-inbox-search {
height: 48px;
padding: 0 14px;
border-color: var(--messages-inbox-control-border);
border-radius: 25px;
background: var(--messages-inbox-control);
box-shadow: var(--messages-inbox-control-shadow);
color: var(--messages-inbox-secondary);
}
.messages-inbox-page .messages-inbox-toolbar label > svg:first-child {
width: 22px;
height: 22px;
.messages-inbox-page .messages-inbox-search__voice {
color: var(--messages-inbox-primary);
}
.messages-inbox-page .messages-inbox-toolbar label > svg:last-child {
width: 21px;
height: 21px;
color: var(--messages-inbox-primary);
}
.messages-inbox-page .messages-inbox-toolbar input {
color: var(--messages-inbox-primary);
font-size: 16px;
}
.messages-inbox-page .messages-inbox-toolbar > button {
width: 48px;
height: 48px;
@@ -6100,8 +6079,7 @@ button {
--messages-inbox-secondary: #8e8e93;
--messages-inbox-separator: #2c2c2e;
}
.phone-app.dark .messages-inbox-page .messages-inbox-header,
.phone-app.dark .messages-inbox-page .messages-inbox-toolbar label {
.phone-app.dark .messages-inbox-page .messages-inbox-header {
background: var(--messages-inbox-control);
box-shadow: none;
}
+40 -5
View File
@@ -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') {
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { Delete } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { usePhoneStore } from '@/stores/phone'
const props = withDefaults(
defineProps<{
busy?: boolean
cancelable?: boolean
disabled?: boolean
error?: string
length: 4 | 6
resetKey?: number
subtitle?: string
title: string
}>(),
{
busy: false,
cancelable: true,
disabled: false,
error: '',
resetKey: 0,
subtitle: '',
},
)
const emit = defineEmits<{
cancel: []
complete: [passcode: string]
}>()
const phone = usePhoneStore()
const digits = ref('')
const keypad = [1, 2, 3, 4, 5, 6, 7, 8, 9]
const inputDisabled = computed(() => props.busy || props.disabled)
function enterDigit(digit: number): void {
if (inputDisabled.value || digits.value.length >= props.length) return
digits.value += String(digit)
if (digits.value.length === props.length) emit('complete', digits.value)
}
function removeDigit(): void {
if (inputDisabled.value) return
digits.value = digits.value.slice(0, -1)
}
watch(
() => props.resetKey,
() => {
digits.value = ''
},
)
</script>
<template>
<section class="passcode-screen" :aria-label="title">
<header class="passcode-screen__header">
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
<div class="passcode-screen__dots" aria-hidden="true">
<span
v-for="index in length"
:key="index"
:class="{ 'passcode-screen__dot--filled': digits.length >= index }"
></span>
</div>
<p
v-if="error"
class="passcode-screen__error"
role="alert"
>
{{ error }}
</p>
</header>
<div class="passcode-screen__keypad">
<button
v-for="digit in keypad"
:key="digit"
type="button"
:disabled="inputDisabled"
@click="enterDigit(digit)"
>
{{ digit }}
</button>
<button
type="button"
class="passcode-screen__action"
:disabled="!cancelable || busy"
@click="emit('cancel')"
>
{{ cancelable ? phone.t('LockScreen.passcode.cancel') : '' }}
</button>
<button type="button" :disabled="inputDisabled" @click="enterDigit(0)">
0
</button>
<button
type="button"
class="passcode-screen__action"
:aria-label="phone.t('LockScreen.passcode.delete')"
:disabled="inputDisabled || digits.length === 0"
@click="removeDigit"
>
<Delete :size="25" :stroke-width="1.7" aria-hidden="true" />
</button>
</div>
</section>
</template>
<style scoped>
.passcode-screen {
position: absolute;
inset: 0;
z-index: 90;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 76px 28px 30px;
color: white;
background:
radial-gradient(circle at 50% 16%, rgb(76 92 132 / 46%), transparent 35%),
linear-gradient(160deg, #182139, #080b12 72%);
user-select: none;
}
.passcode-screen__header {
min-height: 170px;
text-align: center;
}
.passcode-screen__header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
}
.passcode-screen__header p {
max-width: 270px;
margin: 8px auto 0;
color: rgb(255 255 255 / 72%);
font-size: 13px;
line-height: 1.35;
}
.passcode-screen__dots {
display: flex;
justify-content: center;
gap: 14px;
margin-top: 28px;
}
.passcode-screen__dots span {
width: 12px;
height: 12px;
border: 1.5px solid rgb(255 255 255 / 78%);
border-radius: 50%;
transition: background-color 120ms ease, transform 120ms ease;
}
.passcode-screen__dots .passcode-screen__dot--filled {
background: white;
transform: scale(1.06);
}
.passcode-screen__header .passcode-screen__error {
color: #ff9b93;
font-weight: 500;
}
.passcode-screen__keypad {
display: grid;
grid-template-columns: repeat(3, 72px);
gap: 15px 20px;
align-items: center;
justify-items: center;
}
.passcode-screen__keypad button:not(.passcode-screen__action) {
width: 72px;
height: 72px;
border: 0;
border-radius: 50%;
color: white;
background: rgb(255 255 255 / 16%);
font-size: 30px;
font-weight: 400;
backdrop-filter: blur(18px);
transition: background-color 100ms ease, transform 100ms ease;
}
.passcode-screen__keypad button:not(.passcode-screen__action):active {
background: rgb(255 255 255 / 34%);
transform: scale(0.96);
}
.passcode-screen__keypad button:disabled {
opacity: 0.45;
}
.passcode-screen__keypad .passcode-screen__action {
display: flex;
align-items: center;
justify-content: center;
min-width: 72px;
min-height: 48px;
border: 0;
color: white;
background: transparent;
font-size: 14px;
}
</style>
+161 -35
View File
@@ -111,6 +111,9 @@ const forecastLow = computed(() =>
const weatherIcon = computed(
() => weatherIcons[weather.forecast.value?.condition ?? 'partly_cloudy'],
)
const visibleHourlyWeather = computed(
() => weather.forecast.value?.hourly.slice(0, 5) ?? [],
)
const balance = computed(() =>
props.instance.settings.balanceSource === 'cash'
? bank.overview.value.cash
@@ -148,6 +151,13 @@ function avatar(name: string): string {
return name.trim().charAt(0).toLocaleUpperCase(phone.lang) || '?'
}
function formatForecastHour(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
minute: '2-digit',
}).format(timestamp)
}
function clearHold(): void {
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
holdTimer = undefined
@@ -326,6 +336,13 @@ onBeforeUnmount(() => {
<small v-if="instance.size !== 'small'" class="widget-weather-range">
H: {{ forecastHigh ?? '--' }}° &nbsp; L: {{ forecastLow ?? '--' }}°
</small>
<div v-if="instance.size !== 'small'" class="widget-weather-hourly">
<div v-for="hour in visibleHourlyWeather" :key="hour.timestamp">
<time>{{ formatForecastHour(hour.timestamp) }}</time>
<component :is="weatherIcons[hour.condition]" :size="21" />
<strong>{{ hour.temperature }}°</strong>
</div>
</div>
</template>
<template v-else-if="instance.kind === 'music'">
@@ -492,30 +509,46 @@ onBeforeUnmount(() => {
.home-widget {
width: 100%;
height: 100%;
padding: 14px;
padding: 15px;
overflow: hidden;
border: 0.5px solid rgb(255 255 255 / 18%);
border-radius: 22px;
border: 0.75px solid rgb(255 255 255 / 17%);
border-radius: 25px;
outline: none;
color: #fff;
background: rgb(28 28 30 / 76%);
background: rgb(25 25 27 / 91%);
box-shadow:
0 8px 24px rgb(0 0 0 / 24%),
inset 0 0.5px rgb(255 255 255 / 18%);
0 10px 25px rgb(0 0 0 / 28%),
inset 0 0.75px rgb(255 255 255 / 15%);
backdrop-filter: blur(26px) saturate(125%);
-webkit-backdrop-filter: blur(26px) saturate(125%);
cursor: pointer;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
'Segoe UI', sans-serif;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.home-widget-shell--small .home-widget {
padding: 13px;
border-radius: 23px;
}
.home-widget-shell--large .home-widget {
padding: 18px;
border-radius: 28px;
}
.home-widget:active {
filter: brightness(1.08);
}
.home-widget small,
.widget-eyebrow {
color: rgb(255 255 255 / 68%);
color: rgb(255 255 255 / 62%);
font-size: 11px;
font-weight: 500;
line-height: 1.25;
}
@@ -528,25 +561,30 @@ onBeforeUnmount(() => {
justify-content: flex-end;
}
.home-widget--clock {
background: rgb(12 12 13 / 94%);
}
.widget-clock {
margin: 1px 0;
font-size: 31px;
font-weight: 400;
letter-spacing: -1.6px;
margin: 2px 0;
font-size: 28px;
font-weight: 500;
letter-spacing: -1.8px;
line-height: 1;
white-space: nowrap;
}
.home-widget-shell--medium .widget-clock {
font-size: 45px;
font-size: 48px;
}
.home-widget--date {
background: rgb(247 247 247 / 88%);
color: #111;
background: rgb(24 24 26 / 94%);
color: #fff;
}
.home-widget--date small {
color: #6e6e73;
color: rgb(255 255 255 / 58%);
}
.widget-date-month {
@@ -557,8 +595,8 @@ onBeforeUnmount(() => {
}
.widget-date-day {
font-size: 48px;
font-weight: 300;
font-size: 52px;
font-weight: 400;
letter-spacing: -2px;
line-height: 0.95;
}
@@ -567,7 +605,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
justify-content: space-between;
background: rgb(25 69 112 / 82%);
background: rgb(34 42 78 / 95%);
}
.widget-weather-top {
@@ -582,14 +620,72 @@ onBeforeUnmount(() => {
}
.widget-weather-top strong {
font-size: 34px;
font-weight: 300;
font-size: 38px;
font-weight: 400;
letter-spacing: -1.5px;
line-height: 1;
}
.widget-weather-range {
margin-top: 4px;
margin-top: 3px;
color: rgb(255 255 255 / 86%) !important;
font-weight: 600 !important;
}
.widget-weather-hourly {
display: grid;
margin-top: 12px;
padding-top: 11px;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-top: 0.5px solid rgb(255 255 255 / 18%);
}
.widget-weather-hourly > div {
display: grid;
min-width: 0;
justify-items: center;
gap: 6px;
}
.widget-weather-hourly time {
color: rgb(255 255 255 / 58%);
font-size: 9px;
font-weight: 600;
}
.widget-weather-hourly strong {
font-size: 12px;
font-weight: 650;
}
.home-widget-shell--medium .home-widget--weather {
padding: 13px 15px;
}
.home-widget-shell--medium .widget-weather-top strong {
font-size: 32px;
}
.home-widget-shell--medium .widget-weather-hourly {
margin-top: 7px;
padding-top: 7px;
}
.home-widget-shell--medium .widget-weather-hourly > div {
gap: 3px;
}
.home-widget-shell--medium .widget-weather-hourly svg {
width: 18px;
height: 18px;
}
.home-widget-shell--medium .widget-weather-hourly time {
font-size: 8px;
}
.home-widget-shell--medium .widget-weather-hourly strong {
font-size: 11px;
}
.home-widget--music {
@@ -597,6 +693,7 @@ onBeforeUnmount(() => {
align-items: center;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
background: rgb(25 25 28 / 94%);
}
.home-widget-shell--large .home-widget--music {
@@ -610,10 +707,12 @@ onBeforeUnmount(() => {
width: 58px;
height: 58px;
place-items: center;
border-radius: 13px;
border-radius: 14px;
color: #fff;
background: #b33a3a;
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 22%);
background: #5653b8;
box-shadow:
inset 0 0 0 0.5px rgb(255 255 255 / 25%),
0 5px 13px rgb(0 0 0 / 24%);
font-size: 13px;
font-weight: 800;
letter-spacing: 1.5px;
@@ -640,6 +739,10 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.widget-music-copy strong {
font-size: 13px;
}
.widget-music-controls {
display: flex;
align-items: center;
@@ -655,7 +758,11 @@ onBeforeUnmount(() => {
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(255 255 255 / 13%);
background: rgb(255 255 255 / 11%);
}
.home-widget--wallet {
background: rgb(25 26 29 / 94%);
}
.widget-wallet-icon {
@@ -667,14 +774,14 @@ onBeforeUnmount(() => {
height: 36px;
place-items: center;
border-radius: 11px;
color: #0a84ff;
background: rgb(10 132 255 / 15%);
color: #64d2ff;
background: rgb(100 210 255 / 13%);
}
.widget-balance {
margin: 2px 0;
font-size: 28px;
font-weight: 600;
font-size: 30px;
font-weight: 650;
letter-spacing: -1.2px;
}
@@ -684,14 +791,26 @@ onBeforeUnmount(() => {
flex-direction: column;
}
.home-widget--transactions {
background: rgb(29 29 31 / 94%);
}
.home-widget--contacts {
background: rgb(25 26 29 / 94%);
}
.widget-list-header {
display: flex;
margin-bottom: 7px;
align-items: center;
justify-content: space-between;
color: rgb(255 255 255 / 72%);
font-size: 12px;
font-weight: 600;
color: #64d2ff;
font-size: 13px;
font-weight: 700;
}
.home-widget--transactions .widget-list-header {
color: #30d158;
}
.widget-transaction {
@@ -701,7 +820,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: space-between;
border: 0;
border-top: 0.5px solid rgb(255 255 255 / 12%);
border-top: 0.5px solid rgb(255 255 255 / 11%);
color: #fff;
background: transparent;
text-align: left;
@@ -715,6 +834,12 @@ onBeforeUnmount(() => {
.widget-transaction strong {
font-size: 12px;
font-weight: 650;
}
.widget-transaction small {
margin-top: 1px;
font-size: 9px;
}
.widget-transaction b {
@@ -756,6 +881,7 @@ onBeforeUnmount(() => {
border-radius: 50%;
color: #fff;
background: #5e5ce6;
box-shadow: inset 0 1px rgb(255 255 255 / 20%);
font-size: 16px;
font-weight: 650;
}
@@ -784,8 +910,8 @@ onBeforeUnmount(() => {
.widget-contacts button {
width: 25px;
height: 25px;
color: #0a84ff;
background: rgb(10 132 255 / 14%);
color: #64d2ff;
background: rgb(100 210 255 / 12%);
}
.home-widget-remove {
@@ -164,6 +164,8 @@ watch(
:key="contact.id"
link
link-component="button"
content-class="w-full"
:chevron="false"
:title="contact.name"
:subtitle="contact.phone_number"
@click="toggleContact(contact.id)"
@@ -183,6 +183,8 @@ watch(
:key="definition.kind"
link
link-component="button"
content-class="w-full"
:chevron="false"
:title="phone.t(definition.labelKey)"
:subtitle="phone.t(definition.descriptionKey)"
@click="selectWidget(definition)"
+9 -1
View File
@@ -118,7 +118,15 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['flare', 'local-pages', 'phone', 'darkchat', 'banking', 'mail'])
).toEqual([
'fliptok',
'flare',
'local-pages',
'phone',
'darkchat',
'banking',
'mail',
])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+19 -4
View File
@@ -36,11 +36,11 @@ import calendarIcon from '@/assets/img/app-icons/calendar.svg'
import mailIcon from '@/assets/img/app-icons/mail.webp'
import mapIcon from '@/assets/img/app-icons/map.webp'
import messagesIcon from '@/assets/img/app-icons/sms.webp'
import darkChatIcon from '@/assets/img/app-icons/darkchat.svg'
import darkChatIcon from '@/assets/img/app-icons/darkchat.webp'
import notesIcon from '@/assets/img/app-icons/notes.webp'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
import phoneIcon from '@/assets/img/app-icons/phone.webp'
import settingsIcon from '@/assets/img/app-icons/settings.webp'
import settingsIcon from '@/assets/img/app-icons/settings.svg'
import snakeIcon from '@/assets/img/app-icons/snake.webp'
import memoryIcon from '@/assets/img/app-icons/memory.webp'
import numberMergeIcon from '@/assets/img/app-icons/number-merge.webp'
@@ -49,11 +49,12 @@ import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import bankingIcon from '@/assets/img/app-icons/banking.svg'
import garageIcon from '@/assets/img/app-icons/garage.svg'
import bankingIcon from '@/assets/img/app-icons/banking.webp'
import garageIcon from '@/assets/img/app-icons/garage.webp'
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import flareIcon from '@/assets/img/app-icons/flare.svg'
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
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: 'social',
component: markRaw(
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import {
defaultMapPercentToWorld,
defaultMapWorldToPercent,
} from '@/features/map/defaultMapGeometry'
describe('default map geometry', () => {
it('round-trips world coordinates through map percentages', () => {
const world = { x: -75.2, y: -818.9 }
const restored = defaultMapPercentToWorld(defaultMapWorldToPercent(world))
expect(restored.x).toBeCloseTo(world.x, 6)
expect(restored.y).toBeCloseTo(world.y, 6)
})
})
@@ -77,3 +77,11 @@ export const defaultMapWorldToPercent = (point: MapPoint): MapPoint => ({
(defaultMapCoordinates.yFlipOffset - point.y - defaultMapCoordinates.minY) /
defaultMapCoordinates.height,
})
export const defaultMapPercentToWorld = (point: MapPoint): MapPoint => ({
x: defaultMapCoordinates.minX + point.x * defaultMapCoordinates.width,
y:
defaultMapCoordinates.yFlipOffset -
defaultMapCoordinates.minY -
point.y * defaultMapCoordinates.height,
})
+1
View File
@@ -49,6 +49,7 @@ export function useClockService() {
time: computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hour12: false,
minute: '2-digit',
}).format(now.value),
),
+186
View File
@@ -0,0 +1,186 @@
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()
})
it('keeps the app signed out when bootstrap has no FlipTok session', async () => {
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: { authenticated: false, musicTracks: [] },
})
const store = useFlipTokStore()
expect(await store.bootstrap()).toBe(true)
expect(store.authenticated).toBe(false)
expect(store.profile).toBeNull()
expect(store.feed).toEqual([])
})
it('loads the profile after a successful login', async () => {
vi.mocked(nuiCall)
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({
success: true,
data: {
authenticated: true,
feed: { hasMore: false, items: [{ ...video }], offset: 0 },
isAdmin: false,
musicTracks: [],
profile: { ...profile },
},
})
const store = useFlipTokStore()
expect((await store.login('nova', 'password123')).success).toBe(true)
expect(store.authenticated).toBe(true)
expect(store.profile?.handle).toBe('nova')
expect(nuiCall).toHaveBeenNthCalledWith(1, 'fliptok:login', {
handle: 'nova',
password: 'password123',
})
})
it('clears the complete local session after logout', async () => {
vi.mocked(nuiCall).mockResolvedValue({ success: true })
const store = useFlipTokStore()
store.authenticated = true
store.profile = { ...profile }
store.feed = [{ ...video }]
store.activities = [{ ...activity }]
expect((await store.logout()).success).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('fliptok:logout')
expect(store.authenticated).toBe(false)
expect(store.profile).toBeNull()
expect(store.feed).toEqual([])
expect(store.activities).toEqual([])
})
})
+235
View File
@@ -0,0 +1,235 @@
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[],
authenticated: false,
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<{
authenticated: boolean
feed?: FlipTokPage
isAdmin?: boolean
musicTracks: FlipTokMusicTrack[]
profile?: FlipTokProfile
}>('fliptok:bootstrap')
this.loading = false
if (!response.success || !response.data) return false
this.authenticated = response.data.authenticated === true
this.profile = response.data.profile ?? null
this.feed = response.data.feed?.items ?? []
this.isAdmin = response.data.isAdmin === true
this.musicTracks = response.data.musicTracks ?? []
return true
},
async login(handle: string, password: string): Promise<NuiResponse> {
const response = await nuiCall('fliptok:login', { handle, password })
if (response.success) await this.bootstrap()
return response
},
async register(
displayName: string,
handle: string,
password: string,
): Promise<NuiResponse> {
const response = await nuiCall('fliptok:register', {
displayName,
handle,
password,
})
if (response.success) await this.bootstrap()
return response
},
async logout(): Promise<NuiResponse> {
const response = await nuiCall('fliptok:logout')
if (!response.success) return response
this.$reset()
return response
},
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
},
},
})
+60
View File
@@ -0,0 +1,60 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMapStore } from '@/stores/map'
import type { MapMarker } from '@/types/map'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const marker: MapMarker = {
color: 'blue',
coords: { x: -75.2, y: -818.9, z: 0 },
id: 'marker-1',
label: 'Meeting point',
}
describe('map store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads persistent markers', async () => {
mockNuiCall.mockResolvedValueOnce({ data: [marker], success: true })
const map = useMapStore()
expect(await map.load()).toBe(true)
expect(map.markers).toEqual([marker])
expect(mockNuiCall).toHaveBeenCalledWith('map:markers')
})
it('adds a marker returned by the server', async () => {
mockNuiCall.mockResolvedValueOnce({ data: marker, success: true })
const map = useMapStore()
const input = {
color: marker.color,
coords: marker.coords,
label: marker.label,
}
expect((await map.create(input)).success).toBe(true)
expect(map.markers).toEqual([marker])
expect(mockNuiCall).toHaveBeenCalledWith('map:create-marker', input)
})
it('only removes a marker after server confirmation', async () => {
mockNuiCall
.mockResolvedValueOnce({ error: 'request_failed', success: false })
.mockResolvedValueOnce({ success: true })
const map = useMapStore()
map.markers = [marker]
await map.remove(marker.id)
expect(map.markers).toEqual([marker])
await map.remove(marker.id)
expect(map.markers).toEqual([])
})
})
+50
View File
@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import type { CreateMapMarker, MapMarker } from '@/types/map'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useMapStore = defineStore('map', {
state: () => ({
error: '',
isLoading: false,
markers: [] as MapMarker[],
}),
actions: {
async load(): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<MapMarker[]>('map:markers')
this.isLoading = false
if (response.success && response.data) {
this.markers = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async create(marker: CreateMapMarker): Promise<NuiResponse<MapMarker>> {
this.isLoading = true
const response = await nuiCall<MapMarker>('map:create-marker', marker)
this.isLoading = false
if (response.success && response.data) {
this.markers.push(response.data)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async remove(id: string): Promise<NuiResponse> {
this.isLoading = true
const response = await nuiCall('map:delete-marker', { id })
this.isLoading = false
if (response.success) {
this.markers = this.markers.filter((marker) => marker.id !== id)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
},
})
@@ -0,0 +1,80 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const mockNuiCall = vi.mocked(nuiCall)
describe('phone passcode store', () => {
beforeEach(() => {
vi.stubGlobal('window', {
matchMedia: vi.fn(() => ({ matches: false })),
})
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('stores the server security state after setting a six digit passcode', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
security: { enabled: true, length: 6, lockedUntil: 0 },
},
success: true,
})
const phone = usePhoneStore()
const response = await phone.setPasscode('123456')
expect(response.success).toBe(true)
expect(phone.security).toEqual({
enabled: true,
length: 6,
lockedUntil: 0,
})
expect(mockNuiCall).toHaveBeenCalledWith('security:set-passcode', {
passcode: '123456',
})
})
it('keeps the configured state after a rejected unlock attempt', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'invalid_passcode',
success: false,
})
const phone = usePhoneStore()
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
await phone.unlockWithPasscode('9999')
expect(phone.security).toEqual({
enabled: true,
length: 4,
lockedUntil: 0,
})
})
it('clears the security state after disabling the passcode', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
security: { enabled: false, length: null, lockedUntil: 0 },
},
success: true,
})
const phone = usePhoneStore()
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
await phone.disablePasscode('1234')
expect(phone.security.enabled).toBe(false)
expect(phone.security.length).toBeNull()
})
})
+302 -1
View File
@@ -1,10 +1,15 @@
import { defineStore } from 'pinia'
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
import type {
DeviceBootstrap,
DeviceSecurity,
PhoneDevice,
} from '@/types/device'
import { clampPage } from '@/utils/pages'
import { cloneJsonData } from '@/utils/clone'
import { nuiCall } from '@/utils/nui'
import type { NuiResponse } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
parsePhonePreferences,
@@ -15,12 +20,19 @@ import {
type LocaleTree = Record<string, unknown>
export type PasscodeResponseData = {
attemptsRemaining?: number
retryAfter?: number
security?: DeviceSecurity
}
export type PhoneOpenPayload = {
account?: DeviceBootstrap['account']
device?: PhoneDevice
lang?: string
locales?: LocaleTree
notes?: DeviceBootstrap['notes']
security?: DeviceSecurity
token?: string
}
@@ -179,6 +191,151 @@ const defaultLocales: LocaleTree = {
default: 'Flare could not complete the request.',
},
},
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',
authTitle: 'Your FlipTok account',
login: 'Sign In',
register: 'Register',
createAccount: 'Create Account',
logout: 'Sign Out',
loginBody:
'Sign in to continue with your videos, follows, and saved posts.',
registerBody: 'Create a private FlipTok login for this profile.',
password: 'Password',
confirmPassword: 'Confirm password',
passwordsMismatch: 'The passwords do not match.',
displayNamePlaceholder: 'Your name',
usernamePlaceholder: 'username',
passwordPlaceholder: 'At least 8 characters',
confirmPasswordPlaceholder: 'Enter password again',
registrationHint:
'An iFruit account is required once to create and own a FlipTok profile.',
accountDetails: 'Account details',
profileDetails: 'Profile details',
account: 'Account',
signOutTitle: 'Sign out of FlipTok?',
signOutBody:
'Your profile and videos stay online. This phone will return to the FlipTok sign-in screen.',
signingOut: 'Signing Out...',
accountTypes: {
person: 'Person',
business: 'Business',
organization: 'Organization',
media: 'Media',
event: 'Event',
},
activityKinds: {
like: 'liked your video',
comment: 'commented on your video',
follow: 'started following you',
verified: 'verification changed',
},
notifications: {
like: '{actor} liked your video.',
comment: '{actor} commented on your video.',
follow: '{actor} started following you.',
verified: 'Your FlipTok account is now verified.',
default: 'You have new FlipTok activity.',
},
errors: {
invalid_video: 'Check the video details.',
invalid_media: 'Choose a video from this phone.',
invalid_comment: 'Enter a valid comment.',
comments_disabled: 'Comments are disabled.',
invalid_profile: 'Check your profile details.',
invalid_handle: 'Use 324 letters, numbers, dots, or underscores.',
invalid_display_name: 'Enter a display name.',
invalid_password: 'Password must be 872 characters.',
invalid_credentials: 'Username or password is incorrect.',
already_registered:
'This iFruit account already owns a registered FlipTok profile.',
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}',
@@ -1098,6 +1255,36 @@ const defaultLocales: LocaleTree = {
currentLocation: 'Current Location',
imageError: 'The map image could not be loaded.',
switchStyle: 'Switch Map Type',
addMarker: 'Add Marker',
placeMarker: 'Place Marker',
placeMarkerHint:
'Move the map until the crosshair is over the destination.',
addHere: 'Add Here',
newMarker: 'New Marker',
newMarkerDescription: 'Give this saved place a name and color.',
markerName: 'Name',
markerNamePlaceholder: 'e.g. Meeting point',
markerColor: 'Marker Color',
saveMarker: 'Save Marker',
deleteMarker: 'Delete Marker',
setWaypoint: 'Set Waypoint',
waypointSet: 'Waypoint set.',
markerSaved: 'Marker saved.',
markerDeleted: 'Marker deleted.',
colors: {
blue: 'Blue',
green: 'Green',
orange: 'Orange',
purple: 'Purple',
red: 'Red',
},
errors: {
invalid_marker: 'Enter a valid marker name and position.',
marker_limit: 'This phone has reached its marker limit.',
marker_not_found: 'This marker no longer exists.',
rate_limited: 'Too many changes. Try again shortly.',
request_failed: 'The marker could not be saved.',
},
styles: {
default: 'Default Map',
satellite: 'Satellite Map',
@@ -1153,6 +1340,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',
@@ -1170,6 +1359,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.',
@@ -1473,7 +1664,19 @@ const defaultLocales: LocaleTree = {
notifications: 'Notifications',
sounds: 'Sounds & Haptics',
general: 'General Settings',
security: 'Passcode & Security',
appearance: 'Appearance',
connectivity: 'Connectivity',
connections: 'Connections',
wifi: 'Wi-Fi',
bluetooth: 'Bluetooth',
cellular: 'Cellular',
connectivityDescription:
'Airplane Mode temporarily disables wireless connections. Wi-Fi, Bluetooth, and cellular settings are saved on this phone.',
focus: 'Focus',
focusMode: 'Focus',
focusDescription:
'Focus silences non-critical notifications while keeping alarms and important alerts available.',
allowNotifications: 'Allow Notifications',
notificationSounds: 'Sounds',
notificationDuration: 'Notification Duration',
@@ -1488,6 +1691,8 @@ const defaultLocales: LocaleTree = {
dark: 'Dark',
phoneScale: 'Phone Scale',
phoneFrame: 'Phone Frame',
screenBrightness: 'Screen Brightness',
rotationLock: 'Rotation Lock',
about: 'About',
deviceName: 'Device Name',
deviceNameValue: 'Sky Phone',
@@ -1517,6 +1722,28 @@ const defaultLocales: LocaleTree = {
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
factoryResetProgress: 'Erasing iFruit Phone',
factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
passcode: {
description:
'A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.',
status: 'Passcode',
codeLength: 'Code Length',
sixDigit: '6-Digit Code',
fourDigit: '4-Digit Code',
turnOn: 'Turn Passcode On',
turnOff: 'Turn Passcode Off',
change: 'Change Passcode',
enterNew: 'Enter New Passcode',
confirmNew: 'Verify New Passcode',
enterCurrent: 'Enter Current Passcode',
screenSubtitle: 'Use 4 or 6 numbers.',
incorrect: 'Incorrect passcode.',
mismatch: 'The passcodes did not match.',
locked: 'Too many incorrect attempts. Try again later.',
rateLimited: 'Too many attempts. Please wait.',
failed: 'The passcode could not be updated.',
saved: 'Passcode saved.',
disabled: 'Passcode turned off.',
},
accountErrors: {
invalid_email: 'Choose a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
@@ -1532,6 +1759,11 @@ const defaultLocales: LocaleTree = {
toggle: {
airplaneMode: 'Toggle Airplane Mode',
streamerMode: 'Toggle Streamer Mode',
focusMode: 'Toggle Focus',
wifiEnabled: 'Toggle Wi-Fi',
bluetoothEnabled: 'Toggle Bluetooth',
cellularEnabled: 'Toggle Cellular Data',
rotationLocked: 'Toggle Rotation Lock',
notifications: 'Toggle notifications for {app}',
notificationSounds: 'Toggle notification sounds for {app}',
},
@@ -1604,6 +1836,7 @@ const defaultLocales: LocaleTree = {
send: 'Send',
start: 'Start',
stop: 'Stop',
use: 'Use',
},
Notifications: { now: 'now' },
LockScreen: {
@@ -1611,6 +1844,16 @@ const defaultLocales: LocaleTree = {
flashlight: 'Flashlight',
camera: 'Camera',
swipeUp: 'Swipe up to open',
passcode: {
enter: 'Enter Passcode',
unlockSubtitle: 'Enter the passcode for this phone.',
cancel: 'Cancel',
delete: 'Delete digit',
incorrect: 'Incorrect passcode',
locked: 'Too many attempts. Try again in {seconds} seconds.',
tryAgain: 'Try again in {seconds} seconds',
rateLimited: 'Too many attempts. Please wait.',
},
},
Home: {
appLibrary: 'App Library',
@@ -1726,6 +1969,11 @@ export const usePhoneStore = defineStore('phone', {
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
security: {
enabled: false,
length: null,
lockedUntil: 0,
} as DeviceSecurity,
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
}),
getters: {
@@ -1744,6 +1992,11 @@ export const usePhoneStore = defineStore('phone', {
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
this.security = payload.security ?? {
enabled: false,
length: null,
lockedUntil: 0,
}
this.isOpen = true
},
hydrateDevice(device: PhoneDevice): void {
@@ -1813,6 +2066,54 @@ export const usePhoneStore = defineStore('phone', {
this.preferences.settings.wallpaper = wallpaper
this.saveDeviceNamespace('settings', this.preferences)
},
async unlockWithPasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>('security:unlock', {
passcode,
})
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async setPasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:set-passcode',
{ passcode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async changePasscode(
currentPasscode: string,
newPasscode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:change-passcode',
{ currentPasscode, newPasscode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async disablePasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:disable-passcode',
{ passcode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
t(path: string, replacements: Record<string, string> = {}): string {
const translated = getByPath(this.locales, path)
const fallback = getByPath(defaultLocales, path)
+1
View File
@@ -27,6 +27,7 @@ export type PhoneAppId =
| 'citymarkt'
| 'local-pages'
| 'flare'
| 'fliptok'
export type LaunchablePhoneAppId = PhoneAppId
+7
View File
@@ -19,6 +19,12 @@ export type PhoneNotificationDevicePayload = {
settings?: string | null
}
export type DeviceSecurity = {
enabled: boolean
length: 4 | 6 | null
lockedUntil: number
}
export type AccountDevice = {
created_at: string
current: boolean
@@ -37,5 +43,6 @@ export type DeviceBootstrap = {
account: IfruitAccount | null
device: PhoneDevice
notes: Note[]
security: DeviceSecurity
token: string
}
+97
View File
@@ -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
}
+12
View File
@@ -0,0 +1,12 @@
import type { MapPoint } from '@/features/map/defaultMapGeometry'
export type MapMarkerColor = 'blue' | 'green' | 'orange' | 'purple' | 'red'
export type MapMarker = {
color: MapMarkerColor
coords: MapPoint & { z: number }
id: string
label: string
}
export type CreateMapMarker = Omit<MapMarker, 'id'>
+1
View File
@@ -69,6 +69,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 },
+2
View File
@@ -1004,6 +1004,7 @@ watch(isEditablePage, (visible) => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="phone.t('Home.widgetSystem.editWidget')"
@click="openWidgetConfig"
>
@@ -1012,6 +1013,7 @@ watch(isEditablePage, (visible) => {
<k-list-item
link
link-component="button"
content-class="w-full"
class="widget-action-remove"
:title="phone.t('Home.widgetSystem.removeWidget')"
@click="activeWidget && removeWidget(activeWidget.id)"
+115 -40
View File
@@ -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;
+47 -59
View File
@@ -37,7 +37,6 @@ import {
Plus,
QrCode,
Reply,
Search,
ShieldCheck,
ShieldOff,
Trash2,
@@ -89,6 +88,10 @@ const darkMessagebarColors = {
placeholderIos: 'placeholder-[#8e8e93]',
toolbarIconIos: 'fill-[#0a84ff]',
}
const darkSearchbarColors = {
inputBgIos: 'bg-[#1c1c1e]',
placeholderIos: 'placeholder-[#8e8e93]',
}
const darkSheetColors = {
bgIos: 'bg-[#111113]',
}
@@ -182,10 +185,6 @@ function inputValue(event: Event): string {
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
}
function setSearch(event: Event): void {
search.value = inputValue(event)
}
function setIdentifier(event: Event): void {
identifier.value = inputValue(event)
}
@@ -1706,20 +1705,6 @@ onBeforeUnmount(() => {
background: rgb(255 255 255 / 8%);
}
.dc-report-dialog textarea {
width: 100%;
min-height: 82px;
margin-top: 10px;
padding: 10px;
resize: none;
border: 0.5px solid var(--dc-border);
border-radius: 10px;
outline: none;
color: var(--dc-label);
font-size: 14px;
background: var(--dc-surface-raised);
}
.dc-action-sheet {
max-height: 72%;
padding: 7px 0 32px;
@@ -2023,33 +2008,10 @@ onBeforeUnmount(() => {
gap: 9px;
}
.dc-sms-inbox-toolbar label {
display: flex;
height: 40px;
.dc-sms-search {
min-width: 0;
padding: 0 12px;
margin: 0;
flex: 1;
align-items: center;
gap: 7px;
border: 0.5px solid var(--dc-border);
border-radius: 22px;
color: var(--dc-tertiary);
background: rgb(28 28 30 / 94%);
}
.dc-sms-inbox-toolbar input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
color: var(--dc-label);
font-size: 14px;
background: transparent;
}
.dc-sms-inbox-toolbar input::placeholder {
color: var(--dc-tertiary);
opacity: 1;
}
.dc-sms-inbox-toolbar > button {
@@ -2244,13 +2206,14 @@ onBeforeUnmount(() => {
</div>
</div>
<footer class="dc-sms-inbox-toolbar">
<label
><Search :size="17" /><input
:value="search"
type="search"
:placeholder="phone.t('Common.search')"
@input="setSearch" /></label
><k-glass
<k-searchbar
v-model="search"
class="dc-sms-search"
:placeholder="phone.t('Common.search')"
:colors="darkSearchbarColors"
:input-style="{ color: 'var(--dc-label)' }"
/>
<k-glass
component="button"
type="button"
:aria-label="t('newChat')"
@@ -2309,6 +2272,7 @@ onBeforeUnmount(() => {
:key="contact.id"
link
link-component="button"
content-class="w-full"
:title="contact.alias"
:subtitle="contact.darkId"
@click="requestStart(contact.darkId)"
@@ -2611,6 +2575,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('disappearing')"
@click="selectionSheet = 'disappearing'"
>
@@ -2640,6 +2605,7 @@ onBeforeUnmount(() => {
v-if="active.peer.isContact"
link
link-component="button"
content-class="w-full"
:title="t('removeContact')"
@click="removeContact"
><template #media><UserMinus :size="20" /></template
@@ -2647,6 +2613,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="active.peer.blocked ? t('unblock') : t('block')"
@click="toggleBlock"
><template #media><ShieldOff :size="20" /></template
@@ -2654,6 +2621,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('report')"
@click="beginReport()"
><template #media><BellOff :size="20" /></template
@@ -2661,6 +2629,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('clearChat')"
@click="clearChat"
><template #media><Trash2 :size="20" /></template
@@ -2716,12 +2685,18 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
:title="t('notificationPrivacy')"
content-class="w-full"
title-wrap-class="gap-2"
@click="selectionSheet = 'notification'"
>
<template #media><Bell :size="20" /></template>
<template #title>
<span class="whitespace-nowrap text-[15px]">
{{ t('notificationPrivacy') }}
</span>
</template>
<template #after
><span class="dc-setting-value">{{
><span class="dc-setting-value max-w-[106px] text-[12px]">{{
notificationOptions.find(
(option) => option.value === notificationMode,
)?.label
@@ -2797,6 +2772,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('reply')"
@click="beginReply(selectedMessage)"
><template #media><Reply :size="20" /></template
@@ -2808,6 +2784,7 @@ onBeforeUnmount(() => {
"
link
link-component="button"
content-class="w-full"
:title="t('copy')"
@click="copyMessage(selectedMessage)"
><template #media><Copy :size="20" /></template
@@ -2815,6 +2792,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('deleteForMe')"
@click="messageAction(selectedMessage, 'delete_me')"
><template #media><Trash2 :size="20" /></template
@@ -2823,6 +2801,7 @@ onBeforeUnmount(() => {
v-if="selectedMessage.direction === 'sent'"
link
link-component="button"
content-class="w-full"
class="dc-danger-row"
:title="t('deleteForBoth')"
@click="messageAction(selectedMessage, 'delete_all')"
@@ -2832,6 +2811,7 @@ onBeforeUnmount(() => {
v-if="selectedMessage.direction === 'received'"
link
link-component="button"
content-class="w-full"
class="dc-danger-row"
:title="t('report')"
@click="beginReport(selectedMessage)"
@@ -2859,6 +2839,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('reportUser')"
@click="selectionSheet = 'report'"
>
@@ -2870,12 +2851,17 @@ onBeforeUnmount(() => {
>
</k-list-item>
</k-list>
<textarea
:value="reportDetails"
maxlength="500"
:placeholder="t('reportDetails')"
@input="setReportDetails"
/>
<k-list inset strong class="dc-form-list">
<k-list-input
type="textarea"
:value="reportDetails"
maxlength="500"
:placeholder="t('reportDetails')"
:colors="darkInputColors"
input-class="text-[#f5f5f7] placeholder:text-[#8e8e93]"
@input="setReportDetails"
/>
</k-list>
<template #buttons
><k-dialog-button @click="closeReport">{{
phone.t('Common.cancel')
@@ -2900,6 +2886,8 @@ onBeforeUnmount(() => {
:key="option.value"
link
link-component="button"
content-class="w-full"
:chevron="false"
:title="option.label"
@click="chooseSelection(option.value)"
>
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -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"
+21 -9
View File
@@ -741,18 +741,29 @@ onBeforeUnmount(() => {
}
.garage-vehicle__visual > i {
position: absolute;
top: 8px;
right: 7px;
padding: 3px 6px;
top: 7px;
left: 50%;
display: inline-flex;
width: max-content;
min-width: 48px;
max-width: 70px;
height: 14px;
padding: 0 6px;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 999px;
background: rgb(20 92 48 / 78%);
color: #fff;
font-size: 10px;
font-size: 7px;
font-style: normal;
font-weight: 720;
line-height: 1;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.02em;
backdrop-filter: blur(12px);
transform: translateX(-50%);
}
.garage-vehicle__visual > i.is-out {
background: rgb(176 91 0 / 82%);
@@ -794,14 +805,15 @@ onBeforeUnmount(() => {
}
.garage-vehicle__title > b {
flex: none;
padding: 4px 7px;
padding: 2px 5px;
border: 0.5px solid var(--garage-separator);
border-radius: 7px;
border-radius: 5px;
background: var(--garage-surface-muted);
color: var(--garage-text);
font-size: 11px;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.2;
letter-spacing: 0.02em;
}
.garage-vehicle__meta {
display: flex;
+590 -3
View File
@@ -1,6 +1,25 @@
<script setup lang="ts">
import { kFab, kPage } from 'konsta/vue'
import { LocateFixed, Map, MapPinned, Route, Satellite } from 'lucide-vue-next'
import {
kButton,
kFab,
kList,
kListInput,
kPage,
kPreloader,
kSheet,
kToast,
} from 'konsta/vue'
import {
LocateFixed,
Map,
MapPin,
MapPinned,
MapPinPlus,
Route,
Satellite,
Trash2,
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import {
@@ -8,15 +27,19 @@ import {
defaultCayoStyle,
defaultMainlandStyle,
defaultMapCoordinates,
defaultMapPercentToWorld,
defaultMapWorldToPercent,
type MapPoint,
} from '@/features/map/defaultMapGeometry'
import { useMapStore } from '@/stores/map'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import type { MapMarker, MapMarkerColor } from '@/types/map'
import { nuiCall, type NuiResponse } from '@/utils/nui'
type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
const phone = usePhoneStore()
const mapStore = useMapStore()
const mapStyle = ref<MapStyle>('default')
const zoom = ref(1.1)
const pan = ref<MapPoint>({ x: 0, y: 0 })
@@ -31,8 +54,16 @@ const canvasRef = ref<HTMLElement | null>(null)
const locationRef = ref<HTMLElement | null>(null)
const isPointerDown = ref(false)
const isPanning = ref(false)
const placingMarker = ref(false)
const draftCoords = ref<(MapPoint & { z: number }) | null>(null)
const markerLabel = ref('')
const markerColor = ref<MapMarkerColor>('blue')
const selectedMarker = ref<MapMarker | null>(null)
const markerError = ref('')
const toastText = ref('')
let pointerMoveFrame: number | undefined
let wheelZoomFrame: number | undefined
let toastTimer: number | undefined
let pendingWheelDirection: -1 | 0 | 1 = 0
let pendingWheelPoint: MapPoint | undefined
@@ -61,6 +92,13 @@ const mapStyles = [
{ id: 'atlas' as const, icon: Map },
{ id: 'roads' as const, icon: Route },
]
const markerColors: Array<{ id: MapMarkerColor; value: string }> = [
{ id: 'blue', value: '#0a84ff' },
{ id: 'green', value: '#30d158' },
{ id: 'orange', value: '#ff9f0a' },
{ id: 'red', value: '#ff453a' },
{ id: 'purple', value: '#bf5af2' },
]
const mapControlColors = {
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
@@ -122,6 +160,21 @@ const worldToPercent = (coords: MapPoint): MapPoint => {
}
}
const percentToWorld = (point: MapPoint): MapPoint => {
if (mapStyle.value === 'default') {
return defaultMapPercentToWorld(point)
}
const projected = {
x: mapBounds.minX + point.x * (mapBounds.maxX - mapBounds.minX),
y: mapBounds.maxY - point.y * (mapBounds.maxY - mapBounds.minY),
}
return {
x: (projected.x - mapOrigin.x) * mapScale.x,
y: (projected.y - mapOrigin.y) * mapScale.y,
}
}
const locationStyle = computed(() => {
if (!currentLocation.value) return undefined
const percent = worldToPercent(currentLocation.value)
@@ -132,6 +185,39 @@ const locationStyle = computed(() => {
}
})
function markerStyle(marker: MapMarker): Record<string, string> {
const percent = worldToPercent(marker.coords)
return {
left: `${percent.x * 100}%`,
top: `${percent.y * 100}%`,
transform: `translate(-50%, -100%) scale(${1 / zoom.value})`,
}
}
function markerColorValue(color: MapMarkerColor): string {
return (
markerColors.find((candidate) => candidate.id === color)?.value ??
markerColors[0].value
)
}
function showToast(message: string): void {
if (toastTimer) window.clearTimeout(toastTimer)
toastText.value = message
toastTimer = window.setTimeout(() => {
toastText.value = ''
toastTimer = undefined
}, 2200)
}
function markerErrorText(error?: string): string {
const key = error ?? 'request_failed'
const translated = phone.t(`Apps.map.errors.${key}`)
return translated === `Apps.map.errors.${key}`
? phone.t('Apps.map.errors.request_failed')
: translated
}
function setMapStyle(style: MapStyle): void {
mapStyle.value = style
imageError.value = false
@@ -144,6 +230,116 @@ function cycleMapStyle(): void {
setMapStyle(mapStyles[(currentIndex + 1) % mapStyles.length].id)
}
function startMarkerPlacement(): void {
selectedMarker.value = null
draftCoords.value = null
markerError.value = ''
placingMarker.value = true
}
function cancelMarkerPlacement(): void {
placingMarker.value = false
}
function openMarkerEditor(): void {
const viewport = viewportRef.value?.getBoundingClientRect()
const canvas = canvasRef.value?.getBoundingClientRect()
if (!viewport || !canvas || canvas.width <= 0 || canvas.height <= 0) {
showToast(phone.t('Apps.map.errors.request_failed'))
return
}
const percent = {
x: Math.min(
1,
Math.max(
0,
(viewport.left + viewport.width / 2 - canvas.left) / canvas.width,
),
),
y: Math.min(
1,
Math.max(
0,
(viewport.top + viewport.height / 2 - canvas.top) / canvas.height,
),
),
}
const coords = percentToWorld(percent)
draftCoords.value = {
x: Math.round(coords.x * 100) / 100,
y: Math.round(coords.y * 100) / 100,
z: 0,
}
markerLabel.value = ''
markerColor.value = 'blue'
markerError.value = ''
placingMarker.value = false
}
function updateMarkerLabel(event: Event): void {
markerLabel.value = (event.target as HTMLInputElement).value
}
function closeMarkerSheet(): void {
if (mapStore.isLoading) return
draftCoords.value = null
selectedMarker.value = null
markerError.value = ''
}
function selectMarker(marker: MapMarker): void {
if (placingMarker.value) return
selectedMarker.value = marker
markerError.value = ''
}
function handleMarkerResponse(
response: NuiResponse,
successMessage: string,
): boolean {
if (!response.success) {
markerError.value = markerErrorText(response.error)
return false
}
showToast(successMessage)
return true
}
async function saveMarker(): Promise<void> {
const coords = draftCoords.value
const label = markerLabel.value.trim()
if (!coords || !label || label.length > 40) {
markerError.value = phone.t('Apps.map.errors.invalid_marker')
return
}
const response = await mapStore.create({
color: markerColor.value,
coords,
label,
})
if (!handleMarkerResponse(response, phone.t('Apps.map.markerSaved'))) return
draftCoords.value = null
}
async function deleteSelectedMarker(): Promise<void> {
const marker = selectedMarker.value
if (!marker) return
const response = await mapStore.remove(marker.id)
if (!handleMarkerResponse(response, phone.t('Apps.map.markerDeleted'))) return
selectedMarker.value = null
}
async function setSelectedMarkerWaypoint(): Promise<void> {
const marker = selectedMarker.value
if (!marker) return
const response = await nuiCall('map:setWaypoint', { coords: marker.coords })
if (!handleMarkerResponse(response, phone.t('Apps.map.waypointSet'))) return
selectedMarker.value = null
}
function changeZoom(direction: -1 | 1, focalPoint?: MapPoint): void {
const targetZoom =
direction > 0 ? zoom.value * zoomFactor : zoom.value / zoomFactor
@@ -268,11 +464,13 @@ async function loadCurrentLocation(center: boolean): Promise<void> {
onMounted(() => {
void loadCurrentLocation(false)
void mapStore.load()
})
onBeforeUnmount(() => {
if (pointerMoveFrame) cancelAnimationFrame(pointerMoveFrame)
if (wheelZoomFrame) cancelAnimationFrame(wheelZoomFrame)
if (toastTimer) window.clearTimeout(toastTimer)
})
</script>
@@ -320,6 +518,32 @@ onBeforeUnmount(() => {
<span class="current-location__pulse"></span>
<span class="current-location__dot"></span>
</div>
<button
v-for="marker in mapStore.markers"
:key="marker.id"
type="button"
class="custom-map-marker"
:style="markerStyle(marker)"
:aria-label="marker.label"
@pointerdown.stop
@click.stop="selectMarker(marker)"
>
<MapPin
:size="30"
:style="{ color: markerColorValue(marker.color) }"
fill="currentColor"
aria-hidden="true"
/>
<span>{{ marker.label }}</span>
</button>
</div>
<div
v-if="placingMarker"
class="map-placement-crosshair"
aria-hidden="true"
>
<span></span>
</div>
<p v-if="imageError" class="map-error">
@@ -340,6 +564,19 @@ onBeforeUnmount(() => {
<component :is="activeMapStyle.icon" aria-hidden="true" />
</template>
</k-fab>
<k-fab
component="button"
type="button"
class="map-control map-control--marker"
:colors="locationControlColors"
:disabled="placingMarker"
:aria-label="phone.t('Apps.map.addMarker')"
@click="startMarkerPlacement"
>
<template #icon>
<MapPinPlus aria-hidden="true" />
</template>
</k-fab>
<k-fab
component="button"
type="button"
@@ -354,6 +591,134 @@ onBeforeUnmount(() => {
</template>
</k-fab>
</nav>
<section v-if="placingMarker" class="map-placement-panel">
<strong>{{ phone.t('Apps.map.placeMarker') }}</strong>
<span>{{ phone.t('Apps.map.placeMarkerHint') }}</span>
<div>
<k-button small rounded outline @click="cancelMarkerPlacement">
<X :size="16" />
{{ phone.t('Common.cancel') }}
</k-button>
<k-button small rounded @click="openMarkerEditor">
<MapPin :size="16" />
{{ phone.t('Apps.map.addHere') }}
</k-button>
</div>
</section>
<k-sheet
:opened="Boolean(draftCoords || selectedMarker)"
class="map-marker-sheet"
@backdropclick="closeMarkerSheet"
>
<section
v-if="draftCoords"
class="map-marker-sheet__content"
:class="{ 'map-marker-sheet__content--dark': phone.isDarkMode }"
role="dialog"
aria-modal="true"
:aria-label="phone.t('Apps.map.newMarker')"
>
<h2>{{ phone.t('Apps.map.newMarker') }}</h2>
<p>{{ phone.t('Apps.map.newMarkerDescription') }}</p>
<k-list inset strong>
<k-list-input
input-id="map-marker-label"
:label="phone.t('Apps.map.markerName')"
:placeholder="phone.t('Apps.map.markerNamePlaceholder')"
:value="markerLabel"
maxlength="40"
outline
@input="updateMarkerLabel"
@keydown.enter="saveMarker"
/>
</k-list>
<span class="map-marker-sheet__label">{{
phone.t('Apps.map.markerColor')
}}</span>
<div
class="map-marker-colors"
role="radiogroup"
:aria-label="phone.t('Apps.map.markerColor')"
>
<button
v-for="color in markerColors"
:key="color.id"
type="button"
role="radio"
:aria-checked="markerColor === color.id"
:aria-label="phone.t(`Apps.map.colors.${color.id}`)"
:class="{ 'map-marker-color--active': markerColor === color.id }"
:style="{ backgroundColor: color.value }"
@click="markerColor = color.id"
></button>
</div>
<p v-if="markerError" class="map-marker-error" role="alert">
{{ markerError }}
</p>
<k-button
large
rounded
:disabled="mapStore.isLoading || !markerLabel.trim()"
@click="saveMarker"
>
<k-preloader v-if="mapStore.isLoading" />
<template v-else>{{ phone.t('Apps.map.saveMarker') }}</template>
</k-button>
</section>
<section
v-else-if="selectedMarker"
class="map-marker-sheet__content map-marker-sheet__content--details"
:class="{ 'map-marker-sheet__content--dark': phone.isDarkMode }"
role="dialog"
aria-modal="true"
:aria-label="selectedMarker.label"
>
<span
class="map-marker-sheet__pin"
:style="{ color: markerColorValue(selectedMarker.color) }"
>
<MapPin :size="32" fill="currentColor" />
</span>
<h2>{{ selectedMarker.label }}</h2>
<p>
{{ selectedMarker.coords.x.toFixed(1) }},
{{ selectedMarker.coords.y.toFixed(1) }}
</p>
<p v-if="markerError" class="map-marker-error" role="alert">
{{ markerError }}
</p>
<k-button
large
rounded
class="map-marker-waypoint"
:disabled="mapStore.isLoading"
@click="setSelectedMarkerWaypoint"
>
<Route :size="17" />
{{ phone.t('Apps.map.setWaypoint') }}
</k-button>
<k-button
large
rounded
class="map-marker-delete"
:disabled="mapStore.isLoading"
@click="deleteSelectedMarker"
>
<k-preloader v-if="mapStore.isLoading" />
<template v-else>
<Trash2 :size="17" />
{{ phone.t('Apps.map.deleteMarker') }}
</template>
</k-button>
</section>
</k-sheet>
<k-toast :opened="Boolean(toastText)" position="center">
{{ toastText }}
</k-toast>
</k-page>
</template>
@@ -428,6 +793,84 @@ onBeforeUnmount(() => {
box-shadow: 0 1px 4px rgb(0 0 0 / 35%);
}
.custom-map-marker {
position: absolute;
z-index: 2;
display: flex;
padding: 0;
align-items: center;
flex-direction: column;
border: 0;
color: #fff;
background: transparent;
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 45%));
transform-origin: 50% 100%;
white-space: nowrap;
}
.custom-map-marker svg {
stroke: #fff;
stroke-width: 1.8;
}
.custom-map-marker span {
max-width: 112px;
padding: 3px 7px;
overflow: hidden;
border: 0.5px solid rgb(255 255 255 / 18%);
border-radius: 8px;
background: rgb(20 20 22 / 84%);
box-shadow: 0 2px 7px rgb(0 0 0 / 30%);
backdrop-filter: blur(12px);
font-size: 10px;
font-weight: 650;
text-overflow: ellipsis;
}
.map-placement-crosshair {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
width: 42px;
height: 42px;
border: 1px solid rgb(255 255 255 / 80%);
border-radius: 50%;
box-shadow:
0 2px 12px rgb(0 0 0 / 40%),
inset 0 0 0 1px rgb(0 0 0 / 16%);
transform: translate(-50%, -50%);
pointer-events: none;
}
.map-placement-crosshair::before,
.map-placement-crosshair::after,
.map-placement-crosshair span {
position: absolute;
top: 50%;
left: 50%;
background: #fff;
content: '';
transform: translate(-50%, -50%);
}
.map-placement-crosshair::before {
width: 16px;
height: 1px;
}
.map-placement-crosshair::after {
width: 1px;
height: 16px;
}
.map-placement-crosshair span {
width: 5px;
height: 5px;
border: 1px solid rgb(0 0 0 / 35%);
border-radius: 50%;
}
.map-controls {
position: absolute;
z-index: 3;
@@ -448,6 +891,150 @@ onBeforeUnmount(() => {
height: 21px;
}
.map-control--marker {
--color-primary: #0a84ff;
}
.map-placement-panel {
position: absolute;
z-index: 4;
right: 66px;
bottom: 28px;
left: 12px;
display: flex;
min-height: 84px;
padding: 12px;
flex-direction: column;
border: 0.5px solid rgb(255 255 255 / 22%);
border-radius: 18px;
color: #fff;
background: rgb(24 24 27 / 86%);
box-shadow: 0 8px 24px rgb(0 0 0 / 32%);
backdrop-filter: blur(22px) saturate(145%);
}
.map-placement-panel strong {
font-size: 13px;
}
.map-placement-panel > span {
margin-top: 2px;
color: rgb(255 255 255 / 64%);
font-size: 10px;
line-height: 1.25;
}
.map-placement-panel > div {
display: grid;
margin-top: 10px;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.map-placement-panel :deep(button) {
min-height: 32px;
font-size: 11px;
}
.map-marker-sheet__content {
display: flex;
min-height: 350px;
padding: 22px 16px calc(18px + env(safe-area-inset-bottom));
flex-direction: column;
color: #111;
}
.map-marker-sheet__content--dark {
color: #fff;
}
.map-marker-sheet__content h2 {
margin: 0;
font-size: 20px;
font-weight: 750;
letter-spacing: -0.4px;
text-align: center;
}
.map-marker-sheet__content > p {
margin: 5px 0 14px;
color: rgb(60 60 67 / 60%);
font-size: 12px;
line-height: 1.4;
text-align: center;
}
.map-marker-sheet__content--dark > p {
color: rgb(235 235 245 / 60%);
}
.map-marker-sheet__label {
margin: 13px 5px 8px;
color: rgb(60 60 67 / 60%);
font-size: 12px;
}
.map-marker-sheet__content--dark .map-marker-sheet__label {
color: rgb(235 235 245 / 60%);
}
.map-marker-colors {
display: flex;
margin-bottom: 18px;
justify-content: center;
gap: 14px;
}
.map-marker-colors button {
width: 31px;
height: 31px;
padding: 0;
border: 3px solid transparent;
border-radius: 50%;
box-shadow: 0 1px 4px rgb(0 0 0 / 25%);
}
.map-marker-colors .map-marker-color--active {
border-color: #fff;
outline: 2px solid #0a84ff;
}
.map-marker-error {
color: #ff3b30 !important;
font-size: 11px !important;
}
.map-marker-sheet__content--details {
min-height: 250px;
align-items: center;
}
.map-marker-sheet__pin {
display: grid;
width: 58px;
height: 58px;
margin-bottom: 10px;
place-items: center;
border-radius: 18px;
background: rgb(120 120 128 / 12%);
}
.map-marker-sheet__pin svg {
stroke: #fff;
}
.map-marker-sheet__content .map-marker-delete {
width: 100%;
margin-top: 10px;
color: #fff;
background: #ff3b30;
}
.map-marker-sheet__content .map-marker-waypoint {
width: 100%;
margin-top: auto;
}
.map-error {
position: absolute;
top: 50%;
+83 -29
View File
@@ -14,6 +14,7 @@ import {
kNavbarBackLink,
kPage,
kPreloader,
kSearchbar,
kToast,
kToolbarPane,
} from 'konsta/vue'
@@ -394,7 +395,8 @@ async function saveContactDetails(): Promise<void> {
return
}
contactEditing.value = false
contactNumberDraft.value = response.data?.phone_number ?? contactNumberDraft.value
contactNumberDraft.value =
response.data?.phone_number ?? contactNumberDraft.value
}
async function deleteActiveContact(): Promise<void> {
@@ -432,7 +434,10 @@ function openEmojiPicker(): void {
emojiOpen.value = true
}
function openMediaApp(app: 'camera' | 'photos', mediaType: 'photo' | 'video'): void {
function openMediaApp(
app: 'camera' | 'photos',
mediaType: 'photo' | 'video',
): void {
if (!messages.activeNumber) return
attachmentMenuOpen.value = false
messageMedia.begin(messages.activeNumber, mediaType)
@@ -536,7 +541,10 @@ function sampleMicrophone(): void {
async function startVoiceRecording(): Promise<void> {
emojiOpen.value = false
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
if (
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === 'undefined'
) {
showToast(phone.t('Apps.messages.microphoneUnavailable'))
return
}
@@ -618,7 +626,13 @@ function compressedWaveform(): number[] {
const end = Math.max(start + 1, Math.floor((index + 1) * bucketSize))
const bucket = recordingSamples.slice(start, end)
result.push(
Math.max(0.08, Math.min(1, bucket.reduce((sum, value) => sum + value, 0) / bucket.length)),
Math.max(
0.08,
Math.min(
1,
bucket.reduce((sum, value) => sum + value, 0) / bucket.length,
),
),
)
}
return result
@@ -694,7 +708,9 @@ onBeforeUnmount(() => {
>
<k-navbar large transparent :title="phone.t('Apps.messages.name')" />
<div class="messages-empty-state">
<span class="messages-empty-state__icon"><MessageCircle :size="35" /></span>
<span class="messages-empty-state__icon"
><MessageCircle :size="35"
/></span>
<h2>{{ phone.t('Apps.messages.noSim') }}</h2>
<p>{{ phone.t('Apps.messages.noSimBody') }}</p>
</div>
@@ -746,10 +762,7 @@ onBeforeUnmount(() => {
</k-glass>
</header>
<div
v-if="filteredConversations.length"
class="messages-conversation-list"
>
<div v-if="filteredConversations.length" class="messages-conversation-list">
<button
v-for="conversation in filteredConversations"
:key="conversation.phoneNumber"
@@ -797,7 +810,9 @@ onBeforeUnmount(() => {
<span class="messages-conversation__body">
<span class="messages-conversation__headline">
<strong>{{ contactName(conversation.phoneNumber) }}</strong>
<time>{{ formatConversationDate(conversation.lastMessageAt) }}</time>
<time>{{
formatConversationDate(conversation.lastMessageAt)
}}</time>
<ChevronRight :size="13" />
</span>
<span class="messages-conversation__preview">
@@ -808,7 +823,9 @@ onBeforeUnmount(() => {
</div>
<div v-else class="messages-empty-state messages-empty-state--list">
<span class="messages-empty-state__icon"><MessageCircle :size="35" /></span>
<span class="messages-empty-state__icon"
><MessageCircle :size="35"
/></span>
<h2>
{{
phone.t(
@@ -823,16 +840,31 @@ onBeforeUnmount(() => {
</div>
<footer v-if="!editingList" class="messages-inbox-toolbar">
<label>
<Search :size="16" />
<input
<div class="messages-inbox-search">
<k-searchbar
:value="search"
type="search"
:placeholder="phone.t('Apps.messages.search')"
:colors="{
inputBgIos: 'bg-transparent',
placeholderIos: 'placeholder-[#8e8e93]',
}"
:clear-button="false"
:input-style="{
color: phone.isDarkMode ? '#f5f5f7' : '#111',
paddingRight: '48px',
}"
@input="search = eventValue($event)"
@clear="search = ''"
/>
<Mic :size="16" />
</label>
<k-link
component="button"
icon-only
class="messages-inbox-search__voice"
:aria-label="phone.t('Apps.messages.search')"
>
<Mic :size="20" />
</k-link>
</div>
<k-glass
component="button"
type="button"
@@ -843,7 +875,11 @@ onBeforeUnmount(() => {
</k-glass>
</footer>
<footer v-else class="messages-edit-toolbar">
<span>{{ phone.t('Apps.messages.selectedCount', { count: String(selectedNumbers.length) }) }}</span>
<span>{{
phone.t('Apps.messages.selectedCount', {
count: String(selectedNumbers.length),
})
}}</span>
<button
type="button"
:disabled="!selectedNumbers.length"
@@ -881,7 +917,11 @@ onBeforeUnmount(() => {
@keyup.enter="chooseRecipient(composerNumber)"
/>
</k-list>
<k-list v-if="contactSuggestions.length" class="messages-contact-list" strong>
<k-list
v-if="contactSuggestions.length"
class="messages-contact-list"
strong
>
<k-list-item
v-for="contact in contactSuggestions"
:key="contact.id"
@@ -895,7 +935,9 @@ onBeforeUnmount(() => {
class="messages-avatar messages-avatar--small"
:style="avatarStyle(contact.phone_number)"
>
<span class="messages-avatar__glyph">{{ avatarGlyph(contact.phone_number) }}</span>
<span class="messages-avatar__glyph">{{
avatarGlyph(contact.phone_number)
}}</span>
</span>
</template>
</k-list-item>
@@ -919,27 +961,32 @@ onBeforeUnmount(() => {
:aria-label="activeTitle"
>
<header class="messages-chat-header">
<button
<k-link
component="button"
icon-only
type="button"
class="messages-chat-header__back"
:aria-label="phone.t('Apps.messages.name')"
@click="goBack"
>
<ChevronLeft :size="28" :stroke-width="2.35" />
</button>
</k-link>
<div class="messages-chat-header__contact">
<span
class="messages-avatar messages-avatar--header"
:class="{ 'messages-avatar--unknown': !activeContact }"
:style="avatarStyle(messages.activeNumber ?? '')"
>
<span v-if="activeContact" class="messages-avatar__glyph">{{ avatarGlyph(messages.activeNumber ?? '') }}</span>
<span v-if="activeContact" class="messages-avatar__glyph">{{
avatarGlyph(messages.activeNumber ?? '')
}}</span>
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
<i />
<b />
</span>
</span>
<button
<k-link
component="button"
type="button"
class="messages-chat-header__name"
:aria-label="phone.t('Apps.messages.contactDetails')"
@@ -947,7 +994,7 @@ onBeforeUnmount(() => {
>
<strong>{{ activeTitle }}</strong>
<ChevronRight :size="13" />
</button>
</k-link>
</div>
</header>
@@ -977,7 +1024,9 @@ onBeforeUnmount(() => {
:class="{ 'messages-avatar--unknown': !activeContact }"
:style="avatarStyle(messages.activeNumber ?? '')"
>
<span v-if="activeContact" class="messages-avatar__glyph">{{ avatarGlyph(messages.activeNumber ?? '') }}</span>
<span v-if="activeContact" class="messages-avatar__glyph">{{
avatarGlyph(messages.activeNumber ?? '')
}}</span>
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
<i />
<b />
@@ -1035,11 +1084,17 @@ onBeforeUnmount(() => {
</section>
<k-messages class="messages-bubbles">
<template v-for="(message, index) in messages.messages" :key="message.client_id ?? message.id">
<template
v-for="(message, index) in messages.messages"
:key="message.client_id ?? message.id"
>
<k-messages-title v-if="startsDay(message, index)">
<span class="messages-thread-timestamp">
<span>{{ phone.t('Apps.messages.smsLabel') }}</span>
<b>{{ dayLabel(message.created_at) }}, {{ timeLabel(message.created_at) }}</b>
<b
>{{ dayLabel(message.created_at) }},
{{ timeLabel(message.created_at) }}</b
>
</span>
</k-messages-title>
<k-message
@@ -1166,7 +1221,6 @@ onBeforeUnmount(() => {
</button>
</section>
<k-messagebar
v-else
class="messages-messagebar"
+466 -3
View File
@@ -17,22 +17,29 @@ import {
kPreloader,
kRange,
kSearchbar,
kSegmented,
kSegmentedButton,
kToast,
kToggle,
} from 'konsta/vue'
import {
BellRing,
Bluetooth,
Check,
EyeOff,
KeyRound,
Monitor,
Moon,
Plane,
RotateCcw,
RotateCw,
Settings,
Signal,
Smartphone,
Sun,
UserRound,
Volume2,
Wifi,
} from 'lucide-vue-next'
import {
computed,
@@ -45,6 +52,7 @@ import {
import { PHONE_FRAME_COLORS } from '@/config/appearance'
import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import PhonePasscode from '@/components/PhonePasscode.vue'
import { useAccountStore } from '@/stores/account'
import type {
LaunchablePhoneAppDefinition,
@@ -74,14 +82,31 @@ import {
type SettingsView =
| 'root'
| 'account'
| 'security'
| 'notifications'
| 'notification-detail'
| 'sounds'
| 'connectivity'
| 'focus'
| 'general'
| 'appearance'
| 'wallpaper'
type RootToggleKey = 'airplaneMode' | 'streamerMode'
type RootToggleKey =
| 'airplaneMode'
| 'streamerMode'
| 'focusMode'
| 'wifiEnabled'
| 'bluetoothEnabled'
| 'cellularEnabled'
type SubmenuView = Exclude<SettingsView, 'root' | 'notification-detail'>
type PasscodeFlow =
| 'set-new'
| 'set-confirm'
| 'change-current'
| 'change-new'
| 'change-confirm'
| 'disable'
| null
const FACTORY_RESET_DURATION_MS = 60_000
const FACTORY_RESET_CIRCUMFERENCE = 2 * Math.PI * 48
@@ -110,6 +135,13 @@ const accountPassword = ref('')
const accountConfirm = ref('')
const accountSubmitting = ref(false)
const accountToast = ref('')
const passcodeBusy = ref(false)
const passcodeCurrent = ref('')
const passcodeError = ref('')
const passcodeFirst = ref('')
const passcodeFlow = ref<PasscodeFlow>(null)
const passcodeLength = ref<4 | 6>(6)
const passcodeResetKey = ref(0)
const removeDeviceImei = ref('')
const removeDevicePassword = ref('')
const removeDeviceOpened = ref(false)
@@ -152,6 +184,24 @@ const serviceRows = [
},
]
const preferenceRows = [
{
key: 'connectivity',
view: 'connectivity' as const,
icon: Wifi,
iconColor: '#007aff',
},
{
key: 'focus',
view: 'focus' as const,
icon: Moon,
iconColor: '#5856d6',
},
{
key: 'security',
view: 'security' as const,
icon: KeyRound,
iconColor: '#34c759',
},
{
key: 'general',
view: 'general' as const,
@@ -172,6 +222,27 @@ const preferenceRows = [
},
]
const connectivityRows = [
{
key: 'wifi',
preferenceKey: 'wifiEnabled' as const,
icon: Wifi,
iconColor: '#007aff',
},
{
key: 'bluetooth',
preferenceKey: 'bluetoothEnabled' as const,
icon: Bluetooth,
iconColor: '#007aff',
},
{
key: 'cellular',
preferenceKey: 'cellularEnabled' as const,
icon: Signal,
iconColor: '#34c759',
},
]
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
const visibleToggleRows = computed(() =>
toggleRows.filter((row) => matchesSearch(row.key)),
@@ -204,6 +275,21 @@ const activeTitle = computed(() => {
}
return phone.t(`Apps.settings.${activeView.value}`)
})
const passcodeTitle = computed(() => {
if (passcodeFlow.value === 'set-confirm') {
return phone.t('Apps.settings.passcode.confirmNew')
}
if (
passcodeFlow.value === 'change-current' ||
passcodeFlow.value === 'disable'
) {
return phone.t('Apps.settings.passcode.enterCurrent')
}
if (passcodeFlow.value === 'change-confirm') {
return phone.t('Apps.settings.passcode.confirmNew')
}
return phone.t('Apps.settings.passcode.enterNew')
})
function matchesSearch(key: string): boolean {
return (
@@ -220,6 +306,9 @@ function updateSearch(event: Event): void {
}
function openView(view: SubmenuView): void {
if (view === 'security') {
passcodeLength.value = phone.security.length ?? 6
}
activeView.value = view
scrollPageToTop()
}
@@ -248,12 +337,122 @@ function toggleRootSetting(key: RootToggleKey): void {
phone.setPreference(key, !phone.preferences.settings[key])
}
function resetPasscodeInput(): void {
passcodeError.value = ''
passcodeResetKey.value += 1
}
function beginSetPasscode(): void {
passcodeLength.value = phone.security.length ?? passcodeLength.value
passcodeFirst.value = ''
passcodeCurrent.value = ''
passcodeFlow.value = 'set-new'
resetPasscodeInput()
}
function beginChangePasscode(): void {
passcodeFirst.value = ''
passcodeCurrent.value = ''
passcodeFlow.value = 'change-current'
resetPasscodeInput()
}
function beginDisablePasscode(): void {
passcodeLength.value = phone.security.length ?? 6
passcodeCurrent.value = ''
passcodeFlow.value = 'disable'
resetPasscodeInput()
}
function cancelPasscodeFlow(): void {
if (passcodeBusy.value) return
passcodeFlow.value = null
passcodeFirst.value = ''
passcodeCurrent.value = ''
resetPasscodeInput()
}
function passcodeRequestError(error?: string): string {
if (error === 'invalid_passcode') {
return phone.t('Apps.settings.passcode.incorrect')
}
if (error === 'passcode_locked') {
return phone.t('Apps.settings.passcode.locked')
}
if (error === 'rate_limited') {
return phone.t('Apps.settings.passcode.rateLimited')
}
return phone.t('Apps.settings.passcode.failed')
}
async function submitSettingsPasscode(passcode: string): Promise<void> {
if (passcodeBusy.value || !passcodeFlow.value) return
if (passcodeFlow.value === 'set-new') {
passcodeFirst.value = passcode
passcodeFlow.value = 'set-confirm'
resetPasscodeInput()
return
}
if (passcodeFlow.value === 'change-current') {
passcodeCurrent.value = passcode
passcodeFlow.value = 'change-new'
resetPasscodeInput()
return
}
if (passcodeFlow.value === 'change-new') {
passcodeFirst.value = passcode
passcodeFlow.value = 'change-confirm'
resetPasscodeInput()
return
}
if (
(passcodeFlow.value === 'set-confirm' ||
passcodeFlow.value === 'change-confirm') &&
passcode !== passcodeFirst.value
) {
passcodeError.value = phone.t('Apps.settings.passcode.mismatch')
passcodeResetKey.value += 1
return
}
passcodeBusy.value = true
const response =
passcodeFlow.value === 'set-confirm'
? await phone.setPasscode(passcode)
: passcodeFlow.value === 'change-confirm'
? await phone.changePasscode(passcodeCurrent.value, passcode)
: await phone.disablePasscode(passcode)
passcodeBusy.value = false
if (!response.success) {
passcodeError.value = passcodeRequestError(response.error)
if (
passcodeFlow.value === 'change-confirm' &&
response.error === 'invalid_passcode'
) {
passcodeFlow.value = 'change-current'
passcodeCurrent.value = ''
passcodeFirst.value = ''
}
passcodeResetKey.value += 1
return
}
accountToast.value = phone.t(
passcodeFlow.value === 'disable'
? 'Apps.settings.passcode.disabled'
: 'Apps.settings.passcode.saved',
)
cancelPasscodeFlow()
}
function updateNumberPreference(
key:
| 'notificationDurationSeconds'
| 'notificationVolume'
| 'phoneScale'
| 'ringtoneVolume',
| 'ringtoneVolume'
| 'screenBrightness',
event: Event,
): void {
phone.setPreference(
@@ -551,12 +750,28 @@ onBeforeUnmount(() => {
<component :is="row.icon" :size="17" :stroke-width="2.25" />
</span>
</template>
<template v-if="row.key === 'security' || row.key === 'focus'" #after>
{{
phone.t(
(row.key === 'security' && phone.security.enabled) ||
(row.key === 'focus' && phone.preferences.settings.focusMode)
? 'Apps.settings.on'
: 'Apps.settings.off',
)
}}
</template>
</k-list-item>
</k-list>
</template>
<template v-else>
<k-navbar :title="activeTitle" class="top-0 sticky z-20">
<k-navbar
:title="activeTitle"
:class="[
'settings-detail-navbar sticky z-20',
{ 'settings-detail-navbar--dark': phone.isDarkMode },
]"
>
<template #left>
<k-navbar-back-link
component="button"
@@ -715,6 +930,85 @@ onBeforeUnmount(() => {
</template>
</template>
<template v-else-if="activeView === 'security'">
<k-block class="text-sm leading-5 opacity-70">
{{ phone.t('Apps.settings.passcode.description') }}
</k-block>
<template v-if="!phone.security.enabled">
<k-block-title>{{
phone.t('Apps.settings.passcode.codeLength')
}}</k-block-title>
<k-block>
<k-segmented strong rounded>
<k-segmented-button
:active="passcodeLength === 6"
@click="passcodeLength = 6"
>
{{ phone.t('Apps.settings.passcode.sixDigit') }}
</k-segmented-button>
<k-segmented-button
:active="passcodeLength === 4"
@click="passcodeLength = 4"
>
{{ phone.t('Apps.settings.passcode.fourDigit') }}
</k-segmented-button>
</k-segmented>
</k-block>
<k-list strong inset>
<k-list-button @click="beginSetPasscode">
{{ phone.t('Apps.settings.passcode.turnOn') }}
</k-list-button>
</k-list>
</template>
<template v-else>
<k-list strong inset>
<k-list-item
:title="phone.t('Apps.settings.passcode.status')"
:after="phone.t('Apps.settings.on')"
/>
<k-list-item
:title="phone.t('Apps.settings.passcode.codeLength')"
:after="
phone.t(
phone.security.length === 4
? 'Apps.settings.passcode.fourDigit'
: 'Apps.settings.passcode.sixDigit',
)
"
/>
</k-list>
<k-block-title>{{
phone.t('Apps.settings.passcode.codeLength')
}}</k-block-title>
<k-block>
<k-segmented strong rounded>
<k-segmented-button
:active="passcodeLength === 6"
@click="passcodeLength = 6"
>
{{ phone.t('Apps.settings.passcode.sixDigit') }}
</k-segmented-button>
<k-segmented-button
:active="passcodeLength === 4"
@click="passcodeLength = 4"
>
{{ phone.t('Apps.settings.passcode.fourDigit') }}
</k-segmented-button>
</k-segmented>
</k-block>
<k-list strong inset>
<k-list-button @click="beginChangePasscode">
{{ phone.t('Apps.settings.passcode.change') }}
</k-list-button>
<k-list-button class="!text-red-500" @click="beginDisablePasscode">
{{ phone.t('Apps.settings.passcode.turnOff') }}
</k-list-button>
</k-list>
</template>
</template>
<template v-else-if="activeView === 'notifications'">
<k-list strong inset>
<k-list-item
@@ -899,6 +1193,80 @@ onBeforeUnmount(() => {
</k-list>
</template>
<template v-else-if="activeView === 'connectivity'">
<k-list strong inset>
<k-list-item :title="phone.t('Apps.settings.airplaneMode')">
<template #media>
<span class="settings-row-icon bg-[#ff9500]">
<Plane :size="17" :stroke-width="2.25" />
</span>
</template>
<template #after>
<k-toggle
:checked="phone.preferences.settings.airplaneMode"
:aria-label="phone.t('Apps.settings.toggle.airplaneMode')"
@change="toggleRootSetting('airplaneMode')"
/>
</template>
</k-list-item>
</k-list>
<k-block-title>{{
phone.t('Apps.settings.connections')
}}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="row in connectivityRows"
:key="row.key"
:title="phone.t(`Apps.settings.${row.key}`)"
>
<template #media>
<span
class="settings-row-icon"
:style="{ backgroundColor: row.iconColor }"
>
<component :is="row.icon" :size="17" :stroke-width="2.25" />
</span>
</template>
<template #after>
<k-toggle
:checked="phone.preferences.settings[row.preferenceKey]"
:disabled="phone.preferences.settings.airplaneMode"
:aria-label="
phone.t(`Apps.settings.toggle.${row.preferenceKey}`)
"
@change="toggleRootSetting(row.preferenceKey)"
/>
</template>
</k-list-item>
</k-list>
<k-block class="text-sm leading-5 opacity-60">
{{ phone.t('Apps.settings.connectivityDescription') }}
</k-block>
</template>
<template v-else-if="activeView === 'focus'">
<k-list strong inset>
<k-list-item :title="phone.t('Apps.settings.focusMode')">
<template #media>
<span class="settings-row-icon bg-[#5856d6]">
<Moon :size="17" :stroke-width="2.25" />
</span>
</template>
<template #after>
<k-toggle
:checked="phone.preferences.settings.focusMode"
:aria-label="phone.t('Apps.settings.toggle.focusMode')"
@change="toggleRootSetting('focusMode')"
/>
</template>
</k-list-item>
</k-list>
<k-block class="text-sm leading-5 opacity-60">
{{ phone.t('Apps.settings.focusDescription') }}
</k-block>
</template>
<template v-else-if="activeView === 'general'">
<k-block-title>
{{ phone.t('Apps.settings.notificationDuration') }} ·
@@ -1010,6 +1378,48 @@ onBeforeUnmount(() => {
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.screenBrightness') }} ·
{{ phone.preferences.settings.screenBrightness }}%
</k-block-title>
<k-list strong inset>
<k-list-item>
<template #inner>
<div class="flex w-full items-center gap-3">
<Sun :size="16" class="shrink-0 opacity-55" />
<k-range
class="w-full"
:value="phone.preferences.settings.screenBrightness"
:min="10"
:max="100"
:aria-label="phone.t('Apps.settings.screenBrightness')"
@input="updateNumberPreference('screenBrightness', $event)"
/>
<Sun :size="23" class="shrink-0" />
</div>
</template>
</k-list-item>
<k-list-item :title="phone.t('Apps.settings.rotationLock')">
<template #media>
<span class="settings-row-icon bg-[#ff9500]">
<RotateCw :size="17" :stroke-width="2.25" />
</span>
</template>
<template #after>
<k-toggle
:checked="phone.preferences.settings.rotationLocked"
:aria-label="phone.t('Apps.settings.toggle.rotationLocked')"
@change="
phone.setPreference(
'rotationLocked',
!phone.preferences.settings.rotationLocked,
)
"
/>
</template>
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.phoneScale') }} ·
{{ phone.preferences.settings.phoneScale }}%
@@ -1102,6 +1512,18 @@ onBeforeUnmount(() => {
</template>
</k-page>
<PhonePasscode
v-if="passcodeFlow"
:busy="passcodeBusy"
:error="passcodeError"
:length="passcodeLength"
:reset-key="passcodeResetKey"
:subtitle="phone.t('Apps.settings.passcode.screenSubtitle')"
:title="passcodeTitle"
@cancel="cancelPasscodeFlow"
@complete="submitSettingsPasscode"
/>
<div
v-if="factoryResetting"
class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-black px-8 text-center text-white"
@@ -1211,3 +1633,44 @@ onBeforeUnmount(() => {
{{ accountToast }}
</k-toast>
</template>
<style scoped>
.settings-row-icon {
display: flex;
width: 28px;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 7px;
color: #fff;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 35%),
0 1px 2px rgb(0 0 0 / 25%);
}
.settings-detail-navbar {
top: 0 !important;
background: rgb(248 248 248 / 94%);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
}
.settings-detail-navbar::before {
position: absolute;
right: 0;
bottom: 100%;
left: 0;
height: 44px;
background: rgb(248 248 248);
content: '';
}
.settings-detail-navbar--dark {
background: rgb(0 0 0 / 94%);
}
.settings-detail-navbar--dark::before {
background: #000;
}
</style>
+450 -6
View File
@@ -23,6 +23,135 @@ let draft = null
let mockBankBalance = 24787
let mockCashBalance = 2350
let nextBankTransactionId = 7
let mockMapMarkers = [
{
color: 'blue',
coords: { x: -75.2, y: -818.9, z: 0 },
id: 'mock-map-marker-1',
label: 'Meeting point',
},
]
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,
}
let flipTokAuthenticated = true
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,
@@ -714,6 +843,8 @@ const deviceData = {
revision: 1,
},
}
let mockPasscode = ''
let mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
let mockContacts = [
{
created_at: isoTime(-14 * 86_400_000),
@@ -1132,12 +1263,7 @@ const flareMatches = [
unread: 1,
},
]
let flareLikes = [
{
...flareSuggestions[0],
superLiked: true,
},
]
let flareLikes = [{ ...flareSuggestions[0], superLiked: true }]
let flareLastSwipe = null
const flareMessages = {
'flare-match-demo-0000-0000-0000000001': [
@@ -1340,6 +1466,236 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: message })
return
}
if (endpoint === 'fliptok:bootstrap') {
if (!flipTokAuthenticated) {
response.json({
success: true,
data: {
authenticated: false,
musicTracks: flipTokMusicTracks,
},
})
return
}
response.json({
success: true,
data: {
authenticated: true,
profile: flipTokProfile,
feed: { items: flipTokVideos, offset: 0, hasMore: false },
isAdmin: true,
musicTracks: flipTokMusicTracks,
},
})
return
}
if (endpoint === 'fliptok:login' || endpoint === 'fliptok:register') {
flipTokAuthenticated = true
response.json({ success: true })
return
}
if (endpoint === 'fliptok:logout') {
flipTokAuthenticated = false
response.json({ success: true })
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
@@ -1444,6 +1800,49 @@ app.post('/api/:endpoint', (request, response) => {
})
return
}
if (endpoint === 'map:setWaypoint') {
response.json({ success: true })
return
}
if (endpoint === 'map:markers') {
response.json({ success: true, data: mockMapMarkers })
return
}
if (endpoint === 'map:create-marker') {
const label = String(request.body.label ?? '').trim()
const color = String(request.body.color ?? '')
const coords = request.body.coords
if (!label || label.length > 40 || !coords) {
response.json({ success: false, error: 'invalid_marker' })
return
}
const marker = {
color,
coords: {
x: Number(coords.x),
y: Number(coords.y),
z: Number(coords.z) || 0,
},
id: `mock-map-marker-${Date.now()}`,
label,
}
mockMapMarkers.push(marker)
response.json({ success: true, data: marker })
return
}
if (endpoint === 'map:delete-marker') {
const previousLength = mockMapMarkers.length
mockMapMarkers = mockMapMarkers.filter(
(marker) => marker.id !== request.body.id,
)
response.json(
mockMapMarkers.length === previousLength
? { success: false, error: 'marker_not_found' }
: { success: true },
)
return
}
if (endpoint === 'darkchat:bootstrap') {
response.json({ success: true, data: darkChatBootstrap() })
return
@@ -1725,6 +2124,7 @@ app.post('/api/:endpoint', (request, response) => {
},
},
notes: mockNotes,
security: mockSecurity,
token: 'development',
},
})
@@ -1957,12 +2357,56 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: { revision } })
return
}
if (endpoint === 'security:unlock') {
response.json(
!mockSecurity.enabled || request.body.passcode === mockPasscode
? { success: true, data: { security: mockSecurity } }
: { success: false, error: 'invalid_passcode' },
)
return
}
if (endpoint === 'security:set-passcode') {
mockPasscode = String(request.body.passcode)
mockSecurity = {
enabled: true,
length: mockPasscode.length,
lockedUntil: 0,
}
response.json({ success: true, data: { security: mockSecurity } })
return
}
if (endpoint === 'security:change-passcode') {
if (request.body.currentPasscode !== mockPasscode) {
response.json({ success: false, error: 'invalid_passcode' })
return
}
mockPasscode = String(request.body.newPasscode)
mockSecurity = {
enabled: true,
length: mockPasscode.length,
lockedUntil: 0,
}
response.json({ success: true, data: { security: mockSecurity } })
return
}
if (endpoint === 'security:disable-passcode') {
if (request.body.passcode !== mockPasscode) {
response.json({ success: false, error: 'invalid_passcode' })
return
}
mockPasscode = ''
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
response.json({ success: true, data: { security: mockSecurity } })
return
}
if (endpoint === 'device:factory-reset') {
authenticated = false
linkedAccount = null
mockNotes = []
mockMedia = []
calendarEvents = []
mockPasscode = ''
mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
for (const key of Object.keys(deviceData)) delete deviceData[key]
response.json({ success: true })
return
+28
View File
@@ -19,6 +19,13 @@ Config.Phone = {
DeviceName = "iFruit Phone",
}
Config.Security = {
PasscodePepperConvar = "sky_phone_passcode_pepper",
MaximumAttempts = 5,
LockSeconds = 30,
AttemptsPerMinute = 12,
}
Config.Sim = {
RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous",
@@ -211,6 +218,27 @@ Config.LocalPages = {
CityMarktSharesPerDay = 1,
}
Config.FlipTok = {
PageSize = 12,
CaptionMaxLength = 500,
CommentMaxLength = 300,
BioMaxLength = 160,
PasswordMinLength = 8,
PasswordMaxLength = 72,
PasswordPepperConvar = "sky_phone_fliptok_password_pepper",
MaxVideoDurationMs = 300000,
MusicTracks = {},
VerifyCommand = "fliptokverify",
AdminGroups = { "admin" },
ReportAdminGroups = { "admin" },
}
Config.MapMarkers = {
MaximumMarkers = 50,
LabelMaxLength = 40,
ActionsPerMinute = 60,
}
Config.Calendar = {
TitleMaxLength = 120,
NoteMaxLength = 2000,
+72 -4
View File
@@ -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",
},
@@ -29,6 +37,11 @@ Locales["en"] = {
Notifications = { now = "now" },
LockScreen = {
label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open",
passcode = {
enter = "Enter Passcode", unlockSubtitle = "Enter the passcode for this phone.", cancel = "Cancel", delete = "Delete digit",
incorrect = "Incorrect passcode", locked = "Too many attempts. Try again in {seconds} seconds.",
tryAgain = "Try again in {seconds} seconds", rateLimited = "Too many attempts. Please wait.",
},
},
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
@@ -89,6 +102,35 @@ Locales["en"] = {
match_not_found = "This match is no longer available.", invalid_message = "Write a message before sending.", invalid_attachment = "This attachment is unavailable.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", rate_limited = "Slow down for a moment and try again.", default = "Flare could not complete the request.",
},
},
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",
authTitle = "Your FlipTok account", login = "Sign In", register = "Register", createAccount = "Create Account", logout = "Sign Out",
loginBody = "Sign in to continue with your videos, follows, and saved posts.", registerBody = "Create a private FlipTok login for this profile.",
password = "Password", confirmPassword = "Confirm password", passwordsMismatch = "The passwords do not match.",
displayNamePlaceholder = "Your name", usernamePlaceholder = "username", passwordPlaceholder = "At least 8 characters", confirmPasswordPlaceholder = "Enter password again",
registrationHint = "An iFruit account is required once to create and own a FlipTok profile.",
accountDetails = "Account details", profileDetails = "Profile details", account = "Account",
signOutTitle = "Sign out of FlipTok?", signOutBody = "Your profile and videos stay online. This phone will return to the FlipTok sign-in screen.", signingOut = "Signing Out...",
accountTypes = { person = "Person", business = "Business", organization = "Organization", media = "Media", event = "Event" },
activityKinds = { like = "liked your video", comment = "commented on your video", follow = "started following you", verified = "verification changed" },
notifications = { like = "{actor} liked your video.", comment = "{actor} commented on your video.", follow = "{actor} started following you.", verified = "Your FlipTok account is now verified.", default = "You have new FlipTok activity." },
errors = { invalid_video = "Check the video details.", invalid_media = "Choose a video from this phone.", invalid_comment = "Enter a valid comment.", comments_disabled = "Comments are disabled.", invalid_profile = "Check your profile details.", invalid_handle = "Use 324 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 872 characters.", invalid_credentials = "Username or password is incorrect.", already_registered = "This iFruit account already owns a registered FlipTok profile.", handle_taken = "This username is already taken.", video_not_found = "This video is unavailable.", blocked = "This account is blocked.", not_authorized = "You do not have moderation access.", report_not_found = "This report is no longer open.", rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to 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.",
@@ -339,7 +381,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.",
@@ -348,6 +390,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.",
@@ -514,6 +557,16 @@ Locales["en"] = {
map = {
name = "Map", controls = "Map controls", currentLocation = "Current Location",
imageError = "The map image could not be loaded.", switchStyle = "Switch Map Type",
addMarker = "Add Marker", placeMarker = "Place Marker", placeMarkerHint = "Move the map until the crosshair is over the destination.",
addHere = "Add Here", newMarker = "New Marker", newMarkerDescription = "Give this saved place a name and color.",
markerName = "Name", markerNamePlaceholder = "e.g. Meeting point", markerColor = "Marker Color",
saveMarker = "Save Marker", deleteMarker = "Delete Marker", setWaypoint = "Set Waypoint", markerSaved = "Marker saved.", markerDeleted = "Marker deleted.", waypointSet = "Waypoint set.",
colors = { blue = "Blue", green = "Green", orange = "Orange", purple = "Purple", red = "Red" },
errors = {
invalid_marker = "Enter a valid marker name and position.", marker_limit = "This phone has reached its marker limit.",
marker_not_found = "This marker no longer exists.", rate_limited = "Too many changes. Try again shortly.",
request_failed = "The marker could not be saved.",
},
styles = { default = "Default Map", satellite = "Satellite Map", atlas = "Atlas Map", roads = "Road Map" },
},
weather = {
@@ -575,11 +628,15 @@ Locales["en"] = {
accountInformation = "Account Information", accountStatus = "Account Status", accountStatusValue = "Active",
accountStorage = "Cloud Storage", accountStorageValue = "On Device", accountPurchases = "Media & Purchases",
accountPurchasesValue = "Available", notifications = "Notifications", sounds = "Sounds & Haptics",
general = "General Settings", appearance = "Appearance", allowNotifications = "Allow Notifications",
general = "General Settings", security = "Passcode & Security", appearance = "Appearance", allowNotifications = "Allow Notifications",
connectivity = "Connectivity", connections = "Connections", wifi = "Wi-Fi", bluetooth = "Bluetooth", cellular = "Cellular",
connectivityDescription = "Airplane Mode temporarily disables wireless connections. Wi-Fi, Bluetooth, and cellular settings are saved on this phone.",
focus = "Focus", focusMode = "Focus", focusDescription = "Focus silences non-critical notifications while keeping alarms and important alerts available.",
notificationSounds = "Sounds", notificationDuration = "Notification Duration", seconds = "{seconds} seconds",
ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume",
ringtone = "Ringtone", notificationSound = "Notification Sound", appearanceMode = "Appearance Mode",
automatic = "Automatic", light = "Light", dark = "Dark", phoneScale = "Phone Scale", phoneFrame = "Phone Frame",
screenBrightness = "Screen Brightness", rotationLock = "Rotation Lock",
about = "About", deviceName = "Device Name", deviceNameValue = "Sky Phone", softwareVersion = "Software Version",
language = "Language", languageValue = "English", localStorage = "Local Storage", localStorageValue = "On Device",
back = "Settings", wallpaperPicker = "Built-in Wallpapers", deviceInformation = "Device Information",
@@ -589,6 +646,15 @@ Locales["en"] = {
removeDeviceBody = "Enter your iFruit password to remove this device from the account.", signOut = "Sign Out",
factoryReset = "Erase All Content and Settings", factoryResetBody = "This removes the account and all local data from this phone. Cloud data and the IMEI remain.",
factoryResetProgress = "Erasing iFruit Phone", factoryResetWarning = "Do not turn off this phone. This takes 60 seconds.",
passcode = {
description = "A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.",
status = "Passcode", codeLength = "Code Length", sixDigit = "6-Digit Code", fourDigit = "4-Digit Code",
turnOn = "Turn Passcode On", turnOff = "Turn Passcode Off", change = "Change Passcode",
enterNew = "Enter New Passcode", confirmNew = "Verify New Passcode", enterCurrent = "Enter Current Passcode",
screenSubtitle = "Use 4 or 6 numbers.", incorrect = "Incorrect passcode.", mismatch = "The passcodes did not match.",
locked = "Too many incorrect attempts. Try again later.", rateLimited = "Too many attempts. Please wait.",
failed = "The passcode could not be updated.", saved = "Passcode saved.", disabled = "Passcode turned off.",
},
accountErrors = {
invalid_email = "Choose a valid 332 character iFruit address.", invalid_password = "Password must be 664 characters.",
invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit address is already registered.",
@@ -596,7 +662,9 @@ Locales["en"] = {
device_not_found = "That device is no longer linked.", default = "The account request failed.",
},
toggle = {
airplaneMode = "Toggle Airplane Mode", streamerMode = "Toggle Streamer Mode",
airplaneMode = "Toggle Airplane Mode", streamerMode = "Toggle Streamer Mode", focusMode = "Toggle Focus",
wifiEnabled = "Toggle Wi-Fi", bluetoothEnabled = "Toggle Bluetooth", cellularEnabled = "Toggle Cellular Data",
rotationLocked = "Toggle Rotation Lock",
notifications = "Toggle notifications for {app}", notificationSounds = "Toggle notification sounds for {app}",
},
frames = { black = "Black", blue = "Blue", green = "Green", lavender = "Lavender", white = "White" },
+3
View File
@@ -34,6 +34,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',
@@ -55,6 +56,8 @@ server_scripts {
'source/server/garage.lua',
'source/server/marketplace.lua',
'source/server/pages.lua',
'source/server/fliptok.lua',
'source/server/map.lua',
'source/server/calendar.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
+58
View File
@@ -8,6 +8,10 @@ local call_channel = 0
Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
local server_callbacks = {
"security:unlock",
"security:set-passcode",
"security:change-passcode",
"security:disable-passcode",
"device:save",
"device:factory-reset",
"account:login",
@@ -56,10 +60,35 @@ local server_callbacks = {
"pages:share-citymarkt",
"pages:react",
"pages:delete",
"fliptok:register",
"fliptok:login",
"fliptok:logout",
"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",
"calendar:delete",
"map:markers",
"map:create-marker",
"map:delete-marker",
"sim:insert",
"sim:eject",
"contacts:list",
@@ -236,6 +265,19 @@ RegisterNUICallback("map:getPlayerCoords", function(_, cb)
})
end)
RegisterNUICallback("map:setWaypoint", function(data, cb)
local coords = type(data) == "table" and data.coords or nil
local x = type(coords) == "table" and tonumber(coords.x) or nil
local y = type(coords) == "table" and tonumber(coords.y) or nil
if not x or not y or x ~= x or y ~= y or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 then
cb({ success = false, error = "invalid_marker" })
return
end
SetNewWaypoint(x, y)
cb({ success = true })
end)
local weather_types = {
[joaat("EXTRASUNNY")] = "sunny",
[joaat("CLEAR")] = "clear",
@@ -391,6 +433,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
+249
View File
@@ -208,6 +208,39 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_device_security",
columns = {
{
name = "device_imei",
type = "CHAR(15) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "passcode_hash", type = "BINARY(32) NOT NULL" },
{
name = "passcode_salt",
type = "CHAR(32) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "passcode_length", type = "TINYINT UNSIGNED NOT NULL" },
{ name = "failed_attempts", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "locked_until", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
},
primaryKey = "device_imei",
foreignKeys = {
{
column = "device_imei",
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
},
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_notes",
columns = {
@@ -650,6 +683,33 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_map_markers",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{
name = "device_imei",
type = "CHAR(15) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "label", type = "VARCHAR(40) NOT NULL" },
{ name = "color", type = "VARCHAR(16) NOT NULL" },
{ name = "position_x", type = "DOUBLE NOT NULL" },
{ name = "position_y", type = "DOUBLE NOT NULL" },
{ name = "position_z", type = "DOUBLE 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_map_markers_device", columns = "(`device_imei`, `created_at`)" },
},
foreignKeys = {
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_calendar_events",
columns = {
@@ -830,6 +890,195 @@ 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_credentials",
columns = {
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "password_hash", type = "BINARY(32) NOT NULL" },
{ name = "password_salt", type = "CHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ 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 = "profile_id",
foreignKeys = {{ 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_sessions",
columns = {
{ name = "device_imei", type = "CHAR(15) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "profile_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "device_imei",
indexes = {{ name = "idx_sky_phone_fliptok_sessions_profile", columns = "(`profile_id`, `updated_at`)" }},
foreignKeys = {
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) 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_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",
},
{
name = "sky_phone_flare_profiles",
columns = {
+648
View File
@@ -0,0 +1,648 @@
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 = {}
local password_pepper = GetConvar(Config.FlipTok.PasswordPepperConvar, "")
if password_pepper == "" then
Bridge.Debug(
"warn",
"[sky_phone] FlipTok password pepper convar '%s' is empty; configure it before production use.",
Config.FlipTok.PasswordPepperConvar,
{ always = true }
)
end
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 normalize_handle(value)
local handle = trim(value)
if not handle then return nil end
handle = handle:lower():gsub("^@", "")
if #handle < 3 or #handle > 24 or not handle:match("^[a-z0-9][a-z0-9._]*[a-z0-9]$") or handle:find("..", 1, true) then
return nil
end
return handle
end
local function valid_password(value)
local length = type(value) == "string" and utf8.len(value) or nil
return length and length >= Config.FlipTok.PasswordMinLength and length <= Config.FlipTok.PasswordMaxLength
end
local function profile_for_session(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then return nil, error_response end
local rows = Bridge.Database.Query([[SELECT p.* FROM `sky_phone_fliptok_sessions` s
JOIN `sky_phone_fliptok_profiles` p ON p.`id` = s.`profile_id`
WHERE s.`device_imei` = ? LIMIT 1]], { session.imei })
if not rows[1] then return nil, { success = false, error = "fliptok_not_authenticated" } end
return rows[1], nil
end
local function require_profile(source)
return profile_for_session(source)
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:register", function(source, data)
if not SkyPhone.AllowOperation(source, "fliptok:register", 5, 60) then
return { success = false, error = "rate_limited" }
end
local account, error_response = SkyPhone.RequireAccount(source)
if not account then return error_response end
if type(data) ~= "table" then return { success = false, error = "invalid_request" } end
local handle = normalize_handle(data.handle)
local display_name = trim(data.displayName)
if not handle then return { success = false, error = "invalid_handle" } end
if not valid_text(display_name, 1, 40) then return { success = false, error = "invalid_display_name" } end
if not valid_password(data.password) then return { success = false, error = "invalid_password" } end
local profiles = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
local profile_id = profiles[1] and tonumber(profiles[1].id) or nil
local duplicates = profile_id and Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? AND `id` <> ? LIMIT 1",
{ handle, profile_id }
) or Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `handle` = ? LIMIT 1",
{ handle }
)
if duplicates[1] then return { success = false, error = "handle_taken" } end
if profile_id then
local credentials = Bridge.Database.Query("SELECT `profile_id` FROM `sky_phone_fliptok_credentials` WHERE `profile_id` = ? LIMIT 1", { profile_id })
if credentials[1] then return { success = false, error = "already_registered" } end
Bridge.Database.Query("UPDATE `sky_phone_fliptok_profiles` SET `handle` = ?, `display_name` = ? WHERE `id` = ?", {
handle, display_name, profile_id,
})
else
local result = Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_fliptok_profiles`
(`account_id`, `handle`, `display_name`) VALUES (?, ?, ?)]], { account.id, handle, display_name })
if affected_rows(result) ~= 1 then return { success = false, error = "handle_taken" } end
local created = Bridge.Database.Query("SELECT `id` FROM `sky_phone_fliptok_profiles` WHERE `account_id` = ? LIMIT 1", { account.id })
if not created[1] then error("[sky_phone] FlipTok profile insert did not return the created profile.") end
profile_id = tonumber(created[1].id)
end
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
local salt = salts[1] and salts[1].salt
if type(salt) ~= "string" or #salt ~= 32 then error("[sky_phone] Database did not generate a FlipTok password salt.") end
local credential_result = Bridge.Database.Query([[INSERT IGNORE INTO `sky_phone_fliptok_credentials`
(`profile_id`, `password_hash`, `password_salt`) VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?)]], {
profile_id, password_pepper, salt, data.password, salt,
})
if affected_rows(credential_result) ~= 1 then return { success = false, error = "already_registered" } end
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) VALUES (?, ?)
ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP]], {
account.imei, profile_id,
})
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:login", function(source, data)
if not SkyPhone.AllowOperation(source, "fliptok:login", 10, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then return error_response end
if type(data) ~= "table" then return { success = false, error = "invalid_credentials" } end
local handle = normalize_handle(data.handle)
if not handle or not valid_password(data.password) then
return { success = false, error = "invalid_credentials" }
end
local profiles = Bridge.Database.Query([[SELECT p.`id` FROM `sky_phone_fliptok_profiles` p
JOIN `sky_phone_fliptok_credentials` c ON c.`profile_id` = p.`id`
WHERE p.`handle` = ?
AND c.`password_hash` = UNHEX(SHA2(CONCAT(?, c.`password_salt`, ?), 256))
LIMIT 1]], { handle, password_pepper, data.password })
if not profiles[1] then return { success = false, error = "invalid_credentials" } end
Bridge.Database.Query([[INSERT INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) VALUES (?, ?)
ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP]], {
session.imei, profiles[1].id,
})
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:logout", function(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then return error_response end
Bridge.Database.Query("DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?", { session.imei })
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:fliptok:bootstrap", function(source)
local profile, error_response = require_profile(source)
if not profile then
if error_response.error == "fliptok_not_authenticated" then
return { success = true, data = { authenticated = false, musicTracks = music_track_list } }
end
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 = {
authenticated = true,
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)
+161
View File
@@ -0,0 +1,161 @@
Bridge.Database.AfterMigration("sky_phone", function()
local marker_colors = {
blue = true,
green = true,
orange = true,
purple = true,
red = true,
}
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function text_length(value)
return type(value) == "string" and utf8.len(value) or nil
end
local function marker_dto(row)
return {
id = row.id,
label = row.label,
color = row.color,
coords = {
x = tonumber(row.position_x) or 0.0,
y = tonumber(row.position_y) or 0.0,
z = tonumber(row.position_z) or 0.0,
},
}
end
local function validate_marker(data)
if type(data) ~= "table" or type(data.coords) ~= "table" then
return nil
end
local label = type(data.label) == "string" and data.label:match("^%s*(.-)%s*$") or nil
local label_length = text_length(label)
local color = data.color
local x = tonumber(data.coords.x)
local y = tonumber(data.coords.y)
local z = tonumber(data.coords.z)
if not label_length
or label_length < 1
or label_length > Config.MapMarkers.LabelMaxLength
or not marker_colors[color]
or not x
or not y
or (data.coords.z ~= nil and not z)
or x ~= x
or y ~= y
or (z and z ~= z)
or math.abs(x) > 10000.0
or math.abs(y) > 10000.0
or (z and z < -1000.0)
or (z and z > 3000.0)
then
return nil
end
return {
label = label,
color = color,
x = x,
y = y,
z = z or 0.0,
}
end
Bridge.Callbacks.Register("sky_phone:map:markers", function(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local rows = Bridge.Database.Query([[
SELECT `id`, `label`, `color`, `position_x`, `position_y`, `position_z`
FROM `sky_phone_map_markers`
WHERE `device_imei` = ?
ORDER BY `created_at`, `id`
LIMIT ?
]], { session.imei, Config.MapMarkers.MaximumMarkers })
local markers = {}
for index = 1, #rows do
markers[index] = marker_dto(rows[index])
end
return { success = true, data = markers }
end)
Bridge.Callbacks.Register("sky_phone:map:create-marker", function(source, data)
if not SkyPhone.AllowOperation(source, "map_marker_write", Config.MapMarkers.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local marker = validate_marker(data)
if not marker then
return { success = false, error = "invalid_marker" }
end
local count_rows = Bridge.Database.Query([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_map_markers`
WHERE `device_imei` = ?
]], { session.imei })
if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.MapMarkers.MaximumMarkers then
return { success = false, error = "marker_limit" }
end
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = ids[1] and ids[1].id
if type(id) ~= "string" then
error("[sky_phone] Database did not generate a map marker id.")
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_map_markers`
(`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], { id, session.imei, marker.label, marker.color, marker.x, marker.y, marker.z })
return {
success = true,
data = {
id = id,
label = marker.label,
color = marker.color,
coords = { x = marker.x, y = marker.y, z = marker.z },
},
}
end)
Bridge.Callbacks.Register("sky_phone:map:delete-marker", function(source, data)
if not SkyPhone.AllowOperation(source, "map_marker_write", Config.MapMarkers.ActionsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
if type(data) ~= "table" or type(data.id) ~= "string" or #data.id ~= 36 then
return { success = false, error = "invalid_marker" }
end
local result = Bridge.Database.Query(
"DELETE FROM `sky_phone_map_markers` WHERE `id` = ? AND `device_imei` = ?",
{ data.id, session.imei }
)
if affected_rows(result) ~= 1 then
return { success = false, error = "marker_not_found" }
end
return { success = true }
end)
end)
+228 -2
View File
@@ -7,6 +7,15 @@ local sessions = {}
local auth_attempts = {}
local operation_attempts = {}
local max_device_data_bytes = 100000
local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "")
if passcode_pepper == "" then
Bridge.Debug(
"warn",
"[sky_phone] Passcode pepper convar '%s' is empty; configure it before production use.",
Config.Security.PasscodePepperConvar,
{ always = true }
)
end
local allowed_device_namespaces = {
settings = true,
notifications = true,
@@ -234,6 +243,98 @@ local function load_device_data(imei)
return data
end
local function load_device_security(imei)
local rows = Bridge.Database.Query([[
SELECT `passcode_length`, `failed_attempts`, `locked_until`
FROM `sky_phone_device_security`
WHERE `device_imei` = ?
LIMIT 1
]], { imei })
return rows[1]
end
local function security_status(imei)
local security = load_device_security(imei)
return {
enabled = security ~= nil,
length = security and tonumber(security.passcode_length) or nil,
lockedUntil = security and tonumber(security.locked_until) or 0,
}
end
local function valid_passcode(value)
return type(value) == "string"
and (#value == 4 or #value == 6)
and value:match("^%d+$") ~= nil
end
local function passcode_matches(imei, passcode)
local rows = Bridge.Database.Query([[
SELECT 1 AS `matches`
FROM `sky_phone_device_security`
WHERE `device_imei` = ?
AND `passcode_hash` = UNHEX(SHA2(CONCAT(?, `passcode_salt`, ?), 256))
LIMIT 1
]], { imei, passcode_pepper, passcode })
return rows[1] ~= nil
end
local function verify_passcode(session, passcode)
if not valid_passcode(passcode) then
return false, { success = false, error = "invalid_passcode" }
end
local security = load_device_security(session.imei)
if not security then
return false, { success = false, error = "passcode_not_set" }
end
local now = os.time()
local locked_until = tonumber(security.locked_until) or 0
if locked_until > now then
return false, {
success = false,
error = "passcode_locked",
data = { retryAfter = locked_until - now },
}
end
if passcode_matches(session.imei, passcode) then
Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `failed_attempts` = 0, `locked_until` = 0
WHERE `device_imei` = ?
]], { session.imei })
return true
end
local failed_attempts = (tonumber(security.failed_attempts) or 0) + 1
if failed_attempts >= Config.Security.MaximumAttempts then
local next_unlock = now + Config.Security.LockSeconds
Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `failed_attempts` = 0, `locked_until` = ?
WHERE `device_imei` = ?
]], { next_unlock, session.imei })
return false, {
success = false,
error = "passcode_locked",
data = { retryAfter = Config.Security.LockSeconds },
}
end
Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `failed_attempts` = ?
WHERE `device_imei` = ?
]], { failed_attempts, session.imei })
return false, {
success = false,
error = "invalid_passcode",
data = { attemptsRemaining = Config.Security.MaximumAttempts - failed_attempts },
}
end
local function account_devices(account_id, current_imei)
local rows = Bridge.Database.Query([[
SELECT `imei`, `device_name`, `created_at`, `updated_at`
@@ -248,7 +349,7 @@ local function account_devices(account_id, current_imei)
end
local function bootstrap(source)
local session, error_response = SkyPhone.RequireSession(source)
local session, error_response = SkyPhone.RequireDeviceSession(source)
if not session then
return nil, error_response
end
@@ -260,6 +361,7 @@ local function bootstrap(source)
return {
token = session.token,
security = security_status(device.imei),
device = {
imei = device.imei,
name = device.device_name,
@@ -417,7 +519,7 @@ local function authenticate(source, data, registering)
return link_account(source, accounts[1])
end
function SkyPhone.RequireSession(source)
function SkyPhone.RequireDeviceSession(source)
local session = sessions[source]
if not session then
return nil, { success = false, error = "device_not_open" }
@@ -433,6 +535,17 @@ function SkyPhone.RequireSession(source)
return session
end
function SkyPhone.RequireSession(source)
local session, error_response = SkyPhone.RequireDeviceSession(source)
if not session then
return nil, error_response
end
if not session.unlocked then
return nil, { success = false, error = "device_locked" }
end
return session
end
function SkyPhone.AllowOperation(source, operation, maximum, window_seconds)
local now = os.time()
operation_attempts[source] = operation_attempts[source] or {}
@@ -565,10 +678,12 @@ local function open_phone(source, used_item)
return false
end
local security = load_device_security(imei)
sessions[source] = {
imei = imei,
slot = slot.slot,
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
unlocked = security == nil,
}
local payload = bootstrap(source)
Bridge.Debug(
@@ -590,10 +705,12 @@ function SkyPhone.OpenDeviceForCall(source, imei)
Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source))
return false
end
local security = load_device_security(imei)
sessions[source] = {
imei = imei,
slot = matches[1].slot,
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
unlocked = security == nil,
}
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
return true
@@ -619,6 +736,106 @@ Bridge.Callbacks.Register("sky_phone:device:close", function(source)
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data)
if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireDeviceSession(source)
if not session then
return error_response
end
if session.unlocked then
return { success = true, data = { security = security_status(session.imei) } }
end
local verified, verification_error = verify_passcode(session, data and data.passcode)
if not verified then
return verification_error
end
session.unlocked = true
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:security:set-passcode", function(source, data)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local passcode = data and data.passcode
if not valid_passcode(passcode) then
return { success = false, error = "invalid_passcode" }
end
if load_device_security(session.imei) then
return { success = false, error = "passcode_already_set" }
end
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
local salt = salts[1] and salts[1].salt
if type(salt) ~= "string" or #salt ~= 32 then
error("[sky_phone] Database did not generate a valid passcode salt.")
end
local result = Bridge.Database.Query([[
INSERT INTO `sky_phone_device_security`
(`device_imei`, `passcode_hash`, `passcode_salt`, `passcode_length`)
VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?, ?)
]], { session.imei, passcode_pepper, salt, passcode, salt, #passcode })
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:security:change-passcode", function(source, data)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local new_passcode = data and data.newPasscode
if not valid_passcode(new_passcode) then
return { success = false, error = "invalid_passcode" }
end
local verified, verification_error = verify_passcode(session, data and data.currentPasscode)
if not verified then
return verification_error
end
local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {})
local salt = salts[1] and salts[1].salt
if type(salt) ~= "string" or #salt ~= 32 then
error("[sky_phone] Database did not generate a valid passcode salt.")
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_device_security`
SET `passcode_hash` = UNHEX(SHA2(CONCAT(?, ?, ?), 256)),
`passcode_salt` = ?, `passcode_length` = ?, `failed_attempts` = 0, `locked_until` = 0
WHERE `device_imei` = ?
]], { passcode_pepper, salt, new_passcode, salt, #new_passcode, session.imei })
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:security:disable-passcode", function(source, data)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local verified, verification_error = verify_passcode(session, data and data.passcode)
if not verified then
return verification_error
end
local result = Bridge.Database.Query(
"DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
{ session.imei }
)
if affected_rows(result) ~= 1 then
return { success = false, error = "request_failed" }
end
session.unlocked = true
return { success = true, data = { security = security_status(session.imei) } }
end)
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
if not Config.Phone.DevelopmentCommand then
return { success = false, error = "disabled" }
@@ -764,6 +981,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
end
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
if not Bridge.Database.Transaction({
{
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
params = { session.imei },
@@ -784,6 +1005,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
params = { Config.Phone.DeviceName, session.imei },
@@ -792,6 +1017,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
return { success = false, error = "request_failed" }
end
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
session.unlocked = true
refresh_source(source)
return { success = true }
end)
+105
View File
@@ -92,6 +92,18 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_data` (
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_device_security` (
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`passcode_hash` BINARY(32) NOT NULL,
`passcode_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`passcode_length` TINYINT UNSIGNED NOT NULL,
`failed_attempts` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`locked_until` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`device_imei`),
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_notes` (
`id` VARCHAR(64) NOT NULL,
`account_id` BIGINT UNSIGNED NULL,
@@ -229,6 +241,99 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calendar_events` (
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_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_credentials` (
`profile_id` BIGINT UNSIGNED NOT NULL, `password_hash` BINARY(32) NOT NULL,
`password_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`profile_id`),
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_sessions` (
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `profile_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`device_imei`), KEY `idx_sky_phone_fliptok_sessions_profile` (`profile_id`,`updated_at`),
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) 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_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;
CREATE TABLE IF NOT EXISTS `sky_phone_flare_profiles` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` BIGINT UNSIGNED NOT NULL,