Files
sky_phone-sky-systems/frontend/src/App.vue
T

1484 lines
47 KiB
Vue

<script setup lang="ts">
import { kApp } from 'konsta/vue'
import {
computed,
onBeforeUnmount,
onMounted,
ref,
type CSSProperties,
watch,
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
import PhoneMemoRecorder from '@/components/PhoneMemoRecorder.vue'
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
import PhonePasscode from '@/components/PhonePasscode.vue'
import PhoneSetupAssistant from '@/components/PhoneSetupAssistant.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import EasyShareSheet from '@/components/EasyShareSheet.vue'
import PayphoneOverlay from '@/components/PayphoneOverlay.vue'
import RadioHud from '@/components/RadioHud.vue'
import SimPhonePicker, {
type SimPhoneChoice,
} from '@/components/SimPhonePicker.vue'
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { useClockStore } from '@/stores/clock'
import { useGamesStore } from '@/features/games/store'
import { useCallsStore } from '@/stores/calls'
import { useBankingStore } from '@/stores/banking'
import { useBillingStore } from '@/stores/billing'
import { useCompaniesStore } from '@/stores/companies'
import { useAccountStore } from '@/stores/account'
import { useAppAuthStore } from '@/stores/app-auth'
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 { usePicstagramStore } from '@/stores/picstagram'
import { useFeatherStore } from '@/stores/feather'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppCatalogStore } from '@/stores/app-catalog'
import { useAppStoreStore } from '@/stores/app-store'
import { useWidgetsStore } from '@/stores/widgets'
import { isPhoneAppId } from '@/config/apps'
import { useNotesStore } from '@/stores/notes'
import { useMemosStore } from '@/stores/memos'
import { useWeatherStore } from '@/stores/weather'
import { useEasyShareStore } from '@/stores/easyshare'
import {
useNotificationsStore,
type PhoneNotification,
type PhoneNotificationInput,
} from '@/stores/notifications'
import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone'
import type { PhoneNotificationDevicePayload } from '@/types/device'
import type { MailCounts } from '@/types/mail'
import type { MarketplaceCounts } from '@/types/marketplace'
import type {
CompanyChangedPayload,
CompanyUnreadCounts,
} from '@/types/companies'
import type { PhoneCall } from '@/types/phone'
import type { EasyShareEvent } from '@/types/easyshare'
import { nuiCall } from '@/utils/nui'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
type?: string
data?:
| CalendarReminderData
| MailEventData
| MarketplaceEventData
| CompaniesEventData
| MessagesEventData
| DarkChatEventData
| FlareEventData
| FlipTokVerificationData
| FlipTokNotificationData
| PicstagramVerificationData
| PicstagramNotificationData
| FeatherNotificationData
| BillingNotificationData
| EasyShareEvent
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
| CustomAppCatalogEventData
| CustomAppEventData
}
type CustomAppCatalogEventData = {
apps?: unknown
}
type CustomAppEventData = {
appId?: unknown
data?: unknown
payload?: unknown
}
type SimPickerPayload = {
choices: SimPhoneChoice[]
number: string
}
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
device?: PhoneNotificationDevicePayload
}
type MailEventData = {
counts?: MailCounts
device?: PhoneNotificationDevicePayload
sender?: string
subject?: string
text?: string
title?: string
}
type MessagesEventData = {
device?: PhoneNotificationDevicePayload
phoneNumber?: string
sender?: string
text?: string
title?: string
}
type CompaniesEventData = {
area?: CompanyChangedPayload['area']
companyId?: string
counts?: CompanyUnreadCounts
device?: PhoneNotificationDevicePayload
kind?: 'assigned' | 'newMessage' | 'newRequest' | 'requestUpdated'
requestId?: string
subtitle?: string
text?: string
title?: string
}
type DarkChatEventData = {
conversationId?: string
device?: PhoneNotificationDevicePayload
notificationMode?: 'full' | 'private' | 'hidden'
preview?: string
sender?: string
text?: string
title?: string
}
type FlareEventData = {
body?: string
device?: PhoneNotificationDevicePayload
matchId?: string
sender?: string
text?: string
title?: string
}
type MarketplaceEventData = {
counts?: MarketplaceCounts
device?: PhoneNotificationDevicePayload
inquiryId?: string
listingId?: string
sender?: string
text?: string
title?: string
}
type CalendarReminderData = {
device?: PhoneNotificationDevicePayload
eventId?: string
eventTitle?: string
startsAt?: number
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
}
type PicstagramVerificationData = {
profileId: string
verified: boolean
}
type PicstagramNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
kind?: 'like' | 'comment' | 'follow' | 'follow_request' | 'verified'
postId?: string
text?: string
title?: string
}
type FeatherNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
kind?: 'like' | 'reply' | 'follow' | 'quote'
postId?: string
text?: string
title?: string
}
type BillingNotificationData = {
amount?: number
device?: PhoneNotificationDevicePayload
issuer?: string
text?: string
title?: string
}
type CrewLinkNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
groupName?: string
kind?: 'invite' | 'member_joined' | 'ping' | 'role' | 'removed'
pingLabel?: string
text?: string
title?: string
}
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
const DEVELOPMENT_PHONE_SCALE = 1.25
const PHONE_PORTRAIT_WIDTH = 390
const PHONE_PORTRAIT_HEIGHT = 844
const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
const isDevelopment = import.meta.env.DEV
const developmentParameters = new URLSearchParams(window.location.search)
const developmentLockScreenPreview =
isDevelopment && developmentParameters.has('lockScreenPreview')
const phone = usePhoneStore()
const account = useAccountStore()
const appAuth = useAppAuthStore()
const clock = useClockStore()
const games = useGamesStore()
const calls = useCallsStore()
const banking = useBankingStore()
const billing = useBillingStore()
const companies = useCompaniesStore()
const mail = useMailStore()
const messages = useMessagesStore()
const darkchat = useDarkChatStore()
const flare = useFlareStore()
const fliptok = useFlipTokStore()
const picstagram = usePicstagramStore()
const feather = useFeatherStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
const appCatalog = useAppCatalogStore()
const appStore = useAppStoreStore()
const widgets = useWidgetsStore()
const notes = useNotesStore()
const memos = useMemosStore()
const weather = useWeatherStore()
const easyShare = useEasyShareStore()
const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
const isAppRoute = computed(() => route.name === 'app')
const isDevelopmentRoute = computed(
() => isDevelopment && route.name === 'development-sky-ui',
)
const showActiveCallReturn = computed(
() => Boolean(calls.activeCall) && route.params.appId !== 'phone',
)
const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
)
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 setupPreviewDismissed = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
const activitySuspended = ref(false)
const simPicker = ref<SimPickerPayload | null>(null)
const setupRequired = computed(
() =>
!phone.preferences.settings.setupCompleted ||
(isDevelopment &&
developmentParameters.has('setupPreview') &&
!setupPreviewDismissed.value),
)
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
const viewportScale = ref(getViewportScale())
const phoneBaseZoom = computed(
() =>
viewportScale.value *
(isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE),
)
const phoneZoom = computed(() => {
const preferred =
phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100)
if (isDevelopment) return preferred
const edgeGap = 24 * viewportScale.value
const shellWidth = phone.cameraLandscape
? PHONE_PORTRAIT_HEIGHT
: PHONE_PORTRAIT_WIDTH
const shellHeight = phone.cameraLandscape
? PHONE_PORTRAIT_WIDTH
: PHONE_PORTRAIT_HEIGHT
const viewportMaximum = Math.max(
0,
Math.min(
(window.innerWidth - edgeGap) / shellWidth,
(window.innerHeight - edgeGap) / shellHeight,
),
)
return Math.min(
viewportMaximum,
Math.max(MIN_PRODUCTION_PHONE_ZOOM, preferred),
)
})
const phoneResolutionStyle = computed<CSSProperties>(() => ({
'--phone-edge-gap': `${24 * viewportScale.value}px`,
'--phone-stack-gap': `${16 * viewportScale.value}px`,
'--phone-zoom': phoneZoom.value,
}))
const phoneStageStyle = computed<CSSProperties>(() => ({
...phoneResolutionStyle.value,
visibility: activitySuspended.value ? 'hidden' : 'visible',
}))
const phoneDisplayStyle = computed<CSSProperties>(() => ({
'--phone-display-dim': String(
((100 - phone.preferences.settings.screenBrightness) / 100) * 0.8,
),
}))
const phoneFrameImage = computed(
() => PHONE_FRAME_IMAGES[phone.preferences.settings.frame],
)
let clockTicker: ReturnType<typeof setInterval> | undefined
let companiesChangeTimer: number | undefined
let pendingCompaniesChange: CompanyChangedPayload | null = null
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let unlockedServicesFrame: number | undefined
let phoneClosePending = false
let simPickerClosePending = false
function getViewportScale(): number {
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
if (isDevelopment) return heightScale
return Math.min(window.innerWidth / REFERENCE_VIEWPORT_WIDTH, heightScale)
}
function hydratePhone(payload: PhoneOpenPayload): void {
if (payload.device?.imei) {
companies.bindDeviceScope(
payload.device.imei,
payload.device.sim?.id ?? null,
)
}
phone.open(payload)
phone.ensureAppNotificationPreferences(
appCatalog.externalApps.map((app) => app.id),
)
if (payload.device?.imei) {
notifications.hydrate(
payload.device.data.notifications?.payload,
payload.device.imei,
)
notifications.hideDevicePreview(payload.device.imei)
}
account.hydrate(payload.account ?? null)
appAuth.hydrate(
payload.device?.data.appAuth?.payload,
payload.account?.email ?? '',
)
notes.hydrate(payload.notes ?? [])
memos.hydrate(payload.memos ?? [])
clock.hydrate(payload.device?.data.alarms?.payload)
games.hydrate(payload.device?.data.games?.payload)
media.hydrate(payload.device?.data.media?.payload)
appStore.hydrate(payload.device?.data.apps?.payload)
widgets.hydrate(payload.device?.data.widgets?.payload)
}
function queueCompaniesChange(change: CompanyChangedPayload): void {
if (!pendingCompaniesChange) {
pendingCompaniesChange = { ...change }
} else {
pendingCompaniesChange = {
area: pendingCompaniesChange.area === change.area ? change.area : 'all',
companyId:
pendingCompaniesChange.companyId === change.companyId
? change.companyId
: undefined,
requestId:
pendingCompaniesChange.requestId === change.requestId
? change.requestId
: undefined,
}
}
if (companiesChangeTimer !== undefined) return
companiesChangeTimer = window.setTimeout(() => {
companiesChangeTimer = undefined
const queuedChange = pendingCompaniesChange
pendingCompaniesChange = null
if (queuedChange) void companies.applyChanged(queuedChange)
}, 2000)
}
function loadUnlockedPhoneData(): void {
if (unlockedServicesLoaded.value) return
unlockedServicesLoaded.value = true
// Let the lock screen/home screen paint before background app requests compete
// with the first visible NUI frame.
unlockedServicesFrame = window.requestAnimationFrame(() => {
unlockedServicesFrame = undefined
if (!phone.isOpen || isLocked.value) {
unlockedServicesLoaded.value = false
return
}
void mail.bootstrap(account.email)
if (account.email) void marketplace.loadCounts()
else marketplace.setCounts({ active: 0, unread: 0 })
void calls.bootstrap()
void messages.loadConversations()
void billing.loadOverview()
void companies.refreshUnreadCounts()
if (account.email) void darkchat.bootstrap()
})
}
function completePhoneSetup(): void {
setupPreviewDismissed.value = true
isLocked.value = false
isUnlocking.value = false
passcodeVisible.value = false
controlCenterOpened.value = false
void router.replace('/')
loadUnlockedPhoneData()
}
async function hydrateDevelopmentPhone(): Promise<void> {
const response = await nuiCall<PhoneOpenPayload>('development:bootstrap')
if (response.success && response.data) {
hydratePhone(response.data)
return
}
hydratePhone({
account: null,
device: {
data: {},
imei: '356938035643809',
name: 'iFruit Phone',
sim: {
id: 'development-sim',
number: '5551234567',
removable: true,
registered: true,
type: 'registered',
},
},
memos: [],
notes: [],
token: 'development',
})
}
function openDevelopmentPayphonePreview(): void {
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
currency: '$',
locales: {
busy: 'LINE BUSY',
call: 'CALL',
callEnded: 'CALL ENDED',
clear: 'Clear number',
close: 'Close payphone',
connected: 'CONNECTED',
cost: 'COST',
declined: 'CALL DECLINED',
delete: 'Delete digit',
dialing: 'DIALING',
disconnected: 'DISCONNECTED',
elapsed: 'TIME',
hangup: 'HANG UP',
insufficientFunds: 'OUT OF MONEY',
invalidNumber: 'ENTER A VALID NUMBER',
keypad: 'Dial pad',
noAnswer: 'NO ANSWER',
numberLabel: 'NUMBER TO CALL',
numberPlaceholder: 'Enter a phone number',
rate: '{currency}{price} / SEC',
ready: 'READY',
requestFailed: 'CALL COULD NOT BE STARTED',
ringing: 'RINGING',
subtitle: 'PUBLIC TELEPHONE',
title: 'PAYPHONE',
unavailable: 'NUMBER UNAVAILABLE',
voiceUnavailable: 'VOICE SERVICE UNAVAILABLE',
},
maxNumberLength: 10,
pricePerSecond: 2,
},
type: 'payphone:open',
},
}),
)
}
function onMessage(event: MessageEvent<AppMessage>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'custom-apps:catalog') {
appCatalog.replaceCatalog(event.data.data)
const activeAppId = route.params.appId
if (typeof activeAppId === 'string' && !isPhoneAppId(activeAppId)) {
void router.push('/')
}
} else if (event.data?.type === 'custom-app:message') {
const data = event.data.data as CustomAppEventData | undefined
if (typeof data?.appId === 'string') {
appCatalog.queueHostMessage(data.appId, data.payload)
} else {
console.error('[Custom apps] Ignored a message without a valid app id.')
}
} else if (event.data?.type === 'custom-app:open') {
const data = event.data.data as CustomAppEventData | undefined
if (
typeof data?.appId === 'string' &&
appCatalog.requestOpen(data.appId, data.data)
) {
void router.push(`/apps/${data.appId}`)
}
} else if (event.data?.type === 'custom-app:close') {
const data = event.data.data as CustomAppEventData | undefined
if (typeof data?.appId === 'string' && route.params.appId === data.appId) {
void router.push('/')
}
} else if (event.data?.type === 'app:open') {
hydratePhone(event.data.data as PhoneOpenPayload)
void nuiCall('ui:opened')
} else if (event.data?.type === 'device:updated') {
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
activitySuspended.value = false
phone.endDeviceSession()
} else if (event.data?.type === 'app:suspend') {
activitySuspended.value = true
} else if (event.data?.type === 'app:resume') {
activitySuspended.value = false
} else if (event.data?.type === 'notification:show' && event.data.data) {
const data = event.data.data as NotificationEventData
const { device, ...input } = data
const notification: PhoneNotificationInput = input
if (device && (!phone.isOpen || device.imei !== phone.device?.imei)) {
notification.device = {
imei: device.imei,
name: device.name,
preferences: parsePhonePreferences(device.settings ?? null),
}
}
notifications.show(notification)
} else if (event.data?.type === 'mail:changed' && event.data.data) {
const data = event.data.data as MailEventData
if (data.counts) mail.setCounts(data.counts)
} else if (event.data?.type === 'mail:new' && event.data.data) {
const data = event.data.data as MailEventData
if (
data.counts &&
(!data.device || data.device.imei === phone.device?.imei)
) {
mail.setCounts(data.counts)
}
const notification: PhoneNotificationInput = {
appId: 'mail',
subtitle: data.subject,
text:
data.text ??
phone.t('Apps.mail.newMessage', { sender: data.sender ?? '' }),
title: data.title ?? phone.t('Apps.mail.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)
} 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 === 'companies:changed') {
const data = event.data.data as CompaniesEventData | undefined
if (data?.counts) companies.applyUnreadCounts(data.counts)
if (data?.area) {
queueCompaniesChange({
area: data.area,
companyId: data.companyId,
requestId: data.requestId,
})
} else if (!data?.counts) queueCompaniesChange({ area: 'all' })
} else if (event.data?.type === 'companies:notification' && event.data.data) {
const data = event.data.data as CompaniesEventData
if (data.counts) companies.applyUnreadCounts(data.counts)
const notification: PhoneNotificationInput = {
appId: 'companies',
...(data.requestId && data.area
? {
route: `/apps/companies?requestId=${encodeURIComponent(data.requestId)}&area=${encodeURIComponent(data.area)}`,
}
: {}),
persistent: !phone.isOpen && Boolean(data.requestId && data.area),
subtitle: data.subtitle,
text:
data.text ??
phone.t(
`Apps.companies.notifications.${data.kind ?? 'requestUpdated'}`,
),
title: data.title ?? phone.t('Apps.companies.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)
} 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 === 'feather:new' && event.data.data) {
const data = event.data.data as FeatherNotificationData
const notification: PhoneNotificationInput = {
appId: 'feather',
subtitle: data.actor,
text: data.text ?? phone.t('Apps.feather.notifications.default'),
title: data.title ?? phone.t('Apps.feather.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 feather.loadActivities()
} else if (
event.data?.type === 'picstagram:verification-changed' &&
event.data.data
) {
const data = event.data.data as PicstagramVerificationData
picstagram.applyVerification(String(data.profileId), data.verified === true)
} else if (event.data?.type === 'picstagram:new' && event.data.data) {
const data = event.data.data as PicstagramNotificationData
const notification: PhoneNotificationInput = {
appId: 'picstagram',
subtitle: data.actor,
text: data.text ?? phone.t('Apps.picstagram.notifications.default'),
title: data.title ?? phone.t('Apps.picstagram.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 picstagram.loadActivities()
} else if (
event.data?.type === 'marketplace:new-message' &&
event.data.data
) {
const data = event.data.data as MarketplaceEventData
const notification: PhoneNotificationInput = {
appId: 'citymarkt',
subtitle: data.sender,
text:
data.text ??
phone.t('Apps.citymarkt.newMessage', { sender: data.sender ?? '' }),
title: data.title ?? phone.t('Apps.citymarkt.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)
void marketplace.loadCounts()
} else if (event.data?.type === 'calendar:reminder' && event.data.data) {
const data = event.data.data as CalendarReminderData
const startsAt = Number(data.startsAt) || Date.now()
const notification: PhoneNotificationInput = {
appId: 'calendar',
subtitle: new Intl.DateTimeFormat(phone.lang, {
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
}).format(startsAt),
text:
data.text ??
phone.t('Apps.calendar.reminderNotification', {
title: data.eventTitle ?? '',
}),
title: data.title ?? phone.t('Apps.calendar.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)
} else if (event.data?.type === 'contacts:changed') {
void calls.loadContacts()
} else if (event.data?.type === 'easyshare:changed' && event.data.data) {
easyShare.applyEvent(event.data.data as EasyShareEvent)
} else if (event.data?.type === 'messages:changed') {
void messages.loadConversations()
if (messages.activeNumber) void messages.openThread(messages.activeNumber)
} else if (event.data?.type === 'messages:new' && event.data.data) {
const data = event.data.data as MessagesEventData
void messages.loadConversations()
if (messages.activeNumber === data.phoneNumber) {
void messages.openThread(messages.activeNumber)
}
const notification: PhoneNotificationInput = {
appId: 'messages',
subtitle: data.phoneNumber,
text:
data.text ??
phone.t('Apps.messages.newMessage', { sender: data.sender ?? '' }),
title: data.title ?? phone.t('Apps.messages.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)
} else if (event.data?.type === 'darkchat:changed') {
void darkchat.refreshInbox()
if (darkchat.activeConversation) {
void darkchat.openThread(darkchat.activeConversation.id)
}
} else if (event.data?.type === 'darkchat:new' && event.data.data) {
const data = event.data.data as DarkChatEventData
void darkchat.refreshInbox()
if (
data.conversationId &&
darkchat.activeConversation?.id === data.conversationId
) {
void darkchat.openThread(data.conversationId)
}
if (data.notificationMode !== 'hidden') {
const notification: PhoneNotificationInput = {
appId: 'darkchat',
subtitle: data.sender,
text:
data.text ??
data.preview ??
phone.t('Apps.darkchat.privateNotification'),
title: data.title ?? phone.t('Apps.darkchat.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)
}
} else if (
(event.data?.type === 'flare:new-match' ||
event.data?.type === 'flare:new-message') &&
event.data.data
) {
const data = event.data.data as FlareEventData
void flare.bootstrap()
if (
data.matchId &&
event.data.type === 'flare:new-message' &&
flare.activeMatchId === data.matchId
) {
void flare.loadThread(data.matchId)
}
const notification: PhoneNotificationInput = {
appId: 'flare',
subtitle: data.sender,
text:
data.text ??
phone.t(
event.data.type === 'flare:new-match'
? 'Apps.flare.newMatchNotification'
: 'Apps.flare.newMessageNotification',
{ sender: data.sender ?? '' },
),
title: data.title ?? phone.t('Apps.flare.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)
} else if (event.data?.type === 'calls:changed') {
void calls.loadRecents()
} else if (event.data?.type === 'banking:changed') {
void banking.load()
} else if (event.data?.type === 'billing:changed') {
void billing.loadOverview()
} else if (event.data?.type === 'billing:new' && event.data.data) {
const data = event.data.data as BillingNotificationData
void billing.loadOverview()
const notification: PhoneNotificationInput = {
appId: 'billing',
subtitle: data.issuer,
text:
data.text ??
phone.t('Apps.billing.notifications.newInvoice', {
amount: String(data.amount ?? 0),
issuer: data.issuer ?? '',
}),
title: data.title ?? phone.t('Apps.billing.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)
} else if (event.data?.type === 'crewlink:notification' && event.data.data) {
const data = event.data.data as CrewLinkNotificationData
const notification: PhoneNotificationInput = {
appId: 'crewlink',
subtitle: data.groupName,
text: data.text ?? phone.t('Apps.crewlink.notifications.default'),
title: data.title ?? phone.t('Apps.crewlink.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)
} else if (
(event.data?.type === 'call:incoming' ||
event.data?.type === 'call:state') &&
event.data.data
) {
calls.applyCallState(event.data.data as PhoneCall)
controlCenterOpened.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
} else if (event.data?.type === 'sim:picker-close') {
simPicker.value = null
}
}
async function closeSimPicker(): Promise<void> {
if (simPickerClosePending || !simPicker.value) return
simPickerClosePending = true
const closingPicker = simPicker.value
try {
const response = await nuiCall('sim:picker-close')
if (response.success && simPicker.value === closingPicker) {
simPicker.value = null
}
} finally {
simPickerClosePending = false
}
}
async function closePhone(): Promise<void> {
if (phoneClosePending || !phone.isOpen) return
phoneClosePending = true
const closingGeneration = phone.persistenceGeneration
const closingImei = phone.device?.imei ?? null
const closingToken = phone.deviceSessionToken
try {
await phone.flushDevicePersistence()
if (
!phone.isOpen ||
phone.persistenceGeneration !== closingGeneration ||
(phone.device?.imei ?? null) !== closingImei ||
phone.deviceSessionToken !== closingToken
) {
return
}
const response = await nuiCall('close')
if (
!response.success ||
!phone.isOpen ||
phone.persistenceGeneration !== closingGeneration ||
(phone.device?.imei ?? null) !== closingImei ||
phone.deviceSessionToken !== closingToken
) {
return
}
phone.endDeviceSession()
} finally {
phoneClosePending = false
}
}
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return
if (simPicker.value) {
event.preventDefault()
void closeSimPicker()
return
}
queueMicrotask(() => {
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
void closePhone()
})
}
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
phone.setSystemDarkMode(event.matches)
}
function updateViewportScale(): void {
viewportScale.value = getViewportScale()
}
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 openLockScreenNotification(notification: PhoneNotification): void {
if (!notification.route) return
pendingUnlockRoute.value = notification.route
notifications.dismissFromLockScreen(notification.id)
unlockPhone()
}
async function openNotificationPreview(
notification: PhoneNotification,
): Promise<void> {
if (!notification.route) return
const imei = notification.device?.imei ?? phone.device?.imei
if (!imei) return
pendingUnlockRoute.value = notification.route
const response = await nuiCall('device:notification-open', { imei })
if (!response.success) {
pendingUnlockRoute.value = null
return
}
notifications.dismissFromLockScreen(notification.id, imei)
}
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 {
if (isLocked.value) return
controlCenterOpened.value = !controlCenterOpened.value
}
function lockPhone(): void {
if (!phone.isOpen || isLocked.value) return
controlCenterOpened.value = false
isUnlocking.value = false
passcodeVisible.value = false
passcodeBusy.value = false
passcodeError.value = ''
pendingUnlockRoute.value = null
isLocked.value = true
}
function returnToActiveCall(): void {
if (!calls.activeCall) return
controlCenterOpened.value = false
void router.push('/apps/phone')
}
function unlockCamera(): void {
if (phone.security.enabled) {
pendingUnlockRoute.value = '/apps/camera'
unlockPhone()
return
}
unlockPhone()
window.setTimeout(() => void router.push('/apps/camera'), 0)
}
onMounted(() => {
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
window.addEventListener('resize', updateViewportScale)
systemColorScheme.addEventListener('change', onSystemColorSchemeChange)
phone.setSystemDarkMode(systemColorScheme.matches)
void nuiCall('ui:ready', { protocolVersion: 1 })
clockTicker = setInterval(() => {
const now = Date.now()
for (const alarm of clock.dueAlarms(now)) {
notifications.show({
appId: 'clock',
critical: true,
persistent: true,
sound: alarm.sound,
subtitle: alarm.time,
text: alarm.note || phone.t('Apps.clock.alarm.ringing'),
title: phone.t('Apps.clock.name'),
})
}
const timer = clock.consumeDueTimer(now)
if (timer) {
notifications.show({
appId: 'clock',
critical: true,
persistent: true,
sound: timer.sound,
subtitle: formatTimer(clock.timerDuration),
text: timer.note || phone.t('Apps.clock.timer.ringing'),
title: phone.t('Apps.clock.name'),
})
}
}, 1000)
if (isDevelopment) {
void hydrateDevelopmentPhone()
if (developmentParameters.has('simPickerPreview')) {
simPicker.value = {
choices: [
{
imei: '356938035643809',
name: 'Personal Phone',
occupied: false,
},
{
imei: '356938035643810',
name: 'Work Phone',
number: '5559876543',
occupied: true,
},
],
number: '5551234567',
}
}
if (developmentParameters.has('payphonePreview')) {
openDevelopmentPayphonePreview()
}
}
})
watch(
() => route.params.appId,
(appId) => {
if (
phone.isOpen &&
phone.device?.imei &&
appStore.hydrated &&
typeof appId === 'string' &&
isPhoneAppId(appId)
) {
appStore.recordLaunch(appId)
}
},
)
watch(
() => notifications.requiresAttention,
(requiresAttention) => {
void nuiCall('notification:focus', { active: requiresAttention })
},
)
watch(
() => phone.isOpen,
(isOpen) => {
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (!isOpen) {
appStore.cancelPendingInstalls()
if (unlockedServicesFrame !== undefined) {
window.cancelAnimationFrame(unlockedServicesFrame)
unlockedServicesFrame = undefined
}
activitySuspended.value = false
weather.stop()
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 = setupRequired.value
? false
: !isDevelopment || developmentLockScreenPreview
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)
if (isLocked.value || setupRequired.value) void router.replace('/')
else loadUnlockedPhoneData()
},
)
watch(
() => phone.cameraLandscape,
(landscape) => {
if (landscape) controlCenterOpened.value = false
},
)
onBeforeUnmount(() => {
if (unlockedServicesFrame !== undefined) {
window.cancelAnimationFrame(unlockedServicesFrame)
}
weather.stop()
if (clockTicker) clearInterval(clockTicker)
if (companiesChangeTimer !== undefined) {
window.clearTimeout(companiesChangeTimer)
}
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer)
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale)
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
})
</script>
<template>
<PhoneMediaCapture />
<PhoneMemoRecorder />
<RadioHud />
<PayphoneOverlay />
<SimPhonePicker
v-if="simPicker"
:choices="simPicker.choices"
:number="simPicker.number"
@close="closeSimPicker"
/>
<Transition name="phone-lift" appear>
<main
v-if="
phone.isOpen ||
notifications.current ||
calls.activeCall ||
notifications.devicePreviews.length
"
class="phone-stage"
:class="{
'phone-stage--dev': isDevelopment,
'phone-stage--landscape': phone.cameraLandscape,
'phone-stage--peek': notifications.isPeeking,
}"
:style="phoneStageStyle"
>
<div class="phone-device-row">
<NotificationPhonePreview
v-for="notification in notifications.devicePreviews"
:key="notification.device?.imei"
:notification="notification"
:zoom="
phoneBaseZoom *
((notification.device?.preferences.settings.phoneScale ?? 100) /
100)
"
@close="notifications.dismiss(notification.id)"
@open="openNotificationPreview"
/>
<div
v-if="phone.isOpen || notifications.current"
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
>
<section
class="phone-device"
:class="{
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
>
<div
class="phone-screen"
:class="{
'phone-screen--app': isAppRoute,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
>
<k-app
theme="ios"
:dark="phone.isDarkMode"
safe-areas
class="phone-app"
:style="phoneDisplayStyle"
:class="{
dark: phone.isDarkMode,
'phone-app--darkchat': route.params.appId === 'darkchat',
'phone-app--light': !phone.isDarkMode,
'phone-app--messages': route.params.appId === 'messages',
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
>
<PhoneStatusBar
v-if="!isLocked"
:active-call-return="showActiveCallReturn"
:control-center-opened="controlCenterOpened"
:interactive="!setupRequired"
:lockable="!setupRequired"
@active-call="returnToActiveCall"
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
<SpringboardView v-if="!isDevelopmentRoute && !setupRequired" />
<RouterView v-slot="{ Component }">
<Transition :name="appTransitionName">
<component
:is="Component"
v-if="!setupRequired && (isAppRoute || isDevelopmentRoute)"
:key="
isDevelopmentRoute ? String(route.name) : route.path
"
/>
</Transition>
</RouterView>
<PhoneHomeIndicator v-if="!isLocked && !setupRequired" />
<PhoneControlCenter
v-if="!setupRequired"
:opened="controlCenterOpened"
@close="controlCenterOpened = false"
/>
<Transition name="lock-screen">
<PhoneLockScreen
v-if="isLocked && !setupRequired"
:notifications="notifications.lockScreenNotifications"
@camera="unlockCamera"
@clear-notifications="notifications.clearLockScreen"
@dismiss-notification="notifications.dismissFromLockScreen"
@open-notification="openLockScreenNotification"
@unlock="unlockPhone"
/>
</Transition>
<Transition name="lock-screen">
<PhonePasscode
v-if="isLocked && passcodeVisible && !setupRequired"
: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>
<PhoneSetupAssistant
v-if="setupRequired"
@complete="completePhoneSetup"
/>
<PhoneNotifications
:notification="notifications.current"
@close="notifications.dismissCurrent()"
@open="openNotificationPreview"
/>
<EasyShareSheet />
<div class="phone-display-dimmer" aria-hidden="true"></div>
</k-app>
</div>
<img
class="phone-device__frame"
:src="phoneFrameImage"
alt=""
aria-hidden="true"
draggable="false"
/>
</section>
</div>
</div>
</main>
</Transition>
</template>