MERGE - resolve dev integration conflicts

This commit is contained in:
Dominik
2026-08-09 05:36:10 +02:00
54 changed files with 8188 additions and 307 deletions
+178 -6
View File
@@ -14,6 +14,7 @@ import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
import PhonePasscode from '@/components/PhonePasscode.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
@@ -30,6 +31,7 @@ import { useMailStore } from '@/stores/mail'
import { useMessagesStore } from '@/stores/messages'
import { useDarkChatStore } from '@/stores/darkchat'
import { useFlareStore } from '@/stores/flare'
import { useFlipTokStore } from '@/stores/fliptok'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppStoreStore } from '@/stores/app-store'
@@ -60,6 +62,8 @@ type AppMessage = {
| MessagesEventData
| DarkChatEventData
| FlareEventData
| FlipTokVerificationData
| FlipTokNotificationData
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
@@ -124,6 +128,20 @@ type CalendarReminderData = {
text?: string
title?: string
}
type FlipTokVerificationData = {
profileId: number
verified: boolean
}
type FlipTokNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
kind?: 'like' | 'comment' | 'follow' | 'verified'
text?: string
title?: string
videoId?: string
}
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
@@ -140,6 +158,7 @@ const mail = useMailStore()
const messages = useMessagesStore()
const darkchat = useDarkChatStore()
const flare = useFlareStore()
const fliptok = useFlipTokStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
const appStore = useAppStoreStore()
@@ -155,6 +174,13 @@ const appTransitionName = computed(() =>
)
const isLocked = ref(false)
const isUnlocking = ref(false)
const passcodeBusy = ref(false)
const passcodeError = ref('')
const passcodeResetKey = ref(0)
const passcodeRetrySeconds = ref(0)
const passcodeVisible = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
const simPicker = ref<SimPickerPayload | null>(null)
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
@@ -180,6 +206,7 @@ const phoneFrameImage = computed(
)
let clockTicker: ReturnType<typeof setInterval> | undefined
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
function getViewportScale(): number {
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
@@ -197,12 +224,17 @@ function hydratePhone(payload: PhoneOpenPayload): void {
media.hydrate(payload.device?.data.media?.payload)
appStore.hydrate(payload.device?.data.apps?.payload)
widgets.hydrate(payload.device?.data.widgets?.payload)
void mail.bootstrap(payload.account?.email ?? '')
if (payload.account?.email) void marketplace.loadCounts()
}
function loadUnlockedPhoneData(): void {
if (unlockedServicesLoaded.value) return
unlockedServicesLoaded.value = true
void mail.bootstrap(account.email)
if (account.email) void marketplace.loadCounts()
else marketplace.setCounts({ active: 0, unread: 0 })
void calls.bootstrap()
void messages.loadConversations()
if (payload.account?.email) void darkchat.bootstrap()
if (account.email) void darkchat.bootstrap()
}
async function hydrateDevelopmentPhone(): Promise<void> {
@@ -274,6 +306,32 @@ function onMessage(event: MessageEvent<AppMessage>): void {
} else if (event.data?.type === 'marketplace:changed' && event.data.data) {
const data = event.data.data as MarketplaceEventData
if (data.counts) marketplace.setCounts(data.counts)
} else if (
event.data?.type === 'fliptok:verification-changed' &&
event.data.data
) {
const data = event.data.data as FlipTokVerificationData
fliptok.applyVerification(Number(data.profileId), data.verified === true)
} else if (event.data?.type === 'fliptok:new' && event.data.data) {
const data = event.data.data as FlipTokNotificationData
const notification: PhoneNotificationInput = {
appId: 'fliptok',
subtitle: data.actor,
text: data.text ?? phone.t('Apps.fliptok.notifications.default'),
title: data.title ?? phone.t('Apps.fliptok.name'),
}
if (
data.device &&
(!phone.isOpen || data.device.imei !== phone.device?.imei)
) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
preferences: parsePhonePreferences(data.device.settings ?? null),
}
}
notifications.show(notification)
if (phone.isOpen) void fliptok.loadActivities()
} else if (
event.data?.type === 'marketplace:new-message' &&
event.data.data
@@ -442,8 +500,11 @@ function onMessage(event: MessageEvent<AppMessage>): void {
) {
calls.applyCallState(event.data.data as PhoneCall)
controlCenterOpened.value = false
isLocked.value = false
isUnlocking.value = false
if (!phone.security.enabled) {
isLocked.value = false
isUnlocking.value = false
loadUnlockedPhoneData()
}
window.setTimeout(() => void router.push('/apps/phone'), 0)
} else if (event.data?.type === 'sim:picker' && event.data.data) {
simPicker.value = event.data.data as unknown as SimPickerPayload
@@ -470,14 +531,78 @@ function updateViewportScale(): void {
viewportScale.value = getViewportScale()
}
function unlockPhone(): void {
function finishUnlock(): void {
if (!isLocked.value) return
isUnlocking.value = true
isLocked.value = false
passcodeVisible.value = false
passcodeError.value = ''
unlockTimer = window.setTimeout(() => {
isUnlocking.value = false
}, 720)
if (pendingUnlockRoute.value) {
const routePath = pendingUnlockRoute.value
pendingUnlockRoute.value = null
window.setTimeout(() => void router.push(routePath), 0)
}
loadUnlockedPhoneData()
}
function unlockPhone(): void {
if (!isLocked.value) return
if (phone.security.enabled) {
passcodeError.value = ''
passcodeVisible.value = true
return
}
finishUnlock()
}
function cancelPasscode(): void {
if (passcodeBusy.value) return
passcodeVisible.value = false
passcodeError.value = ''
pendingUnlockRoute.value = null
}
function startPasscodeLock(seconds: number): void {
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
passcodeRetrySeconds.value = Math.max(1, Math.ceil(seconds))
passcodeLockTimer = window.setInterval(() => {
passcodeRetrySeconds.value = Math.max(0, passcodeRetrySeconds.value - 1)
if (passcodeRetrySeconds.value === 0 && passcodeLockTimer !== undefined) {
window.clearInterval(passcodeLockTimer)
passcodeLockTimer = undefined
passcodeError.value = ''
}
}, 1000)
}
async function submitUnlockPasscode(passcode: string): Promise<void> {
if (passcodeBusy.value || passcodeRetrySeconds.value > 0) return
passcodeBusy.value = true
const response = await phone.unlockWithPasscode(passcode)
passcodeBusy.value = false
if (response.success) {
finishUnlock()
return
}
passcodeResetKey.value += 1
if (response.error === 'passcode_locked') {
startPasscodeLock(response.data?.retryAfter ?? 30)
passcodeError.value = phone.t('LockScreen.passcode.locked', {
seconds: String(response.data?.retryAfter ?? 30),
})
return
}
if (response.error === 'rate_limited') {
passcodeError.value = phone.t('LockScreen.passcode.rateLimited')
return
}
passcodeError.value = phone.t('LockScreen.passcode.incorrect')
}
function toggleControlCenter(): void {
@@ -486,6 +611,11 @@ function toggleControlCenter(): void {
}
function unlockCamera(): void {
if (phone.security.enabled) {
pendingUnlockRoute.value = '/apps/camera'
unlockPhone()
return
}
unlockPhone()
window.setTimeout(() => void router.push('/apps/camera'), 0)
}
@@ -574,12 +704,33 @@ watch(
controlCenterOpened.value = false
isLocked.value = false
isUnlocking.value = false
passcodeVisible.value = false
passcodeBusy.value = false
passcodeError.value = ''
pendingUnlockRoute.value = null
unlockedServicesLoaded.value = false
if (passcodeLockTimer !== undefined) {
window.clearInterval(passcodeLockTimer)
passcodeLockTimer = undefined
}
return
}
isLocked.value = true
unlockedServicesLoaded.value = false
controlCenterOpened.value = false
weather.start()
isUnlocking.value = false
passcodeVisible.value = false
passcodeBusy.value = false
passcodeError.value = ''
passcodeResetKey.value += 1
passcodeRetrySeconds.value = Math.max(
0,
(phone.security.lockedUntil ?? 0) - Math.floor(Date.now() / 1000),
)
if (passcodeRetrySeconds.value > 0) {
startPasscodeLock(passcodeRetrySeconds.value)
}
phone.setLaunchOrigin(null)
void router.replace('/')
},
@@ -596,6 +747,7 @@ onBeforeUnmount(() => {
weather.stop()
if (clockTicker) clearInterval(clockTicker)
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale)
@@ -687,6 +839,26 @@ onBeforeUnmount(() => {
@unlock="unlockPhone"
/>
</Transition>
<Transition name="lock-screen">
<PhonePasscode
v-if="isLocked && passcodeVisible"
:busy="passcodeBusy"
:disabled="passcodeRetrySeconds > 0"
:error="passcodeError"
:length="phone.security.length ?? 6"
:reset-key="passcodeResetKey"
:subtitle="
passcodeRetrySeconds > 0
? phone.t('LockScreen.passcode.tryAgain', {
seconds: String(passcodeRetrySeconds),
})
: phone.t('LockScreen.passcode.unlockSubtitle')
"
:title="phone.t('LockScreen.passcode.enter')"
@cancel="cancelPasscode"
@complete="submitUnlockPasscode"
/>
</Transition>
<PhoneNotifications
:notification="notifications.current"
@close="notifications.dismissCurrent()"
@@ -1,16 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<linearGradient id="bg" x1="16" y1="8" x2="112" y2="120" gradientUnits="userSpaceOnUse">
<stop stop-color="#3b82f6"/>
<stop offset="0.52" stop-color="#1857d9"/>
<stop offset="1" stop-color="#081b55"/>
</linearGradient>
<linearGradient id="shine" x1="26" y1="18" x2="99" y2="106" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity=".95"/>
<stop offset="1" stop-color="#cfe1ff" stop-opacity=".82"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="url(#bg)"/>
<circle cx="103" cy="22" r="35" fill="#6aa4ff" opacity=".2"/>
<path d="M26 52 64 29l38 23v8H26v-8Zm7 15h10v25H33V67Zm21 0h10v25H54V67Zm21 0h10v25H75V67Zm21 0h-1v25h-9V67h10ZM25 98h78v10H25V98Z" fill="url(#shine)"/>
</svg>

Before

Width:  |  Height:  |  Size: 846 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

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

Before

Width:  |  Height:  |  Size: 622 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

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

After

Width:  |  Height:  |  Size: 1.8 KiB

+25 -47
View File
@@ -4153,29 +4153,23 @@ button {
align-items: center;
gap: 9px;
}
.messages-inbox-toolbar label {
height: 40px;
padding: 0 12px;
display: flex;
flex: 1;
align-items: center;
gap: 7px;
border: 1px solid #ededf0;
border-radius: 22px;
background: rgb(255 255 255 / 94%);
box-shadow: 0 7px 25px rgb(0 0 0 / 8%);
color: #9b9ba0;
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
.messages-inbox-toolbar input {
.messages-inbox-search {
position: relative;
min-width: 0;
height: 40px;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: #111;
font-size: 13px;
}
.messages-inbox-search__voice {
position: absolute;
z-index: 45;
top: 50%;
right: 8px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: currentColor;
transform: translateY(-50%);
}
.messages-inbox-toolbar > button {
width: 40px;
@@ -4380,11 +4374,9 @@ button {
}
.messages-chat-header__name strong {
min-width: 0;
overflow: hidden;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.messages-chat-header__name svg {
@@ -5166,7 +5158,6 @@ button {
backdrop-filter: blur(22px) saturate(175%);
-webkit-backdrop-filter: blur(22px) saturate(175%);
}
.messages-inbox-toolbar label,
.messages-edit-toolbar,
.messages-messagebar,
.messages-chat-header__name {
@@ -5533,7 +5524,6 @@ button {
.phone-app.dark .messages-contact-details__fields span {
color: #98989f;
}
.phone-app.dark .messages-inbox-toolbar label,
.phone-app.dark .messages-edit-toolbar,
.phone-app.dark .messages-messagebar,
.phone-app.dark .messages-chat-header__name {
@@ -5543,7 +5533,6 @@ button {
inset 0 1px 0 rgb(255 255 255 / 10%),
0 8px 26px rgb(0 0 0 / 32%);
}
.phone-app.dark .messages-inbox-toolbar input,
.phone-app.dark .messages-messagebar textarea,
.phone-app.dark .messages-gif-search input,
.phone-app.dark .messages-full-emoji-picker__search input {
@@ -5636,6 +5625,11 @@ button {
}
.messages-thread-page .messages-chat-header__contact {
top: 4px;
left: 50%;
width: 280px;
min-width: 280px;
margin-left: -140px;
transform: none;
}
.messages-thread-page .messages-avatar--header {
width: 64px;
@@ -5644,7 +5638,8 @@ button {
}
.messages-thread-page .messages-chat-header__name {
min-width: 0;
max-width: 230px;
width: max-content;
max-width: calc(100% - 8px);
height: 32px;
padding: 0 11px;
justify-content: center;
@@ -6047,29 +6042,13 @@ button {
height: 50px;
gap: 10px;
}
.messages-inbox-page .messages-inbox-toolbar label {
.messages-inbox-page .messages-inbox-search {
height: 48px;
padding: 0 14px;
border-color: var(--messages-inbox-control-border);
border-radius: 25px;
background: var(--messages-inbox-control);
box-shadow: var(--messages-inbox-control-shadow);
color: var(--messages-inbox-secondary);
}
.messages-inbox-page .messages-inbox-toolbar label > svg:first-child {
width: 22px;
height: 22px;
.messages-inbox-page .messages-inbox-search__voice {
color: var(--messages-inbox-primary);
}
.messages-inbox-page .messages-inbox-toolbar label > svg:last-child {
width: 21px;
height: 21px;
color: var(--messages-inbox-primary);
}
.messages-inbox-page .messages-inbox-toolbar input {
color: var(--messages-inbox-primary);
font-size: 16px;
}
.messages-inbox-page .messages-inbox-toolbar > button {
width: 48px;
height: 48px;
@@ -6100,8 +6079,7 @@ button {
--messages-inbox-secondary: #8e8e93;
--messages-inbox-separator: #2c2c2e;
}
.phone-app.dark .messages-inbox-page .messages-inbox-header,
.phone-app.dark .messages-inbox-page .messages-inbox-toolbar label {
.phone-app.dark .messages-inbox-page .messages-inbox-header {
background: var(--messages-inbox-control);
box-shadow: none;
}
+40 -5
View File
@@ -23,6 +23,7 @@ let renderFrameId: number | undefined
let lastRenderAt = 0
let recorder: MediaRecorder | null = null
let stream: MediaStream | null = null
let microphoneStream: MediaStream | null = null
let chunks: RecordingChunk[] = []
let lastChunkAt = 0
let lastChunkTimecode: number | null = null
@@ -95,7 +96,9 @@ function resetRecording(): void {
function stopTracks(): void {
stream?.getTracks().forEach((track) => track.stop())
microphoneStream?.getTracks().forEach((track) => track.stop())
stream = null
microphoneStream = null
}
function cleanupRecording(): void {
@@ -109,7 +112,7 @@ function cleanupRecording(): void {
postRecordState(false)
}
function startRecording(data: Record<string, unknown>): void {
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (recorder) return
if (typeof MediaRecorder === 'undefined') {
window.postMessage(
@@ -127,13 +130,45 @@ function startRecording(data: Record<string, unknown>): void {
}
startRenderLoop()
resetRecording()
stream = canvasRef.value?.captureStream(captureFps) ?? null
if (!stream) {
const videoStream = canvasRef.value?.captureStream(captureFps) ?? null
if (!videoStream) {
cleanupRecording()
return
}
if (data.microphoneEnabled === true) {
try {
microphoneStream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
} catch {
videoStream.getTracks().forEach((track) => track.stop())
cleanupRecording()
window.postMessage(
{
data: { error: 'microphone_unavailable', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
}
stream = new MediaStream([
...videoStream.getVideoTracks(),
...(microphoneStream?.getAudioTracks() ?? []),
])
const mimeType = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp8',
'video/webm',
].find((type) => MediaRecorder.isTypeSupported(type))
recorder = new MediaRecorder(stream, {
mimeType: 'video/webm',
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 128_000,
videoBitsPerSecond: bitrateBps,
})
recorder.ondataavailable = (event) => {
@@ -311,7 +346,7 @@ function onMessage(event: MessageEvent): void {
type?: string
}
if (message.type === 'camera:recordStart') {
startRecording(message.data ?? {})
void startRecording(message.data ?? {})
} else if (message.type === 'camera:recordStop') {
void stopRecording(message.data ?? {})
} else if (message.type === 'camera:recordCancel') {
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { Delete } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { usePhoneStore } from '@/stores/phone'
const props = withDefaults(
defineProps<{
busy?: boolean
cancelable?: boolean
disabled?: boolean
error?: string
length: 4 | 6
resetKey?: number
subtitle?: string
title: string
}>(),
{
busy: false,
cancelable: true,
disabled: false,
error: '',
resetKey: 0,
subtitle: '',
},
)
const emit = defineEmits<{
cancel: []
complete: [passcode: string]
}>()
const phone = usePhoneStore()
const digits = ref('')
const keypad = [1, 2, 3, 4, 5, 6, 7, 8, 9]
const inputDisabled = computed(() => props.busy || props.disabled)
function enterDigit(digit: number): void {
if (inputDisabled.value || digits.value.length >= props.length) return
digits.value += String(digit)
if (digits.value.length === props.length) emit('complete', digits.value)
}
function removeDigit(): void {
if (inputDisabled.value) return
digits.value = digits.value.slice(0, -1)
}
watch(
() => props.resetKey,
() => {
digits.value = ''
},
)
</script>
<template>
<section class="passcode-screen" :aria-label="title">
<header class="passcode-screen__header">
<h1>{{ title }}</h1>
<p v-if="subtitle">{{ subtitle }}</p>
<div class="passcode-screen__dots" aria-hidden="true">
<span
v-for="index in length"
:key="index"
:class="{ 'passcode-screen__dot--filled': digits.length >= index }"
></span>
</div>
<p
v-if="error"
class="passcode-screen__error"
role="alert"
>
{{ error }}
</p>
</header>
<div class="passcode-screen__keypad">
<button
v-for="digit in keypad"
:key="digit"
type="button"
:disabled="inputDisabled"
@click="enterDigit(digit)"
>
{{ digit }}
</button>
<button
type="button"
class="passcode-screen__action"
:disabled="!cancelable || busy"
@click="emit('cancel')"
>
{{ cancelable ? phone.t('LockScreen.passcode.cancel') : '' }}
</button>
<button type="button" :disabled="inputDisabled" @click="enterDigit(0)">
0
</button>
<button
type="button"
class="passcode-screen__action"
:aria-label="phone.t('LockScreen.passcode.delete')"
:disabled="inputDisabled || digits.length === 0"
@click="removeDigit"
>
<Delete :size="25" :stroke-width="1.7" aria-hidden="true" />
</button>
</div>
</section>
</template>
<style scoped>
.passcode-screen {
position: absolute;
inset: 0;
z-index: 90;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 76px 28px 30px;
color: white;
background:
radial-gradient(circle at 50% 16%, rgb(76 92 132 / 46%), transparent 35%),
linear-gradient(160deg, #182139, #080b12 72%);
user-select: none;
}
.passcode-screen__header {
min-height: 170px;
text-align: center;
}
.passcode-screen__header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
}
.passcode-screen__header p {
max-width: 270px;
margin: 8px auto 0;
color: rgb(255 255 255 / 72%);
font-size: 13px;
line-height: 1.35;
}
.passcode-screen__dots {
display: flex;
justify-content: center;
gap: 14px;
margin-top: 28px;
}
.passcode-screen__dots span {
width: 12px;
height: 12px;
border: 1.5px solid rgb(255 255 255 / 78%);
border-radius: 50%;
transition: background-color 120ms ease, transform 120ms ease;
}
.passcode-screen__dots .passcode-screen__dot--filled {
background: white;
transform: scale(1.06);
}
.passcode-screen__header .passcode-screen__error {
color: #ff9b93;
font-weight: 500;
}
.passcode-screen__keypad {
display: grid;
grid-template-columns: repeat(3, 72px);
gap: 15px 20px;
align-items: center;
justify-items: center;
}
.passcode-screen__keypad button:not(.passcode-screen__action) {
width: 72px;
height: 72px;
border: 0;
border-radius: 50%;
color: white;
background: rgb(255 255 255 / 16%);
font-size: 30px;
font-weight: 400;
backdrop-filter: blur(18px);
transition: background-color 100ms ease, transform 100ms ease;
}
.passcode-screen__keypad button:not(.passcode-screen__action):active {
background: rgb(255 255 255 / 34%);
transform: scale(0.96);
}
.passcode-screen__keypad button:disabled {
opacity: 0.45;
}
.passcode-screen__keypad .passcode-screen__action {
display: flex;
align-items: center;
justify-content: center;
min-width: 72px;
min-height: 48px;
border: 0;
color: white;
background: transparent;
font-size: 14px;
}
</style>
+161 -35
View File
@@ -111,6 +111,9 @@ const forecastLow = computed(() =>
const weatherIcon = computed(
() => weatherIcons[weather.forecast.value?.condition ?? 'partly_cloudy'],
)
const visibleHourlyWeather = computed(
() => weather.forecast.value?.hourly.slice(0, 5) ?? [],
)
const balance = computed(() =>
props.instance.settings.balanceSource === 'cash'
? bank.overview.value.cash
@@ -148,6 +151,13 @@ function avatar(name: string): string {
return name.trim().charAt(0).toLocaleUpperCase(phone.lang) || '?'
}
function formatForecastHour(timestamp: number): string {
return new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
minute: '2-digit',
}).format(timestamp)
}
function clearHold(): void {
if (holdTimer !== undefined) window.clearTimeout(holdTimer)
holdTimer = undefined
@@ -326,6 +336,13 @@ onBeforeUnmount(() => {
<small v-if="instance.size !== 'small'" class="widget-weather-range">
H: {{ forecastHigh ?? '--' }}° &nbsp; L: {{ forecastLow ?? '--' }}°
</small>
<div v-if="instance.size !== 'small'" class="widget-weather-hourly">
<div v-for="hour in visibleHourlyWeather" :key="hour.timestamp">
<time>{{ formatForecastHour(hour.timestamp) }}</time>
<component :is="weatherIcons[hour.condition]" :size="21" />
<strong>{{ hour.temperature }}°</strong>
</div>
</div>
</template>
<template v-else-if="instance.kind === 'music'">
@@ -492,30 +509,46 @@ onBeforeUnmount(() => {
.home-widget {
width: 100%;
height: 100%;
padding: 14px;
padding: 15px;
overflow: hidden;
border: 0.5px solid rgb(255 255 255 / 18%);
border-radius: 22px;
border: 0.75px solid rgb(255 255 255 / 17%);
border-radius: 25px;
outline: none;
color: #fff;
background: rgb(28 28 30 / 76%);
background: rgb(25 25 27 / 91%);
box-shadow:
0 8px 24px rgb(0 0 0 / 24%),
inset 0 0.5px rgb(255 255 255 / 18%);
0 10px 25px rgb(0 0 0 / 28%),
inset 0 0.75px rgb(255 255 255 / 15%);
backdrop-filter: blur(26px) saturate(125%);
-webkit-backdrop-filter: blur(26px) saturate(125%);
cursor: pointer;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
'Segoe UI', sans-serif;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.home-widget-shell--small .home-widget {
padding: 13px;
border-radius: 23px;
}
.home-widget-shell--large .home-widget {
padding: 18px;
border-radius: 28px;
}
.home-widget:active {
filter: brightness(1.08);
}
.home-widget small,
.widget-eyebrow {
color: rgb(255 255 255 / 68%);
color: rgb(255 255 255 / 62%);
font-size: 11px;
font-weight: 500;
line-height: 1.25;
}
@@ -528,25 +561,30 @@ onBeforeUnmount(() => {
justify-content: flex-end;
}
.home-widget--clock {
background: rgb(12 12 13 / 94%);
}
.widget-clock {
margin: 1px 0;
font-size: 31px;
font-weight: 400;
letter-spacing: -1.6px;
margin: 2px 0;
font-size: 28px;
font-weight: 500;
letter-spacing: -1.8px;
line-height: 1;
white-space: nowrap;
}
.home-widget-shell--medium .widget-clock {
font-size: 45px;
font-size: 48px;
}
.home-widget--date {
background: rgb(247 247 247 / 88%);
color: #111;
background: rgb(24 24 26 / 94%);
color: #fff;
}
.home-widget--date small {
color: #6e6e73;
color: rgb(255 255 255 / 58%);
}
.widget-date-month {
@@ -557,8 +595,8 @@ onBeforeUnmount(() => {
}
.widget-date-day {
font-size: 48px;
font-weight: 300;
font-size: 52px;
font-weight: 400;
letter-spacing: -2px;
line-height: 0.95;
}
@@ -567,7 +605,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
justify-content: space-between;
background: rgb(25 69 112 / 82%);
background: rgb(34 42 78 / 95%);
}
.widget-weather-top {
@@ -582,14 +620,72 @@ onBeforeUnmount(() => {
}
.widget-weather-top strong {
font-size: 34px;
font-weight: 300;
font-size: 38px;
font-weight: 400;
letter-spacing: -1.5px;
line-height: 1;
}
.widget-weather-range {
margin-top: 4px;
margin-top: 3px;
color: rgb(255 255 255 / 86%) !important;
font-weight: 600 !important;
}
.widget-weather-hourly {
display: grid;
margin-top: 12px;
padding-top: 11px;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-top: 0.5px solid rgb(255 255 255 / 18%);
}
.widget-weather-hourly > div {
display: grid;
min-width: 0;
justify-items: center;
gap: 6px;
}
.widget-weather-hourly time {
color: rgb(255 255 255 / 58%);
font-size: 9px;
font-weight: 600;
}
.widget-weather-hourly strong {
font-size: 12px;
font-weight: 650;
}
.home-widget-shell--medium .home-widget--weather {
padding: 13px 15px;
}
.home-widget-shell--medium .widget-weather-top strong {
font-size: 32px;
}
.home-widget-shell--medium .widget-weather-hourly {
margin-top: 7px;
padding-top: 7px;
}
.home-widget-shell--medium .widget-weather-hourly > div {
gap: 3px;
}
.home-widget-shell--medium .widget-weather-hourly svg {
width: 18px;
height: 18px;
}
.home-widget-shell--medium .widget-weather-hourly time {
font-size: 8px;
}
.home-widget-shell--medium .widget-weather-hourly strong {
font-size: 11px;
}
.home-widget--music {
@@ -597,6 +693,7 @@ onBeforeUnmount(() => {
align-items: center;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
background: rgb(25 25 28 / 94%);
}
.home-widget-shell--large .home-widget--music {
@@ -610,10 +707,12 @@ onBeforeUnmount(() => {
width: 58px;
height: 58px;
place-items: center;
border-radius: 13px;
border-radius: 14px;
color: #fff;
background: #b33a3a;
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 22%);
background: #5653b8;
box-shadow:
inset 0 0 0 0.5px rgb(255 255 255 / 25%),
0 5px 13px rgb(0 0 0 / 24%);
font-size: 13px;
font-weight: 800;
letter-spacing: 1.5px;
@@ -640,6 +739,10 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.widget-music-copy strong {
font-size: 13px;
}
.widget-music-controls {
display: flex;
align-items: center;
@@ -655,7 +758,11 @@ onBeforeUnmount(() => {
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(255 255 255 / 13%);
background: rgb(255 255 255 / 11%);
}
.home-widget--wallet {
background: rgb(25 26 29 / 94%);
}
.widget-wallet-icon {
@@ -667,14 +774,14 @@ onBeforeUnmount(() => {
height: 36px;
place-items: center;
border-radius: 11px;
color: #0a84ff;
background: rgb(10 132 255 / 15%);
color: #64d2ff;
background: rgb(100 210 255 / 13%);
}
.widget-balance {
margin: 2px 0;
font-size: 28px;
font-weight: 600;
font-size: 30px;
font-weight: 650;
letter-spacing: -1.2px;
}
@@ -684,14 +791,26 @@ onBeforeUnmount(() => {
flex-direction: column;
}
.home-widget--transactions {
background: rgb(29 29 31 / 94%);
}
.home-widget--contacts {
background: rgb(25 26 29 / 94%);
}
.widget-list-header {
display: flex;
margin-bottom: 7px;
align-items: center;
justify-content: space-between;
color: rgb(255 255 255 / 72%);
font-size: 12px;
font-weight: 600;
color: #64d2ff;
font-size: 13px;
font-weight: 700;
}
.home-widget--transactions .widget-list-header {
color: #30d158;
}
.widget-transaction {
@@ -701,7 +820,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: space-between;
border: 0;
border-top: 0.5px solid rgb(255 255 255 / 12%);
border-top: 0.5px solid rgb(255 255 255 / 11%);
color: #fff;
background: transparent;
text-align: left;
@@ -715,6 +834,12 @@ onBeforeUnmount(() => {
.widget-transaction strong {
font-size: 12px;
font-weight: 650;
}
.widget-transaction small {
margin-top: 1px;
font-size: 9px;
}
.widget-transaction b {
@@ -756,6 +881,7 @@ onBeforeUnmount(() => {
border-radius: 50%;
color: #fff;
background: #5e5ce6;
box-shadow: inset 0 1px rgb(255 255 255 / 20%);
font-size: 16px;
font-weight: 650;
}
@@ -784,8 +910,8 @@ onBeforeUnmount(() => {
.widget-contacts button {
width: 25px;
height: 25px;
color: #0a84ff;
background: rgb(10 132 255 / 14%);
color: #64d2ff;
background: rgb(100 210 255 / 12%);
}
.home-widget-remove {
@@ -164,6 +164,8 @@ watch(
:key="contact.id"
link
link-component="button"
content-class="w-full"
:chevron="false"
:title="contact.name"
:subtitle="contact.phone_number"
@click="toggleContact(contact.id)"
@@ -183,6 +183,8 @@ watch(
:key="definition.kind"
link
link-component="button"
content-class="w-full"
:chevron="false"
:title="phone.t(definition.labelKey)"
:subtitle="phone.t(definition.descriptionKey)"
@click="selectWidget(definition)"
+9 -1
View File
@@ -118,7 +118,15 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['flare', 'local-pages', 'phone', 'darkchat', 'banking', 'mail'])
).toEqual([
'fliptok',
'flare',
'local-pages',
'phone',
'darkchat',
'banking',
'mail',
])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+19 -4
View File
@@ -36,11 +36,11 @@ import calendarIcon from '@/assets/img/app-icons/calendar.svg'
import mailIcon from '@/assets/img/app-icons/mail.webp'
import mapIcon from '@/assets/img/app-icons/map.webp'
import messagesIcon from '@/assets/img/app-icons/sms.webp'
import darkChatIcon from '@/assets/img/app-icons/darkchat.svg'
import darkChatIcon from '@/assets/img/app-icons/darkchat.webp'
import notesIcon from '@/assets/img/app-icons/notes.webp'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
import phoneIcon from '@/assets/img/app-icons/phone.webp'
import settingsIcon from '@/assets/img/app-icons/settings.webp'
import settingsIcon from '@/assets/img/app-icons/settings.svg'
import snakeIcon from '@/assets/img/app-icons/snake.webp'
import memoryIcon from '@/assets/img/app-icons/memory.webp'
import numberMergeIcon from '@/assets/img/app-icons/number-merge.webp'
@@ -49,11 +49,12 @@ import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import bankingIcon from '@/assets/img/app-icons/banking.svg'
import garageIcon from '@/assets/img/app-icons/garage.svg'
import bankingIcon from '@/assets/img/app-icons/banking.webp'
import garageIcon from '@/assets/img/app-icons/garage.webp'
import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp'
import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import flareIcon from '@/assets/img/app-icons/flare.svg'
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
@@ -61,6 +62,20 @@ import type {
} from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/FlipTokApp.vue')),
),
dockOrder: null,
gridOrder: 22,
icon: markRaw(Blocks),
iconClass: 'app-icon--fliptok',
iconImage: flipTokIcon,
id: 'fliptok',
labelKey: 'Apps.fliptok.name',
route: '/apps/fliptok',
},
{
category: 'social',
component: markRaw(
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import {
defaultMapPercentToWorld,
defaultMapWorldToPercent,
} from '@/features/map/defaultMapGeometry'
describe('default map geometry', () => {
it('round-trips world coordinates through map percentages', () => {
const world = { x: -75.2, y: -818.9 }
const restored = defaultMapPercentToWorld(defaultMapWorldToPercent(world))
expect(restored.x).toBeCloseTo(world.x, 6)
expect(restored.y).toBeCloseTo(world.y, 6)
})
})
@@ -77,3 +77,11 @@ export const defaultMapWorldToPercent = (point: MapPoint): MapPoint => ({
(defaultMapCoordinates.yFlipOffset - point.y - defaultMapCoordinates.minY) /
defaultMapCoordinates.height,
})
export const defaultMapPercentToWorld = (point: MapPoint): MapPoint => ({
x: defaultMapCoordinates.minX + point.x * defaultMapCoordinates.width,
y:
defaultMapCoordinates.yFlipOffset -
defaultMapCoordinates.minY -
point.y * defaultMapCoordinates.height,
})
+1
View File
@@ -49,6 +49,7 @@ export function useClockService() {
time: computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hour12: false,
minute: '2-digit',
}).format(now.value),
),
+186
View File
@@ -0,0 +1,186 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFlipTokStore } from '@/stores/fliptok'
import type {
FlipTokActivity,
FlipTokComment,
FlipTokProfile,
FlipTokVideo,
} from '@/types/fliptok'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const profile: FlipTokProfile = {
account_type: 'person',
bio: '',
display_name: 'Nova',
followers: 1,
following: 2,
handle: 'nova',
id: 7,
is_following: false,
is_owner: true,
verified: false,
video_count: 1,
}
const video: FlipTokVideo = {
caption: 'Los Santos',
comment_count: 1,
comments_enabled: true,
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'video-1',
is_following: false,
is_liked: false,
is_owner: true,
is_saved: false,
like_count: 2,
location: '',
cover_time_ms: 0,
music_artist: '',
music_title: '',
music_track: '',
music_url: '',
music_volume: 0,
original_volume: 100,
profile_id: 7,
share_count: 0,
trim_end_ms: null,
trim_start_ms: 0,
url: 'https://example.com/video.webm',
verified: false,
view_count: 3,
}
const comment: FlipTokComment = {
body: 'Nice',
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'comment-1',
profile_id: 7,
verified: false,
}
const activity: FlipTokActivity = {
created_at: 1,
display_name: 'Nova',
handle: 'nova',
id: 'activity-1',
kind: 'follow',
profile_id: 7,
read_at: null,
verified: false,
video_id: null,
}
describe('FlipTok verification updates', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(nuiCall).mockReset()
})
it('updates the badge everywhere the profile is already visible', () => {
const store = useFlipTokStore()
store.profile = { ...profile }
store.feed = [{ ...video }]
store.searchResults = [{ ...video }]
store.comments = [{ ...comment }]
store.activities = [{ ...activity }]
store.applyVerification(7, true)
expect(store.profile.verified).toBe(true)
expect(store.feed[0].verified).toBe(true)
expect(store.searchResults[0].verified).toBe(true)
expect(store.comments[0].verified).toBe(true)
expect(store.activities[0].verified).toBe(true)
})
it('does not alter another profile', () => {
const store = useFlipTokStore()
store.feed = [{ ...video }]
store.applyVerification(99, true)
expect(store.feed[0].verified).toBe(false)
})
it('removes a blocked creator from every visible surface', async () => {
vi.mocked(nuiCall).mockResolvedValue({ success: true })
const store = useFlipTokStore()
store.feed = [{ ...video }]
store.searchResults = [{ ...video }]
store.profileVideos = [{ ...video }]
store.comments = [{ ...comment }]
store.activities = [{ ...activity }]
store.viewedProfile = { ...profile }
expect(await store.blockProfile(7)).toBe(true)
expect(store.feed).toEqual([])
expect(store.searchResults).toEqual([])
expect(store.profileVideos).toEqual([])
expect(store.comments).toEqual([])
expect(store.activities).toEqual([])
expect(store.viewedProfile).toBeNull()
})
it('keeps the app signed out when bootstrap has no FlipTok session', async () => {
vi.mocked(nuiCall).mockResolvedValue({
success: true,
data: { authenticated: false, musicTracks: [] },
})
const store = useFlipTokStore()
expect(await store.bootstrap()).toBe(true)
expect(store.authenticated).toBe(false)
expect(store.profile).toBeNull()
expect(store.feed).toEqual([])
})
it('loads the profile after a successful login', async () => {
vi.mocked(nuiCall)
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({
success: true,
data: {
authenticated: true,
feed: { hasMore: false, items: [{ ...video }], offset: 0 },
isAdmin: false,
musicTracks: [],
profile: { ...profile },
},
})
const store = useFlipTokStore()
expect((await store.login('nova', 'password123')).success).toBe(true)
expect(store.authenticated).toBe(true)
expect(store.profile?.handle).toBe('nova')
expect(nuiCall).toHaveBeenNthCalledWith(1, 'fliptok:login', {
handle: 'nova',
password: 'password123',
})
})
it('clears the complete local session after logout', async () => {
vi.mocked(nuiCall).mockResolvedValue({ success: true })
const store = useFlipTokStore()
store.authenticated = true
store.profile = { ...profile }
store.feed = [{ ...video }]
store.activities = [{ ...activity }]
expect((await store.logout()).success).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('fliptok:logout')
expect(store.authenticated).toBe(false)
expect(store.profile).toBeNull()
expect(store.feed).toEqual([])
expect(store.activities).toEqual([])
})
})
+235
View File
@@ -0,0 +1,235 @@
import { defineStore } from 'pinia'
import type {
FlipTokActivity,
FlipTokComment,
FlipTokMusicTrack,
FlipTokPage,
FlipTokProfile,
FlipTokProfilePage,
FlipTokReport,
FlipTokVideo,
} from '@/types/fliptok'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useFlipTokStore = defineStore('fliptok', {
state: () => ({
activities: [] as FlipTokActivity[],
authenticated: false,
comments: [] as FlipTokComment[],
feed: [] as FlipTokVideo[],
isAdmin: false,
loading: false,
musicTracks: [] as FlipTokMusicTrack[],
mode: 'for-you' as 'for-you' | 'following',
profile: null as FlipTokProfile | null,
profileVideos: [] as FlipTokVideo[],
reports: [] as FlipTokReport[],
searchResults: [] as FlipTokVideo[],
viewedProfile: null as FlipTokProfile | null,
}),
actions: {
applyVerification(profileId: number, verified: boolean): void {
if (this.profile?.id === profileId) this.profile.verified = verified
if (this.viewedProfile?.id === profileId)
this.viewedProfile.verified = verified
this.feed
.filter((video) => video.profile_id === profileId)
.forEach((video) => {
video.verified = verified
})
this.searchResults
.filter((video) => video.profile_id === profileId)
.forEach((video) => {
video.verified = verified
})
this.profileVideos
.filter((video) => video.profile_id === profileId)
.forEach((video) => {
video.verified = verified
})
this.comments
.filter((comment) => comment.profile_id === profileId)
.forEach((comment) => {
comment.verified = verified
})
this.activities
.filter((activity) => activity.profile_id === profileId)
.forEach((activity) => {
activity.verified = verified
})
},
async bootstrap(): Promise<boolean> {
this.loading = true
const response = await nuiCall<{
authenticated: boolean
feed?: FlipTokPage
isAdmin?: boolean
musicTracks: FlipTokMusicTrack[]
profile?: FlipTokProfile
}>('fliptok:bootstrap')
this.loading = false
if (!response.success || !response.data) return false
this.authenticated = response.data.authenticated === true
this.profile = response.data.profile ?? null
this.feed = response.data.feed?.items ?? []
this.isAdmin = response.data.isAdmin === true
this.musicTracks = response.data.musicTracks ?? []
return true
},
async login(handle: string, password: string): Promise<NuiResponse> {
const response = await nuiCall('fliptok:login', { handle, password })
if (response.success) await this.bootstrap()
return response
},
async register(
displayName: string,
handle: string,
password: string,
): Promise<NuiResponse> {
const response = await nuiCall('fliptok:register', {
displayName,
handle,
password,
})
if (response.success) await this.bootstrap()
return response
},
async logout(): Promise<NuiResponse> {
const response = await nuiCall('fliptok:logout')
if (!response.success) return response
this.$reset()
return response
},
async loadFeed(mode?: 'for-you' | 'following'): Promise<boolean> {
mode ??= this.mode
this.mode = mode
this.loading = true
const response = await nuiCall<FlipTokPage>('fliptok:feed', {
mode,
offset: 0,
})
this.loading = false
if (!response.success || !response.data) return false
this.feed = response.data.items
return true
},
async discover(search: string): Promise<FlipTokVideo[]> {
const response = await nuiCall<FlipTokVideo[]>('fliptok:discover', {
search,
})
this.searchResults =
response.success && response.data ? response.data : []
return this.searchResults
},
async react(video: FlipTokVideo, kind: 'like' | 'save'): Promise<void> {
const key = kind === 'like' ? 'is_liked' : 'is_saved'
const next = !video[key]
video[key] = next
if (kind === 'like') video.like_count += next ? 1 : -1
const response = await nuiCall('fliptok:react', {
active: next,
id: video.id,
kind,
})
if (!response.success) {
video[key] = !next
if (kind === 'like') video.like_count += next ? -1 : 1
}
},
async follow(video: FlipTokVideo): Promise<void> {
const next = !video.is_following
const response = await nuiCall('fliptok:follow', {
active: next,
profileId: video.profile_id,
})
if (response.success)
this.feed
.filter((item) => item.profile_id === video.profile_id)
.forEach((item) => {
item.is_following = next
})
},
async followProfile(profile: FlipTokProfile): Promise<void> {
const next = !profile.is_following
const response = await nuiCall('fliptok:follow', {
active: next,
profileId: profile.id,
})
if (!response.success) return
profile.is_following = next
profile.followers += next ? 1 : -1
this.feed
.filter((item) => item.profile_id === profile.id)
.forEach((item) => {
item.is_following = next
})
},
async loadProfile(query: {
handle?: string
profileId?: number
}): Promise<boolean> {
const response = await nuiCall<FlipTokProfilePage>(
'fliptok:profile',
query,
)
if (!response.success || !response.data) return false
this.viewedProfile = response.data.profile
this.profileVideos = response.data.videos
return true
},
showOwnProfile(): void {
this.viewedProfile = null
this.profileVideos = this.feed.filter((item) => item.is_owner)
},
async blockProfile(profileId: number): Promise<boolean> {
const response = await nuiCall('fliptok:block', { profileId })
if (!response.success) return false
this.feed = this.feed.filter((video) => video.profile_id !== profileId)
this.searchResults = this.searchResults.filter(
(video) => video.profile_id !== profileId,
)
this.comments = this.comments.filter(
(comment) => comment.profile_id !== profileId,
)
this.activities = this.activities.filter(
(activity) => activity.profile_id !== profileId,
)
this.profileVideos = this.profileVideos.filter(
(video) => video.profile_id !== profileId,
)
if (this.viewedProfile?.id === profileId) this.viewedProfile = null
return true
},
async loadComments(id: string): Promise<void> {
const response = await nuiCall<FlipTokComment[]>('fliptok:comments', {
id,
})
this.comments = response.success && response.data ? response.data : []
},
async comment(id: string, body: string): Promise<NuiResponse> {
return nuiCall('fliptok:comment', { body, id })
},
async loadActivities(): Promise<void> {
const response = await nuiCall<FlipTokActivity[]>('fliptok:activities')
this.activities = response.success && response.data ? response.data : []
if (response.success) await nuiCall('fliptok:mark-activities')
},
async loadReports(): Promise<boolean> {
const response = await nuiCall<FlipTokReport[]>('fliptok:admin-reports')
this.reports = response.success && response.data ? response.data : []
return response.success
},
async resolveReport(
id: string,
action: 'dismiss' | 'remove',
): Promise<boolean> {
const response = await nuiCall('fliptok:admin-resolve-report', {
action,
id,
})
if (response.success) await this.loadReports()
return response.success
},
},
})
+60
View File
@@ -0,0 +1,60 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMapStore } from '@/stores/map'
import type { MapMarker } from '@/types/map'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const marker: MapMarker = {
color: 'blue',
coords: { x: -75.2, y: -818.9, z: 0 },
id: 'marker-1',
label: 'Meeting point',
}
describe('map store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads persistent markers', async () => {
mockNuiCall.mockResolvedValueOnce({ data: [marker], success: true })
const map = useMapStore()
expect(await map.load()).toBe(true)
expect(map.markers).toEqual([marker])
expect(mockNuiCall).toHaveBeenCalledWith('map:markers')
})
it('adds a marker returned by the server', async () => {
mockNuiCall.mockResolvedValueOnce({ data: marker, success: true })
const map = useMapStore()
const input = {
color: marker.color,
coords: marker.coords,
label: marker.label,
}
expect((await map.create(input)).success).toBe(true)
expect(map.markers).toEqual([marker])
expect(mockNuiCall).toHaveBeenCalledWith('map:create-marker', input)
})
it('only removes a marker after server confirmation', async () => {
mockNuiCall
.mockResolvedValueOnce({ error: 'request_failed', success: false })
.mockResolvedValueOnce({ success: true })
const map = useMapStore()
map.markers = [marker]
await map.remove(marker.id)
expect(map.markers).toEqual([marker])
await map.remove(marker.id)
expect(map.markers).toEqual([])
})
})
+50
View File
@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import type { CreateMapMarker, MapMarker } from '@/types/map'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useMapStore = defineStore('map', {
state: () => ({
error: '',
isLoading: false,
markers: [] as MapMarker[],
}),
actions: {
async load(): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<MapMarker[]>('map:markers')
this.isLoading = false
if (response.success && response.data) {
this.markers = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async create(marker: CreateMapMarker): Promise<NuiResponse<MapMarker>> {
this.isLoading = true
const response = await nuiCall<MapMarker>('map:create-marker', marker)
this.isLoading = false
if (response.success && response.data) {
this.markers.push(response.data)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
async remove(id: string): Promise<NuiResponse> {
this.isLoading = true
const response = await nuiCall('map:delete-marker', { id })
this.isLoading = false
if (response.success) {
this.markers = this.markers.filter((marker) => marker.id !== id)
this.error = ''
} else {
this.error = response.error ?? 'request_failed'
}
return response
},
},
})
@@ -0,0 +1,80 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const mockNuiCall = vi.mocked(nuiCall)
describe('phone passcode store', () => {
beforeEach(() => {
vi.stubGlobal('window', {
matchMedia: vi.fn(() => ({ matches: false })),
})
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('stores the server security state after setting a six digit passcode', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
security: { enabled: true, length: 6, lockedUntil: 0 },
},
success: true,
})
const phone = usePhoneStore()
const response = await phone.setPasscode('123456')
expect(response.success).toBe(true)
expect(phone.security).toEqual({
enabled: true,
length: 6,
lockedUntil: 0,
})
expect(mockNuiCall).toHaveBeenCalledWith('security:set-passcode', {
passcode: '123456',
})
})
it('keeps the configured state after a rejected unlock attempt', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'invalid_passcode',
success: false,
})
const phone = usePhoneStore()
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
await phone.unlockWithPasscode('9999')
expect(phone.security).toEqual({
enabled: true,
length: 4,
lockedUntil: 0,
})
})
it('clears the security state after disabling the passcode', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
security: { enabled: false, length: null, lockedUntil: 0 },
},
success: true,
})
const phone = usePhoneStore()
phone.security = { enabled: true, length: 4, lockedUntil: 0 }
await phone.disablePasscode('1234')
expect(phone.security.enabled).toBe(false)
expect(phone.security.length).toBeNull()
})
})
+302 -1
View File
@@ -1,10 +1,15 @@
import { defineStore } from 'pinia'
import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
import type {
DeviceBootstrap,
DeviceSecurity,
PhoneDevice,
} from '@/types/device'
import { clampPage } from '@/utils/pages'
import { cloneJsonData } from '@/utils/clone'
import { nuiCall } from '@/utils/nui'
import type { NuiResponse } from '@/utils/nui'
import {
DEFAULT_PHONE_PREFERENCES,
parsePhonePreferences,
@@ -15,12 +20,19 @@ import {
type LocaleTree = Record<string, unknown>
export type PasscodeResponseData = {
attemptsRemaining?: number
retryAfter?: number
security?: DeviceSecurity
}
export type PhoneOpenPayload = {
account?: DeviceBootstrap['account']
device?: PhoneDevice
lang?: string
locales?: LocaleTree
notes?: DeviceBootstrap['notes']
security?: DeviceSecurity
token?: string
}
@@ -179,6 +191,151 @@ const defaultLocales: LocaleTree = {
default: 'Flare could not complete the request.',
},
},
fliptok: {
name: 'FlipTok',
loading: 'Loading FlipTok',
following: 'Following',
forYou: 'For You',
verified: 'Verified account',
originalSound: 'original sound',
save: 'Save',
home: 'Home',
discover: 'Discover',
create: 'Create',
activity: 'Activity',
profile: 'Profile',
emptyFeed: 'No videos yet',
emptyFeedBody: 'Follow creators or post the first FlipTok.',
searchPlaceholder: 'Search creators and videos',
noActivity: 'No activity yet',
followers: 'Followers',
videos: 'Videos',
emptyBio: 'No bio yet.',
editProfile: 'Edit profile',
newVideo: 'New FlipTok',
chooseVideo: 'Choose a video',
chooseVideoHint: 'Select one from Gallery',
changeVideo: 'Change',
captionPlaceholder: 'Write a caption...',
location: 'Add location',
whoCanWatch: 'Who can watch',
public: 'Everyone',
followersOnly: 'Followers',
private: 'Only me',
allowComments: 'Allow comments',
saveDraft: 'Drafts',
publishing: 'Posting...',
post: 'Post',
draftSaved: 'Draft saved.',
published: 'Your FlipTok is live.',
linkCopied: 'Video link copied.',
reported: 'Report submitted.',
blocked: 'Creator blocked.',
comments: 'Comments',
noComments: 'No comments yet',
addComment: 'Add comment...',
report: 'Report video',
reportReason: 'Reason',
reportDetails: 'Additional details (optional)',
submitReport: 'Submit report',
reportReasons: {
spam: 'Spam or misleading',
harassment: 'Harassment or bullying',
dangerous: 'Dangerous activity',
illegal: 'Illegal content',
other: 'Something else',
},
block: 'Block creator',
follow: 'Follow',
unfollow: 'Following',
backToProfile: 'Back',
sounds: 'Sound',
chooseSound: 'Choose music',
originalOnly: 'Original sound only',
noMusic: 'No music tracks are configured.',
trimAndCover: 'Trim & cover',
trimStart: 'Start',
trimEnd: 'End',
coverFrame: 'Cover',
originalVolume: 'Original sound',
musicVolume: 'Music',
moderation: 'Moderation',
reports: 'Open reports',
noReports: 'No open reports',
removeVideo: 'Remove video',
dismissReport: 'Dismiss',
cancel: 'Cancel',
done: 'Done',
displayName: 'Name',
username: 'Username',
bio: 'Bio',
accountType: 'Account type',
authTitle: 'Your FlipTok account',
login: 'Sign In',
register: 'Register',
createAccount: 'Create Account',
logout: 'Sign Out',
loginBody:
'Sign in to continue with your videos, follows, and saved posts.',
registerBody: 'Create a private FlipTok login for this profile.',
password: 'Password',
confirmPassword: 'Confirm password',
passwordsMismatch: 'The passwords do not match.',
displayNamePlaceholder: 'Your name',
usernamePlaceholder: 'username',
passwordPlaceholder: 'At least 8 characters',
confirmPasswordPlaceholder: 'Enter password again',
registrationHint:
'An iFruit account is required once to create and own a FlipTok profile.',
accountDetails: 'Account details',
profileDetails: 'Profile details',
account: 'Account',
signOutTitle: 'Sign out of FlipTok?',
signOutBody:
'Your profile and videos stay online. This phone will return to the FlipTok sign-in screen.',
signingOut: 'Signing Out...',
accountTypes: {
person: 'Person',
business: 'Business',
organization: 'Organization',
media: 'Media',
event: 'Event',
},
activityKinds: {
like: 'liked your video',
comment: 'commented on your video',
follow: 'started following you',
verified: 'verification changed',
},
notifications: {
like: '{actor} liked your video.',
comment: '{actor} commented on your video.',
follow: '{actor} started following you.',
verified: 'Your FlipTok account is now verified.',
default: 'You have new FlipTok activity.',
},
errors: {
invalid_video: 'Check the video details.',
invalid_media: 'Choose a video from this phone.',
invalid_comment: 'Enter a valid comment.',
comments_disabled: 'Comments are disabled.',
invalid_profile: 'Check your profile details.',
invalid_handle: 'Use 324 letters, numbers, dots, or underscores.',
invalid_display_name: 'Enter a display name.',
invalid_password: 'Password must be 872 characters.',
invalid_credentials: 'Username or password is incorrect.',
already_registered:
'This iFruit account already owns a registered FlipTok profile.',
handle_taken: 'This username is already taken.',
video_not_found: 'This video is unavailable.',
rate_limited: 'Too many actions. Try again shortly.',
not_authenticated: 'Sign in to iFruit first.',
blocked: 'This account is blocked.',
not_authorized: 'You do not have moderation access.',
report_not_found: 'This report is no longer open.',
default: 'FlipTok could not complete the request.',
},
},
darkchat: {
name: 'DarkChat',
newMessage: 'New DarkChat message from {sender}',
@@ -1098,6 +1255,36 @@ const defaultLocales: LocaleTree = {
currentLocation: 'Current Location',
imageError: 'The map image could not be loaded.',
switchStyle: 'Switch Map Type',
addMarker: 'Add Marker',
placeMarker: 'Place Marker',
placeMarkerHint:
'Move the map until the crosshair is over the destination.',
addHere: 'Add Here',
newMarker: 'New Marker',
newMarkerDescription: 'Give this saved place a name and color.',
markerName: 'Name',
markerNamePlaceholder: 'e.g. Meeting point',
markerColor: 'Marker Color',
saveMarker: 'Save Marker',
deleteMarker: 'Delete Marker',
setWaypoint: 'Set Waypoint',
waypointSet: 'Waypoint set.',
markerSaved: 'Marker saved.',
markerDeleted: 'Marker deleted.',
colors: {
blue: 'Blue',
green: 'Green',
orange: 'Orange',
purple: 'Purple',
red: 'Red',
},
errors: {
invalid_marker: 'Enter a valid marker name and position.',
marker_limit: 'This phone has reached its marker limit.',
marker_not_found: 'This marker no longer exists.',
rate_limited: 'Too many changes. Try again shortly.',
request_failed: 'The marker could not be saved.',
},
styles: {
default: 'Default Map',
satellite: 'Satellite Map',
@@ -1153,6 +1340,8 @@ const defaultLocales: LocaleTree = {
portrait: 'Switch to portrait',
photo: 'Photo',
video: 'Video',
microphoneOn: 'Microphone on',
microphoneOff: 'Microphone muted',
focusHelp: 'Space for movement',
returnHelp: 'Space to return',
uploading: '{count} uploading',
@@ -1170,6 +1359,8 @@ const defaultLocales: LocaleTree = {
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
missing_config: 'Camera uploads are not configured.',
microphone_unavailable:
'Allow microphone access or mute the microphone before recording.',
not_found: 'The media item no longer exists.',
operation_in_progress:
'Another media operation is already in progress.',
@@ -1473,7 +1664,19 @@ const defaultLocales: LocaleTree = {
notifications: 'Notifications',
sounds: 'Sounds & Haptics',
general: 'General Settings',
security: 'Passcode & Security',
appearance: 'Appearance',
connectivity: 'Connectivity',
connections: 'Connections',
wifi: 'Wi-Fi',
bluetooth: 'Bluetooth',
cellular: 'Cellular',
connectivityDescription:
'Airplane Mode temporarily disables wireless connections. Wi-Fi, Bluetooth, and cellular settings are saved on this phone.',
focus: 'Focus',
focusMode: 'Focus',
focusDescription:
'Focus silences non-critical notifications while keeping alarms and important alerts available.',
allowNotifications: 'Allow Notifications',
notificationSounds: 'Sounds',
notificationDuration: 'Notification Duration',
@@ -1488,6 +1691,8 @@ const defaultLocales: LocaleTree = {
dark: 'Dark',
phoneScale: 'Phone Scale',
phoneFrame: 'Phone Frame',
screenBrightness: 'Screen Brightness',
rotationLock: 'Rotation Lock',
about: 'About',
deviceName: 'Device Name',
deviceNameValue: 'Sky Phone',
@@ -1517,6 +1722,28 @@ const defaultLocales: LocaleTree = {
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
factoryResetProgress: 'Erasing iFruit Phone',
factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
passcode: {
description:
'A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.',
status: 'Passcode',
codeLength: 'Code Length',
sixDigit: '6-Digit Code',
fourDigit: '4-Digit Code',
turnOn: 'Turn Passcode On',
turnOff: 'Turn Passcode Off',
change: 'Change Passcode',
enterNew: 'Enter New Passcode',
confirmNew: 'Verify New Passcode',
enterCurrent: 'Enter Current Passcode',
screenSubtitle: 'Use 4 or 6 numbers.',
incorrect: 'Incorrect passcode.',
mismatch: 'The passcodes did not match.',
locked: 'Too many incorrect attempts. Try again later.',
rateLimited: 'Too many attempts. Please wait.',
failed: 'The passcode could not be updated.',
saved: 'Passcode saved.',
disabled: 'Passcode turned off.',
},
accountErrors: {
invalid_email: 'Choose a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
@@ -1532,6 +1759,11 @@ const defaultLocales: LocaleTree = {
toggle: {
airplaneMode: 'Toggle Airplane Mode',
streamerMode: 'Toggle Streamer Mode',
focusMode: 'Toggle Focus',
wifiEnabled: 'Toggle Wi-Fi',
bluetoothEnabled: 'Toggle Bluetooth',
cellularEnabled: 'Toggle Cellular Data',
rotationLocked: 'Toggle Rotation Lock',
notifications: 'Toggle notifications for {app}',
notificationSounds: 'Toggle notification sounds for {app}',
},
@@ -1604,6 +1836,7 @@ const defaultLocales: LocaleTree = {
send: 'Send',
start: 'Start',
stop: 'Stop',
use: 'Use',
},
Notifications: { now: 'now' },
LockScreen: {
@@ -1611,6 +1844,16 @@ const defaultLocales: LocaleTree = {
flashlight: 'Flashlight',
camera: 'Camera',
swipeUp: 'Swipe up to open',
passcode: {
enter: 'Enter Passcode',
unlockSubtitle: 'Enter the passcode for this phone.',
cancel: 'Cancel',
delete: 'Delete digit',
incorrect: 'Incorrect passcode',
locked: 'Too many attempts. Try again in {seconds} seconds.',
tryAgain: 'Try again in {seconds} seconds',
rateLimited: 'Too many attempts. Please wait.',
},
},
Home: {
appLibrary: 'App Library',
@@ -1726,6 +1969,11 @@ export const usePhoneStore = defineStore('phone', {
launchOrigin: null as AppLaunchOrigin | null,
locales: defaultLocales,
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
security: {
enabled: false,
length: null,
lockedUntil: 0,
} as DeviceSecurity,
systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
}),
getters: {
@@ -1744,6 +1992,11 @@ export const usePhoneStore = defineStore('phone', {
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
if (payload.device) this.hydrateDevice(payload.device)
this.security = payload.security ?? {
enabled: false,
length: null,
lockedUntil: 0,
}
this.isOpen = true
},
hydrateDevice(device: PhoneDevice): void {
@@ -1813,6 +2066,54 @@ export const usePhoneStore = defineStore('phone', {
this.preferences.settings.wallpaper = wallpaper
this.saveDeviceNamespace('settings', this.preferences)
},
async unlockWithPasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>('security:unlock', {
passcode,
})
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async setPasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:set-passcode',
{ passcode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async changePasscode(
currentPasscode: string,
newPasscode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:change-passcode',
{ currentPasscode, newPasscode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
async disablePasscode(
passcode: string,
): Promise<NuiResponse<PasscodeResponseData>> {
const response = await nuiCall<PasscodeResponseData>(
'security:disable-passcode',
{ passcode },
)
if (response.success && response.data?.security) {
this.security = response.data.security
}
return response
},
t(path: string, replacements: Record<string, string> = {}): string {
const translated = getByPath(this.locales, path)
const fallback = getByPath(defaultLocales, path)
+1
View File
@@ -27,6 +27,7 @@ export type PhoneAppId =
| 'citymarkt'
| 'local-pages'
| 'flare'
| 'fliptok'
export type LaunchablePhoneAppId = PhoneAppId
+7
View File
@@ -19,6 +19,12 @@ export type PhoneNotificationDevicePayload = {
settings?: string | null
}
export type DeviceSecurity = {
enabled: boolean
length: 4 | 6 | null
lockedUntil: number
}
export type AccountDevice = {
created_at: string
current: boolean
@@ -37,5 +43,6 @@ export type DeviceBootstrap = {
account: IfruitAccount | null
device: PhoneDevice
notes: Note[]
security: DeviceSecurity
token: string
}
+97
View File
@@ -0,0 +1,97 @@
export type FlipTokProfile = {
account_type: 'person' | 'business' | 'organization' | 'media' | 'event'
bio: string
display_name: string
followers: number
following: number
handle: string
id: number
is_following: boolean
is_owner: boolean
verified: boolean
video_count: number
}
export type FlipTokVideo = {
caption: string
comment_count: number
comments_enabled: boolean
created_at: number
display_name: string
handle: string
id: string
is_following: boolean
is_liked: boolean
is_owner: boolean
is_saved: boolean
like_count: number
location: string
cover_time_ms: number
music_artist: string
music_title: string
music_track: string
music_url: string
music_volume: number
original_volume: number
profile_id: number
share_count: number
trim_end_ms: number | null
trim_start_ms: number
url: string
verified: boolean
view_count: number
}
export type FlipTokMusicTrack = {
artist: string
id: string
title: string
url: string
}
export type FlipTokReport = {
caption: string
created_at: number
creator_display_name: string
creator_handle: string
details: string
id: string
reason: 'spam' | 'harassment' | 'dangerous' | 'illegal' | 'other'
reporter_display_name: string
reporter_handle: string
url: string
video_id: string
}
export type FlipTokProfilePage = {
profile: FlipTokProfile
videos: FlipTokVideo[]
}
export type FlipTokComment = {
body: string
created_at: number
display_name: string
handle: string
id: string
profile_id: number
verified: boolean
}
export type FlipTokActivity = {
created_at: number
display_name: string
handle: string
id: string
kind: 'like' | 'comment' | 'follow' | 'verified'
profile_id: number
read_at: string | null
verified: boolean
video_id: string | null
}
export type FlipTokPage = {
hasMore: boolean
items: FlipTokVideo[]
offset: number
}
+12
View File
@@ -0,0 +1,12 @@
import type { MapPoint } from '@/features/map/defaultMapGeometry'
export type MapMarkerColor = 'blue' | 'green' | 'orange' | 'purple' | 'red'
export type MapMarker = {
color: MapMarkerColor
coords: MapPoint & { z: number }
id: string
label: string
}
export type CreateMapMarker = Omit<MapMarker, 'id'>
+1
View File
@@ -69,6 +69,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
'neon-drop': { enabled: true, sounds: true },
citymarkt: { enabled: true, sounds: true },
'local-pages': { enabled: true, sounds: true },
fliptok: { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
calendar: { enabled: true, sounds: true },
+2
View File
@@ -1004,6 +1004,7 @@ watch(isEditablePage, (visible) => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="phone.t('Home.widgetSystem.editWidget')"
@click="openWidgetConfig"
>
@@ -1012,6 +1013,7 @@ watch(isEditablePage, (visible) => {
<k-list-item
link
link-component="button"
content-class="w-full"
class="widget-action-remove"
:title="phone.t('Home.widgetSystem.removeWidget')"
@click="activeWidget && removeWidget(activeWidget.id)"
+115 -40
View File
@@ -1,14 +1,10 @@
<script setup lang="ts">
import {
kFab,
kNavbar,
kPage,
kSegmented,
kSegmentedButton,
} from 'konsta/vue'
import { kFab, kNavbar, kPage, kSegmented, kSegmentedButton } from 'konsta/vue'
import {
ArrowLeft,
Images,
Mic,
MicOff,
RefreshCw,
RotateCcwSquare,
Video,
@@ -45,6 +41,7 @@ const requestedMessageMedia = computed<MediaType | null>(() => {
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
const selectedZoom = ref<(typeof zoomLevels)[number]>(1)
const flashEnabled = ref(false)
const microphoneEnabled = ref(true)
const frontCamera = ref(false)
const shutterActive = ref(false)
const focused = ref(true)
@@ -63,6 +60,8 @@ let recordingTimer: number | undefined
let gameView: GameView | null = null
let renderFrameId: number | undefined
let resizeObserver: ResizeObserver | null = null
let wheelDelta = 0
let wheelResetTimer: number | undefined
const pendingCount = computed(
() =>
@@ -88,6 +87,12 @@ const flashColors = computed(() => ({
? 'text-yellow-500 dark:text-yellow-300'
: controlColors.textIos,
}))
const microphoneColors = computed(() => ({
...controlColors,
textIos: microphoneEnabled.value
? 'text-white'
: 'text-red-400',
}))
function correlationId(): string {
return `${Date.now()}-${crypto.randomUUID()}`
@@ -156,15 +161,31 @@ async function requestPhoto(): Promise<void> {
function startRecording(): void {
if (savingVideo.value) return
if (isDevelopment) {
recording.value = true
recordingStartedAt.value = Date.now()
updateRecordingTimer()
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
recordingTimer = window.setInterval(updateRecordingTimer, 250)
return
}
window.postMessage(
{
data: { bitrateKbps: videoBitrateKbps.value },
data: {
bitrateKbps: videoBitrateKbps.value,
microphoneEnabled: microphoneEnabled.value,
},
type: 'camera:recordStart',
},
'*',
)
}
function toggleMicrophone(): void {
if (recording.value || savingVideo.value) return
microphoneEnabled.value = !microphoneEnabled.value
}
function stopRecording(): void {
if (!recording.value || savingVideo.value) return
const id = correlationId()
@@ -172,6 +193,8 @@ function stopRecording(): void {
if (isDevelopment) {
recording.value = false
savingVideo.value = true
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
recordingTimer = undefined
window.setTimeout(() => {
window.dispatchEvent(
new MessageEvent('message', {
@@ -247,6 +270,26 @@ function setZoom(zoom: (typeof zoomLevels)[number]): void {
void nuiCall('camera:setZoom', { zoom })
}
function zoomWithWheel(event: WheelEvent): void {
wheelDelta += event.deltaY
if (wheelResetTimer !== undefined) window.clearTimeout(wheelResetTimer)
wheelResetTimer = window.setTimeout(() => {
wheelDelta = 0
wheelResetTimer = undefined
}, 140)
if (Math.abs(wheelDelta) < 35) return
const currentIndex = zoomLevels.indexOf(selectedZoom.value)
const nextIndex = Math.min(
zoomLevels.length - 1,
Math.max(0, currentIndex + (wheelDelta < 0 ? 1 : -1)),
)
wheelDelta = 0
const nextZoom = zoomLevels[nextIndex]
if (nextZoom !== undefined && nextZoom !== selectedZoom.value)
setZoom(nextZoom)
}
function resizeGameView(entry?: ResizeObserverEntry): void {
if (!gameCanvas.value || !gameView) return
const width = entry?.contentRect.width ?? gameCanvas.value.offsetWidth
@@ -379,6 +422,7 @@ onBeforeUnmount(() => {
if (shutterTimer !== undefined) window.clearTimeout(shutterTimer)
if (noticeTimer !== undefined) window.clearTimeout(noticeTimer)
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
if (wheelResetTimer !== undefined) window.clearTimeout(wheelResetTimer)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('message', onMessage)
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
@@ -402,7 +446,7 @@ onBeforeUnmount(() => {
:class="{ 'camera-page--landscape': phone.cameraLandscape }"
:aria-label="phone.t('Apps.camera.name')"
>
<div class="camera-viewport">
<div class="camera-viewport" @wheel.prevent="zoomWithWheel">
<canvas
v-if="!isDevelopment"
ref="gameCanvas"
@@ -423,29 +467,53 @@ onBeforeUnmount(() => {
</div>
<header class="camera-topbar">
<button
v-if="requestedMessageMedia"
class="camera-picker-back"
type="button"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<k-fab
v-if="!requestedMessageMedia"
component="button"
type="button"
class="camera-control"
:colors="flashColors"
:aria-label="phone.t('Apps.camera.flash')"
@click="toggleFlash"
>
<template #icon>
<Zap v-if="flashEnabled" :size="19" />
<ZapOff v-else :size="19" />
</template>
</k-fab>
<div class="camera-topbar-actions">
<button
v-if="requestedMessageMedia"
class="camera-picker-back"
type="button"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<k-fab
v-else
component="button"
type="button"
class="camera-control"
:colors="flashColors"
:aria-label="phone.t('Apps.camera.flash')"
@click="toggleFlash"
>
<template #icon>
<Zap v-if="flashEnabled" :size="19" />
<ZapOff v-else :size="19" />
</template>
</k-fab>
<k-fab
v-if="mode === 'video'"
component="button"
type="button"
class="camera-control"
:colors="microphoneColors"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
microphoneEnabled
? 'Apps.camera.microphoneOn'
: 'Apps.camera.microphoneOff',
)
"
:aria-pressed="microphoneEnabled"
@click="toggleMicrophone"
>
<template #icon>
<Mic v-if="microphoneEnabled" :size="19" />
<MicOff v-else :size="19" />
</template>
</k-fab>
</div>
<span
v-if="noticeText"
class="camera-focus-pill camera-focus-pill--notice"
@@ -455,11 +523,7 @@ onBeforeUnmount(() => {
<span v-else-if="pendingCount" class="camera-upload-pill">
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
</span>
<span v-else class="camera-focus-pill">
{{
phone.t(focused ? 'Apps.camera.focusHelp' : 'Apps.camera.returnHelp')
}}
</span>
<span v-else class="camera-topbar-spacer" aria-hidden="true"></span>
<k-fab
component="button"
type="button"
@@ -595,7 +659,7 @@ onBeforeUnmount(() => {
<style scoped>
.camera-page {
position: relative;
overflow: hidden;
overflow: clip;
background: #000;
color: #fff;
}
@@ -684,9 +748,20 @@ onBeforeUnmount(() => {
left: 18px;
right: 18px;
display: grid;
grid-template-columns: 44px 1fr 44px;
grid-template-columns: auto minmax(0, 1fr) 44px;
align-items: center;
gap: 10px;
gap: 8px;
}
.camera-topbar-actions {
display: flex;
gap: 8px;
}
.camera-topbar-spacer {
min-width: 0;
}
.camera-topbar .camera-control {
width: 44px;
height: 44px;
}
.camera-control {
--color-primary: transparent;
+47 -59
View File
@@ -37,7 +37,6 @@ import {
Plus,
QrCode,
Reply,
Search,
ShieldCheck,
ShieldOff,
Trash2,
@@ -89,6 +88,10 @@ const darkMessagebarColors = {
placeholderIos: 'placeholder-[#8e8e93]',
toolbarIconIos: 'fill-[#0a84ff]',
}
const darkSearchbarColors = {
inputBgIos: 'bg-[#1c1c1e]',
placeholderIos: 'placeholder-[#8e8e93]',
}
const darkSheetColors = {
bgIos: 'bg-[#111113]',
}
@@ -182,10 +185,6 @@ function inputValue(event: Event): string {
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
}
function setSearch(event: Event): void {
search.value = inputValue(event)
}
function setIdentifier(event: Event): void {
identifier.value = inputValue(event)
}
@@ -1706,20 +1705,6 @@ onBeforeUnmount(() => {
background: rgb(255 255 255 / 8%);
}
.dc-report-dialog textarea {
width: 100%;
min-height: 82px;
margin-top: 10px;
padding: 10px;
resize: none;
border: 0.5px solid var(--dc-border);
border-radius: 10px;
outline: none;
color: var(--dc-label);
font-size: 14px;
background: var(--dc-surface-raised);
}
.dc-action-sheet {
max-height: 72%;
padding: 7px 0 32px;
@@ -2023,33 +2008,10 @@ onBeforeUnmount(() => {
gap: 9px;
}
.dc-sms-inbox-toolbar label {
display: flex;
height: 40px;
.dc-sms-search {
min-width: 0;
padding: 0 12px;
margin: 0;
flex: 1;
align-items: center;
gap: 7px;
border: 0.5px solid var(--dc-border);
border-radius: 22px;
color: var(--dc-tertiary);
background: rgb(28 28 30 / 94%);
}
.dc-sms-inbox-toolbar input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
color: var(--dc-label);
font-size: 14px;
background: transparent;
}
.dc-sms-inbox-toolbar input::placeholder {
color: var(--dc-tertiary);
opacity: 1;
}
.dc-sms-inbox-toolbar > button {
@@ -2244,13 +2206,14 @@ onBeforeUnmount(() => {
</div>
</div>
<footer class="dc-sms-inbox-toolbar">
<label
><Search :size="17" /><input
:value="search"
type="search"
:placeholder="phone.t('Common.search')"
@input="setSearch" /></label
><k-glass
<k-searchbar
v-model="search"
class="dc-sms-search"
:placeholder="phone.t('Common.search')"
:colors="darkSearchbarColors"
:input-style="{ color: 'var(--dc-label)' }"
/>
<k-glass
component="button"
type="button"
:aria-label="t('newChat')"
@@ -2309,6 +2272,7 @@ onBeforeUnmount(() => {
:key="contact.id"
link
link-component="button"
content-class="w-full"
:title="contact.alias"
:subtitle="contact.darkId"
@click="requestStart(contact.darkId)"
@@ -2611,6 +2575,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('disappearing')"
@click="selectionSheet = 'disappearing'"
>
@@ -2640,6 +2605,7 @@ onBeforeUnmount(() => {
v-if="active.peer.isContact"
link
link-component="button"
content-class="w-full"
:title="t('removeContact')"
@click="removeContact"
><template #media><UserMinus :size="20" /></template
@@ -2647,6 +2613,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="active.peer.blocked ? t('unblock') : t('block')"
@click="toggleBlock"
><template #media><ShieldOff :size="20" /></template
@@ -2654,6 +2621,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('report')"
@click="beginReport()"
><template #media><BellOff :size="20" /></template
@@ -2661,6 +2629,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('clearChat')"
@click="clearChat"
><template #media><Trash2 :size="20" /></template
@@ -2716,12 +2685,18 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
:title="t('notificationPrivacy')"
content-class="w-full"
title-wrap-class="gap-2"
@click="selectionSheet = 'notification'"
>
<template #media><Bell :size="20" /></template>
<template #title>
<span class="whitespace-nowrap text-[15px]">
{{ t('notificationPrivacy') }}
</span>
</template>
<template #after
><span class="dc-setting-value">{{
><span class="dc-setting-value max-w-[106px] text-[12px]">{{
notificationOptions.find(
(option) => option.value === notificationMode,
)?.label
@@ -2797,6 +2772,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('reply')"
@click="beginReply(selectedMessage)"
><template #media><Reply :size="20" /></template
@@ -2808,6 +2784,7 @@ onBeforeUnmount(() => {
"
link
link-component="button"
content-class="w-full"
:title="t('copy')"
@click="copyMessage(selectedMessage)"
><template #media><Copy :size="20" /></template
@@ -2815,6 +2792,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('deleteForMe')"
@click="messageAction(selectedMessage, 'delete_me')"
><template #media><Trash2 :size="20" /></template
@@ -2823,6 +2801,7 @@ onBeforeUnmount(() => {
v-if="selectedMessage.direction === 'sent'"
link
link-component="button"
content-class="w-full"
class="dc-danger-row"
:title="t('deleteForBoth')"
@click="messageAction(selectedMessage, 'delete_all')"
@@ -2832,6 +2811,7 @@ onBeforeUnmount(() => {
v-if="selectedMessage.direction === 'received'"
link
link-component="button"
content-class="w-full"
class="dc-danger-row"
:title="t('report')"
@click="beginReport(selectedMessage)"
@@ -2859,6 +2839,7 @@ onBeforeUnmount(() => {
<k-list-item
link
link-component="button"
content-class="w-full"
:title="t('reportUser')"
@click="selectionSheet = 'report'"
>
@@ -2870,12 +2851,17 @@ onBeforeUnmount(() => {
>
</k-list-item>
</k-list>
<textarea
:value="reportDetails"
maxlength="500"
:placeholder="t('reportDetails')"
@input="setReportDetails"
/>
<k-list inset strong class="dc-form-list">
<k-list-input
type="textarea"
:value="reportDetails"
maxlength="500"
:placeholder="t('reportDetails')"
:colors="darkInputColors"
input-class="text-[#f5f5f7] placeholder:text-[#8e8e93]"
@input="setReportDetails"
/>
</k-list>
<template #buttons
><k-dialog-button @click="closeReport">{{
phone.t('Common.cancel')
@@ -2900,6 +2886,8 @@ onBeforeUnmount(() => {
:key="option.value"
link
link-component="button"
content-class="w-full"
:chevron="false"
:title="option.label"
@click="chooseSelection(option.value)"
>
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -45,7 +45,9 @@ const requestedMessageMedia = computed<GalleryFilter | null>(() => {
return value === 'photo' || value === 'video' ? value : null
})
const multipleSelection = computed(
() => requestedMessageMedia.value !== null && (messageMedia.request?.maxSelection ?? 1) > 1,
() =>
requestedMessageMedia.value !== null &&
(messageMedia.request?.maxSelection ?? 1) > 1,
)
const selectedMediaIds = ref<number[]>([])
const media = ref<PhoneMedia[]>([])
@@ -210,14 +212,14 @@ function openMedia(entry: PhoneMedia): void {
if (multipleSelection.value) {
const index = selectedMediaIds.value.indexOf(entry.id)
if (index >= 0) selectedMediaIds.value.splice(index, 1)
else if (selectedMediaIds.value.length < (messageMedia.request?.maxSelection ?? 1)) {
else if (
selectedMediaIds.value.length <
(messageMedia.request?.maxSelection ?? 1)
) {
selectedMediaIds.value.push(entry.id)
}
return
}
const returnPath = messageMedia.complete(entry)
if (returnPath) void router.replace(returnPath)
return
}
landscapeViewer.value = false
phone.setCameraLandscape(false)
@@ -226,6 +228,12 @@ function openMedia(entry: PhoneMedia): void {
imagePan.value = { x: 0, y: 0 }
}
function completeSingleSelection(): void {
if (!selected.value) return
const returnPath = messageMedia.complete(selected.value)
if (returnPath) void router.replace(returnPath)
}
function completeMultipleSelection(): void {
const selectedMedia = selectedMediaIds.value.flatMap((id) => {
const entry = media.value.find((item) => item.id === id)
@@ -405,7 +413,9 @@ onBeforeUnmount(() => {
v-for="entry in media"
:key="entry.id"
class="gallery-tile"
:class="{ 'gallery-tile--selected': selectedMediaIds.includes(entry.id) }"
:class="{
'gallery-tile--selected': selectedMediaIds.includes(entry.id),
}"
type="button"
:aria-label="
phone.t(
@@ -499,6 +509,14 @@ onBeforeUnmount(() => {
</template>
<template #right>
<k-link
v-if="requestedMessageMedia"
component="button"
@click="completeSingleSelection"
>
{{ phone.t('Common.use') }}
</k-link>
<k-link
v-else
component="button"
icon-only
class="text-red-500"
+21 -9
View File
@@ -741,18 +741,29 @@ onBeforeUnmount(() => {
}
.garage-vehicle__visual > i {
position: absolute;
top: 8px;
right: 7px;
padding: 3px 6px;
top: 7px;
left: 50%;
display: inline-flex;
width: max-content;
min-width: 48px;
max-width: 70px;
height: 14px;
padding: 0 6px;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 999px;
background: rgb(20 92 48 / 78%);
color: #fff;
font-size: 10px;
font-size: 7px;
font-style: normal;
font-weight: 720;
line-height: 1;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.02em;
backdrop-filter: blur(12px);
transform: translateX(-50%);
}
.garage-vehicle__visual > i.is-out {
background: rgb(176 91 0 / 82%);
@@ -794,14 +805,15 @@ onBeforeUnmount(() => {
}
.garage-vehicle__title > b {
flex: none;
padding: 4px 7px;
padding: 2px 5px;
border: 0.5px solid var(--garage-separator);
border-radius: 7px;
border-radius: 5px;
background: var(--garage-surface-muted);
color: var(--garage-text);
font-size: 11px;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.2;
letter-spacing: 0.02em;
}
.garage-vehicle__meta {
display: flex;
+590 -3
View File
@@ -1,6 +1,25 @@
<script setup lang="ts">
import { kFab, kPage } from 'konsta/vue'
import { LocateFixed, Map, MapPinned, Route, Satellite } from 'lucide-vue-next'
import {
kButton,
kFab,
kList,
kListInput,
kPage,
kPreloader,
kSheet,
kToast,
} from 'konsta/vue'
import {
LocateFixed,
Map,
MapPin,
MapPinned,
MapPinPlus,
Route,
Satellite,
Trash2,
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import {
@@ -8,15 +27,19 @@ import {
defaultCayoStyle,
defaultMainlandStyle,
defaultMapCoordinates,
defaultMapPercentToWorld,
defaultMapWorldToPercent,
type MapPoint,
} from '@/features/map/defaultMapGeometry'
import { useMapStore } from '@/stores/map'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import type { MapMarker, MapMarkerColor } from '@/types/map'
import { nuiCall, type NuiResponse } from '@/utils/nui'
type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
const phone = usePhoneStore()
const mapStore = useMapStore()
const mapStyle = ref<MapStyle>('default')
const zoom = ref(1.1)
const pan = ref<MapPoint>({ x: 0, y: 0 })
@@ -31,8 +54,16 @@ const canvasRef = ref<HTMLElement | null>(null)
const locationRef = ref<HTMLElement | null>(null)
const isPointerDown = ref(false)
const isPanning = ref(false)
const placingMarker = ref(false)
const draftCoords = ref<(MapPoint & { z: number }) | null>(null)
const markerLabel = ref('')
const markerColor = ref<MapMarkerColor>('blue')
const selectedMarker = ref<MapMarker | null>(null)
const markerError = ref('')
const toastText = ref('')
let pointerMoveFrame: number | undefined
let wheelZoomFrame: number | undefined
let toastTimer: number | undefined
let pendingWheelDirection: -1 | 0 | 1 = 0
let pendingWheelPoint: MapPoint | undefined
@@ -61,6 +92,13 @@ const mapStyles = [
{ id: 'atlas' as const, icon: Map },
{ id: 'roads' as const, icon: Route },
]
const markerColors: Array<{ id: MapMarkerColor; value: string }> = [
{ id: 'blue', value: '#0a84ff' },
{ id: 'green', value: '#30d158' },
{ id: 'orange', value: '#ff9f0a' },
{ id: 'red', value: '#ff453a' },
{ id: 'purple', value: '#bf5af2' },
]
const mapControlColors = {
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
@@ -122,6 +160,21 @@ const worldToPercent = (coords: MapPoint): MapPoint => {
}
}
const percentToWorld = (point: MapPoint): MapPoint => {
if (mapStyle.value === 'default') {
return defaultMapPercentToWorld(point)
}
const projected = {
x: mapBounds.minX + point.x * (mapBounds.maxX - mapBounds.minX),
y: mapBounds.maxY - point.y * (mapBounds.maxY - mapBounds.minY),
}
return {
x: (projected.x - mapOrigin.x) * mapScale.x,
y: (projected.y - mapOrigin.y) * mapScale.y,
}
}
const locationStyle = computed(() => {
if (!currentLocation.value) return undefined
const percent = worldToPercent(currentLocation.value)
@@ -132,6 +185,39 @@ const locationStyle = computed(() => {
}
})
function markerStyle(marker: MapMarker): Record<string, string> {
const percent = worldToPercent(marker.coords)
return {
left: `${percent.x * 100}%`,
top: `${percent.y * 100}%`,
transform: `translate(-50%, -100%) scale(${1 / zoom.value})`,
}
}
function markerColorValue(color: MapMarkerColor): string {
return (
markerColors.find((candidate) => candidate.id === color)?.value ??
markerColors[0].value
)
}
function showToast(message: string): void {
if (toastTimer) window.clearTimeout(toastTimer)
toastText.value = message
toastTimer = window.setTimeout(() => {
toastText.value = ''
toastTimer = undefined
}, 2200)
}
function markerErrorText(error?: string): string {
const key = error ?? 'request_failed'
const translated = phone.t(`Apps.map.errors.${key}`)
return translated === `Apps.map.errors.${key}`
? phone.t('Apps.map.errors.request_failed')
: translated
}
function setMapStyle(style: MapStyle): void {
mapStyle.value = style
imageError.value = false
@@ -144,6 +230,116 @@ function cycleMapStyle(): void {
setMapStyle(mapStyles[(currentIndex + 1) % mapStyles.length].id)
}
function startMarkerPlacement(): void {
selectedMarker.value = null
draftCoords.value = null
markerError.value = ''
placingMarker.value = true
}
function cancelMarkerPlacement(): void {
placingMarker.value = false
}
function openMarkerEditor(): void {
const viewport = viewportRef.value?.getBoundingClientRect()
const canvas = canvasRef.value?.getBoundingClientRect()
if (!viewport || !canvas || canvas.width <= 0 || canvas.height <= 0) {
showToast(phone.t('Apps.map.errors.request_failed'))
return
}
const percent = {
x: Math.min(
1,
Math.max(
0,
(viewport.left + viewport.width / 2 - canvas.left) / canvas.width,
),
),
y: Math.min(
1,
Math.max(
0,
(viewport.top + viewport.height / 2 - canvas.top) / canvas.height,
),
),
}
const coords = percentToWorld(percent)
draftCoords.value = {
x: Math.round(coords.x * 100) / 100,
y: Math.round(coords.y * 100) / 100,
z: 0,
}
markerLabel.value = ''
markerColor.value = 'blue'
markerError.value = ''
placingMarker.value = false
}
function updateMarkerLabel(event: Event): void {
markerLabel.value = (event.target as HTMLInputElement).value
}
function closeMarkerSheet(): void {
if (mapStore.isLoading) return
draftCoords.value = null
selectedMarker.value = null
markerError.value = ''
}
function selectMarker(marker: MapMarker): void {
if (placingMarker.value) return
selectedMarker.value = marker
markerError.value = ''
}
function handleMarkerResponse(
response: NuiResponse,
successMessage: string,
): boolean {
if (!response.success) {
markerError.value = markerErrorText(response.error)
return false
}
showToast(successMessage)
return true
}
async function saveMarker(): Promise<void> {
const coords = draftCoords.value
const label = markerLabel.value.trim()
if (!coords || !label || label.length > 40) {
markerError.value = phone.t('Apps.map.errors.invalid_marker')
return
}
const response = await mapStore.create({
color: markerColor.value,
coords,
label,
})
if (!handleMarkerResponse(response, phone.t('Apps.map.markerSaved'))) return
draftCoords.value = null
}
async function deleteSelectedMarker(): Promise<void> {
const marker = selectedMarker.value
if (!marker) return
const response = await mapStore.remove(marker.id)
if (!handleMarkerResponse(response, phone.t('Apps.map.markerDeleted'))) return
selectedMarker.value = null
}
async function setSelectedMarkerWaypoint(): Promise<void> {
const marker = selectedMarker.value
if (!marker) return
const response = await nuiCall('map:setWaypoint', { coords: marker.coords })
if (!handleMarkerResponse(response, phone.t('Apps.map.waypointSet'))) return
selectedMarker.value = null
}
function changeZoom(direction: -1 | 1, focalPoint?: MapPoint): void {
const targetZoom =
direction > 0 ? zoom.value * zoomFactor : zoom.value / zoomFactor
@@ -268,11 +464,13 @@ async function loadCurrentLocation(center: boolean): Promise<void> {
onMounted(() => {
void loadCurrentLocation(false)
void mapStore.load()
})
onBeforeUnmount(() => {
if (pointerMoveFrame) cancelAnimationFrame(pointerMoveFrame)
if (wheelZoomFrame) cancelAnimationFrame(wheelZoomFrame)
if (toastTimer) window.clearTimeout(toastTimer)
})
</script>
@@ -320,6 +518,32 @@ onBeforeUnmount(() => {
<span class="current-location__pulse"></span>
<span class="current-location__dot"></span>
</div>
<button
v-for="marker in mapStore.markers"
:key="marker.id"
type="button"
class="custom-map-marker"
:style="markerStyle(marker)"
:aria-label="marker.label"
@pointerdown.stop
@click.stop="selectMarker(marker)"
>
<MapPin
:size="30"
:style="{ color: markerColorValue(marker.color) }"
fill="currentColor"
aria-hidden="true"
/>
<span>{{ marker.label }}</span>
</button>
</div>
<div
v-if="placingMarker"
class="map-placement-crosshair"
aria-hidden="true"
>
<span></span>
</div>
<p v-if="imageError" class="map-error">
@@ -340,6 +564,19 @@ onBeforeUnmount(() => {
<component :is="activeMapStyle.icon" aria-hidden="true" />
</template>
</k-fab>
<k-fab
component="button"
type="button"
class="map-control map-control--marker"
:colors="locationControlColors"
:disabled="placingMarker"
:aria-label="phone.t('Apps.map.addMarker')"
@click="startMarkerPlacement"
>
<template #icon>
<MapPinPlus aria-hidden="true" />
</template>
</k-fab>
<k-fab
component="button"
type="button"
@@ -354,6 +591,134 @@ onBeforeUnmount(() => {
</template>
</k-fab>
</nav>
<section v-if="placingMarker" class="map-placement-panel">
<strong>{{ phone.t('Apps.map.placeMarker') }}</strong>
<span>{{ phone.t('Apps.map.placeMarkerHint') }}</span>
<div>
<k-button small rounded outline @click="cancelMarkerPlacement">
<X :size="16" />
{{ phone.t('Common.cancel') }}
</k-button>
<k-button small rounded @click="openMarkerEditor">
<MapPin :size="16" />
{{ phone.t('Apps.map.addHere') }}
</k-button>
</div>
</section>
<k-sheet
:opened="Boolean(draftCoords || selectedMarker)"
class="map-marker-sheet"
@backdropclick="closeMarkerSheet"
>
<section
v-if="draftCoords"
class="map-marker-sheet__content"
:class="{ 'map-marker-sheet__content--dark': phone.isDarkMode }"
role="dialog"
aria-modal="true"
:aria-label="phone.t('Apps.map.newMarker')"
>
<h2>{{ phone.t('Apps.map.newMarker') }}</h2>
<p>{{ phone.t('Apps.map.newMarkerDescription') }}</p>
<k-list inset strong>
<k-list-input
input-id="map-marker-label"
:label="phone.t('Apps.map.markerName')"
:placeholder="phone.t('Apps.map.markerNamePlaceholder')"
:value="markerLabel"
maxlength="40"
outline
@input="updateMarkerLabel"
@keydown.enter="saveMarker"
/>
</k-list>
<span class="map-marker-sheet__label">{{
phone.t('Apps.map.markerColor')
}}</span>
<div
class="map-marker-colors"
role="radiogroup"
:aria-label="phone.t('Apps.map.markerColor')"
>
<button
v-for="color in markerColors"
:key="color.id"
type="button"
role="radio"
:aria-checked="markerColor === color.id"
:aria-label="phone.t(`Apps.map.colors.${color.id}`)"
:class="{ 'map-marker-color--active': markerColor === color.id }"
:style="{ backgroundColor: color.value }"
@click="markerColor = color.id"
></button>
</div>
<p v-if="markerError" class="map-marker-error" role="alert">
{{ markerError }}
</p>
<k-button
large
rounded
:disabled="mapStore.isLoading || !markerLabel.trim()"
@click="saveMarker"
>
<k-preloader v-if="mapStore.isLoading" />
<template v-else>{{ phone.t('Apps.map.saveMarker') }}</template>
</k-button>
</section>
<section
v-else-if="selectedMarker"
class="map-marker-sheet__content map-marker-sheet__content--details"
:class="{ 'map-marker-sheet__content--dark': phone.isDarkMode }"
role="dialog"
aria-modal="true"
:aria-label="selectedMarker.label"
>
<span
class="map-marker-sheet__pin"
:style="{ color: markerColorValue(selectedMarker.color) }"
>
<MapPin :size="32" fill="currentColor" />
</span>
<h2>{{ selectedMarker.label }}</h2>
<p>
{{ selectedMarker.coords.x.toFixed(1) }},
{{ selectedMarker.coords.y.toFixed(1) }}
</p>
<p v-if="markerError" class="map-marker-error" role="alert">
{{ markerError }}
</p>
<k-button
large
rounded
class="map-marker-waypoint"
:disabled="mapStore.isLoading"
@click="setSelectedMarkerWaypoint"
>
<Route :size="17" />
{{ phone.t('Apps.map.setWaypoint') }}
</k-button>
<k-button
large
rounded
class="map-marker-delete"
:disabled="mapStore.isLoading"
@click="deleteSelectedMarker"
>
<k-preloader v-if="mapStore.isLoading" />
<template v-else>
<Trash2 :size="17" />
{{ phone.t('Apps.map.deleteMarker') }}
</template>
</k-button>
</section>
</k-sheet>
<k-toast :opened="Boolean(toastText)" position="center">
{{ toastText }}
</k-toast>
</k-page>
</template>
@@ -428,6 +793,84 @@ onBeforeUnmount(() => {
box-shadow: 0 1px 4px rgb(0 0 0 / 35%);
}
.custom-map-marker {
position: absolute;
z-index: 2;
display: flex;
padding: 0;
align-items: center;
flex-direction: column;
border: 0;
color: #fff;
background: transparent;
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 45%));
transform-origin: 50% 100%;
white-space: nowrap;
}
.custom-map-marker svg {
stroke: #fff;
stroke-width: 1.8;
}
.custom-map-marker span {
max-width: 112px;
padding: 3px 7px;
overflow: hidden;
border: 0.5px solid rgb(255 255 255 / 18%);
border-radius: 8px;
background: rgb(20 20 22 / 84%);
box-shadow: 0 2px 7px rgb(0 0 0 / 30%);
backdrop-filter: blur(12px);
font-size: 10px;
font-weight: 650;
text-overflow: ellipsis;
}
.map-placement-crosshair {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
width: 42px;
height: 42px;
border: 1px solid rgb(255 255 255 / 80%);
border-radius: 50%;
box-shadow:
0 2px 12px rgb(0 0 0 / 40%),
inset 0 0 0 1px rgb(0 0 0 / 16%);
transform: translate(-50%, -50%);
pointer-events: none;
}
.map-placement-crosshair::before,
.map-placement-crosshair::after,
.map-placement-crosshair span {
position: absolute;
top: 50%;
left: 50%;
background: #fff;
content: '';
transform: translate(-50%, -50%);
}
.map-placement-crosshair::before {
width: 16px;
height: 1px;
}
.map-placement-crosshair::after {
width: 1px;
height: 16px;
}
.map-placement-crosshair span {
width: 5px;
height: 5px;
border: 1px solid rgb(0 0 0 / 35%);
border-radius: 50%;
}
.map-controls {
position: absolute;
z-index: 3;
@@ -448,6 +891,150 @@ onBeforeUnmount(() => {
height: 21px;
}
.map-control--marker {
--color-primary: #0a84ff;
}
.map-placement-panel {
position: absolute;
z-index: 4;
right: 66px;
bottom: 28px;
left: 12px;
display: flex;
min-height: 84px;
padding: 12px;
flex-direction: column;
border: 0.5px solid rgb(255 255 255 / 22%);
border-radius: 18px;
color: #fff;
background: rgb(24 24 27 / 86%);
box-shadow: 0 8px 24px rgb(0 0 0 / 32%);
backdrop-filter: blur(22px) saturate(145%);
}
.map-placement-panel strong {
font-size: 13px;
}
.map-placement-panel > span {
margin-top: 2px;
color: rgb(255 255 255 / 64%);
font-size: 10px;
line-height: 1.25;
}
.map-placement-panel > div {
display: grid;
margin-top: 10px;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.map-placement-panel :deep(button) {
min-height: 32px;
font-size: 11px;
}
.map-marker-sheet__content {
display: flex;
min-height: 350px;
padding: 22px 16px calc(18px + env(safe-area-inset-bottom));
flex-direction: column;
color: #111;
}
.map-marker-sheet__content--dark {
color: #fff;
}
.map-marker-sheet__content h2 {
margin: 0;
font-size: 20px;
font-weight: 750;
letter-spacing: -0.4px;
text-align: center;
}
.map-marker-sheet__content > p {
margin: 5px 0 14px;
color: rgb(60 60 67 / 60%);
font-size: 12px;
line-height: 1.4;
text-align: center;
}
.map-marker-sheet__content--dark > p {
color: rgb(235 235 245 / 60%);
}
.map-marker-sheet__label {
margin: 13px 5px 8px;
color: rgb(60 60 67 / 60%);
font-size: 12px;
}
.map-marker-sheet__content--dark .map-marker-sheet__label {
color: rgb(235 235 245 / 60%);
}
.map-marker-colors {
display: flex;
margin-bottom: 18px;
justify-content: center;
gap: 14px;
}
.map-marker-colors button {
width: 31px;
height: 31px;
padding: 0;
border: 3px solid transparent;
border-radius: 50%;
box-shadow: 0 1px 4px rgb(0 0 0 / 25%);
}
.map-marker-colors .map-marker-color--active {
border-color: #fff;
outline: 2px solid #0a84ff;
}
.map-marker-error {
color: #ff3b30 !important;
font-size: 11px !important;
}
.map-marker-sheet__content--details {
min-height: 250px;
align-items: center;
}
.map-marker-sheet__pin {
display: grid;
width: 58px;
height: 58px;
margin-bottom: 10px;
place-items: center;
border-radius: 18px;
background: rgb(120 120 128 / 12%);
}
.map-marker-sheet__pin svg {
stroke: #fff;
}
.map-marker-sheet__content .map-marker-delete {
width: 100%;
margin-top: 10px;
color: #fff;
background: #ff3b30;
}
.map-marker-sheet__content .map-marker-waypoint {
width: 100%;
margin-top: auto;
}
.map-error {
position: absolute;
top: 50%;
+83 -29
View File
@@ -14,6 +14,7 @@ import {
kNavbarBackLink,
kPage,
kPreloader,
kSearchbar,
kToast,
kToolbarPane,
} from 'konsta/vue'
@@ -394,7 +395,8 @@ async function saveContactDetails(): Promise<void> {
return
}
contactEditing.value = false
contactNumberDraft.value = response.data?.phone_number ?? contactNumberDraft.value
contactNumberDraft.value =
response.data?.phone_number ?? contactNumberDraft.value
}
async function deleteActiveContact(): Promise<void> {
@@ -432,7 +434,10 @@ function openEmojiPicker(): void {
emojiOpen.value = true
}
function openMediaApp(app: 'camera' | 'photos', mediaType: 'photo' | 'video'): void {
function openMediaApp(
app: 'camera' | 'photos',
mediaType: 'photo' | 'video',
): void {
if (!messages.activeNumber) return
attachmentMenuOpen.value = false
messageMedia.begin(messages.activeNumber, mediaType)
@@ -536,7 +541,10 @@ function sampleMicrophone(): void {
async function startVoiceRecording(): Promise<void> {
emojiOpen.value = false
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
if (
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === 'undefined'
) {
showToast(phone.t('Apps.messages.microphoneUnavailable'))
return
}
@@ -618,7 +626,13 @@ function compressedWaveform(): number[] {
const end = Math.max(start + 1, Math.floor((index + 1) * bucketSize))
const bucket = recordingSamples.slice(start, end)
result.push(
Math.max(0.08, Math.min(1, bucket.reduce((sum, value) => sum + value, 0) / bucket.length)),
Math.max(
0.08,
Math.min(
1,
bucket.reduce((sum, value) => sum + value, 0) / bucket.length,
),
),
)
}
return result
@@ -694,7 +708,9 @@ onBeforeUnmount(() => {
>
<k-navbar large transparent :title="phone.t('Apps.messages.name')" />
<div class="messages-empty-state">
<span class="messages-empty-state__icon"><MessageCircle :size="35" /></span>
<span class="messages-empty-state__icon"
><MessageCircle :size="35"
/></span>
<h2>{{ phone.t('Apps.messages.noSim') }}</h2>
<p>{{ phone.t('Apps.messages.noSimBody') }}</p>
</div>
@@ -746,10 +762,7 @@ onBeforeUnmount(() => {
</k-glass>
</header>
<div
v-if="filteredConversations.length"
class="messages-conversation-list"
>
<div v-if="filteredConversations.length" class="messages-conversation-list">
<button
v-for="conversation in filteredConversations"
:key="conversation.phoneNumber"
@@ -797,7 +810,9 @@ onBeforeUnmount(() => {
<span class="messages-conversation__body">
<span class="messages-conversation__headline">
<strong>{{ contactName(conversation.phoneNumber) }}</strong>
<time>{{ formatConversationDate(conversation.lastMessageAt) }}</time>
<time>{{
formatConversationDate(conversation.lastMessageAt)
}}</time>
<ChevronRight :size="13" />
</span>
<span class="messages-conversation__preview">
@@ -808,7 +823,9 @@ onBeforeUnmount(() => {
</div>
<div v-else class="messages-empty-state messages-empty-state--list">
<span class="messages-empty-state__icon"><MessageCircle :size="35" /></span>
<span class="messages-empty-state__icon"
><MessageCircle :size="35"
/></span>
<h2>
{{
phone.t(
@@ -823,16 +840,31 @@ onBeforeUnmount(() => {
</div>
<footer v-if="!editingList" class="messages-inbox-toolbar">
<label>
<Search :size="16" />
<input
<div class="messages-inbox-search">
<k-searchbar
:value="search"
type="search"
:placeholder="phone.t('Apps.messages.search')"
:colors="{
inputBgIos: 'bg-transparent',
placeholderIos: 'placeholder-[#8e8e93]',
}"
:clear-button="false"
:input-style="{
color: phone.isDarkMode ? '#f5f5f7' : '#111',
paddingRight: '48px',
}"
@input="search = eventValue($event)"
@clear="search = ''"
/>
<Mic :size="16" />
</label>
<k-link
component="button"
icon-only
class="messages-inbox-search__voice"
:aria-label="phone.t('Apps.messages.search')"
>
<Mic :size="20" />
</k-link>
</div>
<k-glass
component="button"
type="button"
@@ -843,7 +875,11 @@ onBeforeUnmount(() => {
</k-glass>
</footer>
<footer v-else class="messages-edit-toolbar">
<span>{{ phone.t('Apps.messages.selectedCount', { count: String(selectedNumbers.length) }) }}</span>
<span>{{
phone.t('Apps.messages.selectedCount', {
count: String(selectedNumbers.length),
})
}}</span>
<button
type="button"
:disabled="!selectedNumbers.length"
@@ -881,7 +917,11 @@ onBeforeUnmount(() => {
@keyup.enter="chooseRecipient(composerNumber)"
/>
</k-list>
<k-list v-if="contactSuggestions.length" class="messages-contact-list" strong>
<k-list
v-if="contactSuggestions.length"
class="messages-contact-list"
strong
>
<k-list-item
v-for="contact in contactSuggestions"
:key="contact.id"
@@ -895,7 +935,9 @@ onBeforeUnmount(() => {
class="messages-avatar messages-avatar--small"
:style="avatarStyle(contact.phone_number)"
>
<span class="messages-avatar__glyph">{{ avatarGlyph(contact.phone_number) }}</span>
<span class="messages-avatar__glyph">{{
avatarGlyph(contact.phone_number)
}}</span>
</span>
</template>
</k-list-item>
@@ -919,27 +961,32 @@ onBeforeUnmount(() => {
:aria-label="activeTitle"
>
<header class="messages-chat-header">
<button
<k-link
component="button"
icon-only
type="button"
class="messages-chat-header__back"
:aria-label="phone.t('Apps.messages.name')"
@click="goBack"
>
<ChevronLeft :size="28" :stroke-width="2.35" />
</button>
</k-link>
<div class="messages-chat-header__contact">
<span
class="messages-avatar messages-avatar--header"
:class="{ 'messages-avatar--unknown': !activeContact }"
:style="avatarStyle(messages.activeNumber ?? '')"
>
<span v-if="activeContact" class="messages-avatar__glyph">{{ avatarGlyph(messages.activeNumber ?? '') }}</span>
<span v-if="activeContact" class="messages-avatar__glyph">{{
avatarGlyph(messages.activeNumber ?? '')
}}</span>
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
<i />
<b />
</span>
</span>
<button
<k-link
component="button"
type="button"
class="messages-chat-header__name"
:aria-label="phone.t('Apps.messages.contactDetails')"
@@ -947,7 +994,7 @@ onBeforeUnmount(() => {
>
<strong>{{ activeTitle }}</strong>
<ChevronRight :size="13" />
</button>
</k-link>
</div>
</header>
@@ -977,7 +1024,9 @@ onBeforeUnmount(() => {
:class="{ 'messages-avatar--unknown': !activeContact }"
:style="avatarStyle(messages.activeNumber ?? '')"
>
<span v-if="activeContact" class="messages-avatar__glyph">{{ avatarGlyph(messages.activeNumber ?? '') }}</span>
<span v-if="activeContact" class="messages-avatar__glyph">{{
avatarGlyph(messages.activeNumber ?? '')
}}</span>
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
<i />
<b />
@@ -1035,11 +1084,17 @@ onBeforeUnmount(() => {
</section>
<k-messages class="messages-bubbles">
<template v-for="(message, index) in messages.messages" :key="message.client_id ?? message.id">
<template
v-for="(message, index) in messages.messages"
:key="message.client_id ?? message.id"
>
<k-messages-title v-if="startsDay(message, index)">
<span class="messages-thread-timestamp">
<span>{{ phone.t('Apps.messages.smsLabel') }}</span>
<b>{{ dayLabel(message.created_at) }}, {{ timeLabel(message.created_at) }}</b>
<b
>{{ dayLabel(message.created_at) }},
{{ timeLabel(message.created_at) }}</b
>
</span>
</k-messages-title>
<k-message
@@ -1166,7 +1221,6 @@ onBeforeUnmount(() => {
</button>
</section>
<k-messagebar
v-else
class="messages-messagebar"
+466 -3
View File
@@ -17,22 +17,29 @@ import {
kPreloader,
kRange,
kSearchbar,
kSegmented,
kSegmentedButton,
kToast,
kToggle,
} from 'konsta/vue'
import {
BellRing,
Bluetooth,
Check,
EyeOff,
KeyRound,
Monitor,
Moon,
Plane,
RotateCcw,
RotateCw,
Settings,
Signal,
Smartphone,
Sun,
UserRound,
Volume2,
Wifi,
} from 'lucide-vue-next'
import {
computed,
@@ -45,6 +52,7 @@ import {
import { PHONE_FRAME_COLORS } from '@/config/appearance'
import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import PhonePasscode from '@/components/PhonePasscode.vue'
import { useAccountStore } from '@/stores/account'
import type {
LaunchablePhoneAppDefinition,
@@ -74,14 +82,31 @@ import {
type SettingsView =
| 'root'
| 'account'
| 'security'
| 'notifications'
| 'notification-detail'
| 'sounds'
| 'connectivity'
| 'focus'
| 'general'
| 'appearance'
| 'wallpaper'
type RootToggleKey = 'airplaneMode' | 'streamerMode'
type RootToggleKey =
| 'airplaneMode'
| 'streamerMode'
| 'focusMode'
| 'wifiEnabled'
| 'bluetoothEnabled'
| 'cellularEnabled'
type SubmenuView = Exclude<SettingsView, 'root' | 'notification-detail'>
type PasscodeFlow =
| 'set-new'
| 'set-confirm'
| 'change-current'
| 'change-new'
| 'change-confirm'
| 'disable'
| null
const FACTORY_RESET_DURATION_MS = 60_000
const FACTORY_RESET_CIRCUMFERENCE = 2 * Math.PI * 48
@@ -110,6 +135,13 @@ const accountPassword = ref('')
const accountConfirm = ref('')
const accountSubmitting = ref(false)
const accountToast = ref('')
const passcodeBusy = ref(false)
const passcodeCurrent = ref('')
const passcodeError = ref('')
const passcodeFirst = ref('')
const passcodeFlow = ref<PasscodeFlow>(null)
const passcodeLength = ref<4 | 6>(6)
const passcodeResetKey = ref(0)
const removeDeviceImei = ref('')
const removeDevicePassword = ref('')
const removeDeviceOpened = ref(false)
@@ -152,6 +184,24 @@ const serviceRows = [
},
]
const preferenceRows = [
{
key: 'connectivity',
view: 'connectivity' as const,
icon: Wifi,
iconColor: '#007aff',
},
{
key: 'focus',
view: 'focus' as const,
icon: Moon,
iconColor: '#5856d6',
},
{
key: 'security',
view: 'security' as const,
icon: KeyRound,
iconColor: '#34c759',
},
{
key: 'general',
view: 'general' as const,
@@ -172,6 +222,27 @@ const preferenceRows = [
},
]
const connectivityRows = [
{
key: 'wifi',
preferenceKey: 'wifiEnabled' as const,
icon: Wifi,
iconColor: '#007aff',
},
{
key: 'bluetooth',
preferenceKey: 'bluetoothEnabled' as const,
icon: Bluetooth,
iconColor: '#007aff',
},
{
key: 'cellular',
preferenceKey: 'cellularEnabled' as const,
icon: Signal,
iconColor: '#34c759',
},
]
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
const visibleToggleRows = computed(() =>
toggleRows.filter((row) => matchesSearch(row.key)),
@@ -204,6 +275,21 @@ const activeTitle = computed(() => {
}
return phone.t(`Apps.settings.${activeView.value}`)
})
const passcodeTitle = computed(() => {
if (passcodeFlow.value === 'set-confirm') {
return phone.t('Apps.settings.passcode.confirmNew')
}
if (
passcodeFlow.value === 'change-current' ||
passcodeFlow.value === 'disable'
) {
return phone.t('Apps.settings.passcode.enterCurrent')
}
if (passcodeFlow.value === 'change-confirm') {
return phone.t('Apps.settings.passcode.confirmNew')
}
return phone.t('Apps.settings.passcode.enterNew')
})
function matchesSearch(key: string): boolean {
return (
@@ -220,6 +306,9 @@ function updateSearch(event: Event): void {
}
function openView(view: SubmenuView): void {
if (view === 'security') {
passcodeLength.value = phone.security.length ?? 6
}
activeView.value = view
scrollPageToTop()
}
@@ -248,12 +337,122 @@ function toggleRootSetting(key: RootToggleKey): void {
phone.setPreference(key, !phone.preferences.settings[key])
}
function resetPasscodeInput(): void {
passcodeError.value = ''
passcodeResetKey.value += 1
}
function beginSetPasscode(): void {
passcodeLength.value = phone.security.length ?? passcodeLength.value
passcodeFirst.value = ''
passcodeCurrent.value = ''
passcodeFlow.value = 'set-new'
resetPasscodeInput()
}
function beginChangePasscode(): void {
passcodeFirst.value = ''
passcodeCurrent.value = ''
passcodeFlow.value = 'change-current'
resetPasscodeInput()
}
function beginDisablePasscode(): void {
passcodeLength.value = phone.security.length ?? 6
passcodeCurrent.value = ''
passcodeFlow.value = 'disable'
resetPasscodeInput()
}
function cancelPasscodeFlow(): void {
if (passcodeBusy.value) return
passcodeFlow.value = null
passcodeFirst.value = ''
passcodeCurrent.value = ''
resetPasscodeInput()
}
function passcodeRequestError(error?: string): string {
if (error === 'invalid_passcode') {
return phone.t('Apps.settings.passcode.incorrect')
}
if (error === 'passcode_locked') {
return phone.t('Apps.settings.passcode.locked')
}
if (error === 'rate_limited') {
return phone.t('Apps.settings.passcode.rateLimited')
}
return phone.t('Apps.settings.passcode.failed')
}
async function submitSettingsPasscode(passcode: string): Promise<void> {
if (passcodeBusy.value || !passcodeFlow.value) return
if (passcodeFlow.value === 'set-new') {
passcodeFirst.value = passcode
passcodeFlow.value = 'set-confirm'
resetPasscodeInput()
return
}
if (passcodeFlow.value === 'change-current') {
passcodeCurrent.value = passcode
passcodeFlow.value = 'change-new'
resetPasscodeInput()
return
}
if (passcodeFlow.value === 'change-new') {
passcodeFirst.value = passcode
passcodeFlow.value = 'change-confirm'
resetPasscodeInput()
return
}
if (
(passcodeFlow.value === 'set-confirm' ||
passcodeFlow.value === 'change-confirm') &&
passcode !== passcodeFirst.value
) {
passcodeError.value = phone.t('Apps.settings.passcode.mismatch')
passcodeResetKey.value += 1
return
}
passcodeBusy.value = true
const response =
passcodeFlow.value === 'set-confirm'
? await phone.setPasscode(passcode)
: passcodeFlow.value === 'change-confirm'
? await phone.changePasscode(passcodeCurrent.value, passcode)
: await phone.disablePasscode(passcode)
passcodeBusy.value = false
if (!response.success) {
passcodeError.value = passcodeRequestError(response.error)
if (
passcodeFlow.value === 'change-confirm' &&
response.error === 'invalid_passcode'
) {
passcodeFlow.value = 'change-current'
passcodeCurrent.value = ''
passcodeFirst.value = ''
}
passcodeResetKey.value += 1
return
}
accountToast.value = phone.t(
passcodeFlow.value === 'disable'
? 'Apps.settings.passcode.disabled'
: 'Apps.settings.passcode.saved',
)
cancelPasscodeFlow()
}
function updateNumberPreference(
key:
| 'notificationDurationSeconds'
| 'notificationVolume'
| 'phoneScale'
| 'ringtoneVolume',
| 'ringtoneVolume'
| 'screenBrightness',
event: Event,
): void {
phone.setPreference(
@@ -551,12 +750,28 @@ onBeforeUnmount(() => {
<component :is="row.icon" :size="17" :stroke-width="2.25" />
</span>
</template>
<template v-if="row.key === 'security' || row.key === 'focus'" #after>
{{
phone.t(
(row.key === 'security' && phone.security.enabled) ||
(row.key === 'focus' && phone.preferences.settings.focusMode)
? 'Apps.settings.on'
: 'Apps.settings.off',
)
}}
</template>
</k-list-item>
</k-list>
</template>
<template v-else>
<k-navbar :title="activeTitle" class="top-0 sticky z-20">
<k-navbar
:title="activeTitle"
:class="[
'settings-detail-navbar sticky z-20',
{ 'settings-detail-navbar--dark': phone.isDarkMode },
]"
>
<template #left>
<k-navbar-back-link
component="button"
@@ -715,6 +930,85 @@ onBeforeUnmount(() => {
</template>
</template>
<template v-else-if="activeView === 'security'">
<k-block class="text-sm leading-5 opacity-70">
{{ phone.t('Apps.settings.passcode.description') }}
</k-block>
<template v-if="!phone.security.enabled">
<k-block-title>{{
phone.t('Apps.settings.passcode.codeLength')
}}</k-block-title>
<k-block>
<k-segmented strong rounded>
<k-segmented-button
:active="passcodeLength === 6"
@click="passcodeLength = 6"
>
{{ phone.t('Apps.settings.passcode.sixDigit') }}
</k-segmented-button>
<k-segmented-button
:active="passcodeLength === 4"
@click="passcodeLength = 4"
>
{{ phone.t('Apps.settings.passcode.fourDigit') }}
</k-segmented-button>
</k-segmented>
</k-block>
<k-list strong inset>
<k-list-button @click="beginSetPasscode">
{{ phone.t('Apps.settings.passcode.turnOn') }}
</k-list-button>
</k-list>
</template>
<template v-else>
<k-list strong inset>
<k-list-item
:title="phone.t('Apps.settings.passcode.status')"
:after="phone.t('Apps.settings.on')"
/>
<k-list-item
:title="phone.t('Apps.settings.passcode.codeLength')"
:after="
phone.t(
phone.security.length === 4
? 'Apps.settings.passcode.fourDigit'
: 'Apps.settings.passcode.sixDigit',
)
"
/>
</k-list>
<k-block-title>{{
phone.t('Apps.settings.passcode.codeLength')
}}</k-block-title>
<k-block>
<k-segmented strong rounded>
<k-segmented-button
:active="passcodeLength === 6"
@click="passcodeLength = 6"
>
{{ phone.t('Apps.settings.passcode.sixDigit') }}
</k-segmented-button>
<k-segmented-button
:active="passcodeLength === 4"
@click="passcodeLength = 4"
>
{{ phone.t('Apps.settings.passcode.fourDigit') }}
</k-segmented-button>
</k-segmented>
</k-block>
<k-list strong inset>
<k-list-button @click="beginChangePasscode">
{{ phone.t('Apps.settings.passcode.change') }}
</k-list-button>
<k-list-button class="!text-red-500" @click="beginDisablePasscode">
{{ phone.t('Apps.settings.passcode.turnOff') }}
</k-list-button>
</k-list>
</template>
</template>
<template v-else-if="activeView === 'notifications'">
<k-list strong inset>
<k-list-item
@@ -899,6 +1193,80 @@ onBeforeUnmount(() => {
</k-list>
</template>
<template v-else-if="activeView === 'connectivity'">
<k-list strong inset>
<k-list-item :title="phone.t('Apps.settings.airplaneMode')">
<template #media>
<span class="settings-row-icon bg-[#ff9500]">
<Plane :size="17" :stroke-width="2.25" />
</span>
</template>
<template #after>
<k-toggle
:checked="phone.preferences.settings.airplaneMode"
:aria-label="phone.t('Apps.settings.toggle.airplaneMode')"
@change="toggleRootSetting('airplaneMode')"
/>
</template>
</k-list-item>
</k-list>
<k-block-title>{{
phone.t('Apps.settings.connections')
}}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="row in connectivityRows"
:key="row.key"
:title="phone.t(`Apps.settings.${row.key}`)"
>
<template #media>
<span
class="settings-row-icon"
:style="{ backgroundColor: row.iconColor }"
>
<component :is="row.icon" :size="17" :stroke-width="2.25" />
</span>
</template>
<template #after>
<k-toggle
:checked="phone.preferences.settings[row.preferenceKey]"
:disabled="phone.preferences.settings.airplaneMode"
:aria-label="
phone.t(`Apps.settings.toggle.${row.preferenceKey}`)
"
@change="toggleRootSetting(row.preferenceKey)"
/>
</template>
</k-list-item>
</k-list>
<k-block class="text-sm leading-5 opacity-60">
{{ phone.t('Apps.settings.connectivityDescription') }}
</k-block>
</template>
<template v-else-if="activeView === 'focus'">
<k-list strong inset>
<k-list-item :title="phone.t('Apps.settings.focusMode')">
<template #media>
<span class="settings-row-icon bg-[#5856d6]">
<Moon :size="17" :stroke-width="2.25" />
</span>
</template>
<template #after>
<k-toggle
:checked="phone.preferences.settings.focusMode"
:aria-label="phone.t('Apps.settings.toggle.focusMode')"
@change="toggleRootSetting('focusMode')"
/>
</template>
</k-list-item>
</k-list>
<k-block class="text-sm leading-5 opacity-60">
{{ phone.t('Apps.settings.focusDescription') }}
</k-block>
</template>
<template v-else-if="activeView === 'general'">
<k-block-title>
{{ phone.t('Apps.settings.notificationDuration') }} ·
@@ -1010,6 +1378,48 @@ onBeforeUnmount(() => {
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.screenBrightness') }} ·
{{ phone.preferences.settings.screenBrightness }}%
</k-block-title>
<k-list strong inset>
<k-list-item>
<template #inner>
<div class="flex w-full items-center gap-3">
<Sun :size="16" class="shrink-0 opacity-55" />
<k-range
class="w-full"
:value="phone.preferences.settings.screenBrightness"
:min="10"
:max="100"
:aria-label="phone.t('Apps.settings.screenBrightness')"
@input="updateNumberPreference('screenBrightness', $event)"
/>
<Sun :size="23" class="shrink-0" />
</div>
</template>
</k-list-item>
<k-list-item :title="phone.t('Apps.settings.rotationLock')">
<template #media>
<span class="settings-row-icon bg-[#ff9500]">
<RotateCw :size="17" :stroke-width="2.25" />
</span>
</template>
<template #after>
<k-toggle
:checked="phone.preferences.settings.rotationLocked"
:aria-label="phone.t('Apps.settings.toggle.rotationLocked')"
@change="
phone.setPreference(
'rotationLocked',
!phone.preferences.settings.rotationLocked,
)
"
/>
</template>
</k-list-item>
</k-list>
<k-block-title>
{{ phone.t('Apps.settings.phoneScale') }} ·
{{ phone.preferences.settings.phoneScale }}%
@@ -1102,6 +1512,18 @@ onBeforeUnmount(() => {
</template>
</k-page>
<PhonePasscode
v-if="passcodeFlow"
:busy="passcodeBusy"
:error="passcodeError"
:length="passcodeLength"
:reset-key="passcodeResetKey"
:subtitle="phone.t('Apps.settings.passcode.screenSubtitle')"
:title="passcodeTitle"
@cancel="cancelPasscodeFlow"
@complete="submitSettingsPasscode"
/>
<div
v-if="factoryResetting"
class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-black px-8 text-center text-white"
@@ -1211,3 +1633,44 @@ onBeforeUnmount(() => {
{{ accountToast }}
</k-toast>
</template>
<style scoped>
.settings-row-icon {
display: flex;
width: 28px;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 7px;
color: #fff;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 35%),
0 1px 2px rgb(0 0 0 / 25%);
}
.settings-detail-navbar {
top: 0 !important;
background: rgb(248 248 248 / 94%);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
}
.settings-detail-navbar::before {
position: absolute;
right: 0;
bottom: 100%;
left: 0;
height: 44px;
background: rgb(248 248 248);
content: '';
}
.settings-detail-navbar--dark {
background: rgb(0 0 0 / 94%);
}
.settings-detail-navbar--dark::before {
background: #000;
}
</style>