MERGE - resolve dev integration conflicts
@@ -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.
|
||||
|
||||
@@ -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 |
|
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 |
|
After Width: | Height: | Size: 8.5 KiB |
|
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 |
|
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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 ?? '--' }}° 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)"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 3–24 letters, numbers, dots, or underscores.',
|
||||
invalid_display_name: 'Enter a display name.',
|
||||
invalid_password: 'Password must be 8–72 characters.',
|
||||
invalid_credentials: 'Username or password is incorrect.',
|
||||
already_registered:
|
||||
'This 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 3–32 character iFruit address.',
|
||||
invalid_password: 'Password must be 6–64 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)
|
||||
|
||||
@@ -27,6 +27,7 @@ export type PhoneAppId =
|
||||
| 'citymarkt'
|
||||
| 'local-pages'
|
||||
| 'flare'
|
||||
| 'fliptok'
|
||||
|
||||
export type LaunchablePhoneAppId = PhoneAppId
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'>
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 3–24 letters, numbers, dots, or underscores.", invalid_display_name = "Enter a display name.", invalid_password = "Password must be 8–72 characters.", invalid_credentials = "Username or password is incorrect.", already_registered = "This 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 3–32 character iFruit address.", invalid_password = "Password must be 6–64 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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||