ADD - expand phone application platform

Add the Companies app, resilient call handling, custom app registry, vendor compatibility, storage policies, frontend bridge, tests, documentation, config, locale, and SQL migrations.
This commit is contained in:
Leon.Schmidt
2026-08-11 00:22:39 +02:00
parent 72e5f91e02
commit 42ac955052
77 changed files with 17630 additions and 333 deletions
+13 -13
View File
@@ -11,7 +11,11 @@ import { Gamepad2, Grid2X2, Search } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { PHONE_APPS } from '@/config/apps'
import {
getPhoneAppLabel,
isLaunchablePhoneApp,
PHONE_APPS,
} from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import type {
@@ -35,16 +39,11 @@ const tabBarColors = {
}
const catalog = computed(() =>
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
if (
app.component === null ||
app.route === null ||
app.id === 'app-store'
) {
if (!isLaunchablePhoneApp(app) || app.id === 'app-store') {
return false
}
const installed =
app.category !== 'games' || appStore.claimedApps.includes(app.id)
const installed = appStore.isInstalled(app.id)
return (
!installed ||
appStore.homeLayout.hidden.includes(app.id) ||
@@ -63,7 +62,9 @@ const shownApps = computed(() => {
const search = query.value.trim().toLocaleLowerCase(phone.lang)
if (!search) return catalog.value
return catalog.value.filter((app) =>
phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(search),
getPhoneAppLabel(app, phone.t)
.toLocaleLowerCase(phone.lang)
.includes(search),
)
})
@@ -76,8 +77,7 @@ function appAction(
): 'get' | 'installing' | 'open' {
if (appStore.installingApps[app.id]) return 'installing'
const installed =
app.category !== 'games' || appStore.claimedApps.includes(app.id)
const installed = appStore.isInstalled(app.id)
if (
installed &&
!appStore.homeLayout.hidden.includes(app.id) &&
@@ -130,13 +130,13 @@ function handleApp(app: LaunchablePhoneAppDefinition): void {
draggable="false"
/>
<div>
<strong>{{ phone.t(app.labelKey) }}</strong>
<strong>{{ getPhoneAppLabel(app, phone.t) }}</strong>
<small>{{ phone.t(`Home.groups.${app.category}`) }}</small>
</div>
<button
type="button"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${phone.t(app.labelKey)} ${phone.t(
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
)}`"
@click="handleApp(app)"
+2
View File
@@ -51,6 +51,7 @@ import type {
BillingStatus,
InvoiceSummary,
} from '@/types/billing'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type BillingTab = 'overview' | 'inbox' | 'history'
type BillingScreen = 'main' | 'detail'
@@ -225,6 +226,7 @@ async function disputeInvoice(): Promise<void> {
}
function onBillingMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (
event.data?.type !== 'billing:changed' &&
event.data?.type !== 'billing:new'
+2
View File
@@ -20,6 +20,7 @@ import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
import { createGameView, type GameView } from '@/utils/gameView'
import { formatRecordingDuration, mediaErrorKey } from '@/utils/media'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type CaptureItem = {
error?: string
@@ -333,6 +334,7 @@ function onKeydown(event: KeyboardEvent): void {
}
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
const message = event.data as {
data?: Record<string, unknown>
type?: string
File diff suppressed because it is too large Load Diff
+2
View File
@@ -80,6 +80,7 @@ import type {
} from '@/types/crewlink'
import { copyText } from '@/utils/clipboard'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type CrewLinkTab = 'map' | 'group' | 'pings' | 'profile'
type CrewLinkSheet =
@@ -644,6 +645,7 @@ function onWheel(event: WheelEvent): void {
}
function onCrewLinkMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'crewlink:changed') void crew.bootstrap()
}
+2
View File
@@ -26,6 +26,7 @@ import {
mergeMedia,
} from '@/utils/media'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
const isDevelopment = import.meta.env.DEV
const developmentGalleryState = isDevelopment
@@ -331,6 +332,7 @@ async function deleteSelected(): Promise<void> {
}
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
const message = event.data as { data?: DeleteResult; type?: string }
if (
message.type !== 'media:deleteResult' ||
+2
View File
@@ -44,6 +44,7 @@ import type {
GarageVehicleStatus,
GarageValetState,
} from '@/types/garage'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type GarageFilter = 'all' | GarageVehicleStatus
@@ -208,6 +209,7 @@ async function cancelValet(): Promise<void> {
}
function handleValetStatus(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type !== 'garage:valet-status') return
garage.setValetState((event.data.data as GarageValetState | null) ?? null)
}
+2
View File
@@ -51,6 +51,7 @@ import {
type MailSwipeAction,
type MailSwipeAxis,
} from '@/utils/mailSwipe'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type AuthMode = 'login' | 'register'
type MailScreen = 'folders' | 'list' | 'message' | 'compose'
@@ -594,6 +595,7 @@ function goBack(): void {
}
function onMailEvent(event: MessageEvent<MailEvent>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data.type === 'mail:changed' && event.data.data?.counts) {
mail.setCounts(event.data.data.counts)
if (screen.value === 'list') {
+69 -17
View File
@@ -121,8 +121,11 @@ const knownContactNumbers = computed(
)
const contactSuggestions = computed(() => {
const query = composerNumber.value.trim().toLocaleLowerCase(phone.lang)
if (!query) return calls.contacts.slice(0, 8)
return calls.contacts
const contacts = calls.contacts.filter(
(contact) => contact.canMessage !== false,
)
if (!query) return contacts.slice(0, 8)
return contacts
.filter((contact) =>
`${contact.name} ${contact.phone_number}`
.toLocaleLowerCase(phone.lang)
@@ -138,6 +141,9 @@ const activeContact = computed(() =>
(contact) => contact.phone_number === messages.activeNumber,
),
)
const activeCanMessage = computed(
() => activeContact.value?.canMessage !== false,
)
const attachmentPanelOpen = computed(
() => emojiOpen.value || attachmentPicker.value !== null,
)
@@ -348,6 +354,13 @@ function beginCompose(): void {
}
async function chooseRecipient(number: string): Promise<void> {
const contact = calls.contacts.find(
(candidate) => candidate.phone_number === number,
)
if (contact?.canMessage === false) {
showToast(phone.t('Apps.messages.messagingUnavailable'))
return
}
composerNumber.value = number
if (!(await messages.openThread(number))) {
showToast(errorText('invalid_number'))
@@ -386,6 +399,10 @@ function openContactDetails(): void {
}
async function saveContactDetails(): Promise<void> {
if (activeContact.value?.readonly) {
showToast(phone.t('Apps.phone.errors.readonly_contact'))
return
}
if (!contactNameDraft.value.trim() || !contactNumberDraft.value.trim()) return
const response = await calls.saveContact({
id: activeContact.value?.id,
@@ -403,6 +420,10 @@ async function saveContactDetails(): Promise<void> {
async function deleteActiveContact(): Promise<void> {
if (!activeContact.value) return
if (activeContact.value.readonly) {
showToast(phone.t('Apps.phone.errors.readonly_contact'))
return
}
if (!(await calls.deleteContact(activeContact.value.id))) {
showToast(phone.t('Apps.messages.contactDeleteFailed'))
return
@@ -412,7 +433,7 @@ async function deleteActiveContact(): Promise<void> {
}
async function callActiveContact(): Promise<void> {
if (!messages.activeNumber) return
if (!messages.activeNumber || activeContact.value?.canCall === false) return
const response = await calls.dial(messages.activeNumber)
if (!response.success) showToast(phone.t('Apps.messages.callFailed'))
}
@@ -454,7 +475,7 @@ async function sendAttachment(
mediaAssetId: string,
mediaDurationMs?: number,
): Promise<void> {
if (!messages.activeNumber || sending.value) return
if (!messages.activeNumber || !activeCanMessage.value || sending.value) return
attachmentMenuOpen.value = false
attachmentPicker.value = null
sending.value = true
@@ -504,7 +525,14 @@ function queueGifSearch(): void {
}
async function sendTextMessage(): Promise<void> {
if (!messages.activeNumber || !draft.value.trim() || sending.value) return
if (
!messages.activeNumber ||
!activeCanMessage.value ||
!draft.value.trim() ||
sending.value
) {
return
}
const body = draft.value
draft.value = ''
emojiOpen.value = false
@@ -542,6 +570,7 @@ function sampleMicrophone(): void {
}
async function startVoiceRecording(): Promise<void> {
if (!activeCanMessage.value) return
emojiOpen.value = false
if (
!navigator.mediaDevices?.getUserMedia ||
@@ -660,7 +689,7 @@ async function finishVoiceRecording(): Promise<void> {
const waveform = compressedWaveform()
const shouldDiscard = discardRecording
cleanupRecorder()
if (shouldDiscard) return
if (shouldDiscard || !activeCanMessage.value) return
const blob = new Blob(chunks, { type: mime })
if (!blob.size || blob.size > VOICE_MAX_BYTES) {
showToast(phone.t('Apps.messages.recordingTooLarge'))
@@ -1011,6 +1040,7 @@ onBeforeUnmount(() => {
</button>
<strong>{{ phone.t('Apps.messages.contactDetails') }}</strong>
<button
v-if="!activeContact?.readonly"
type="button"
@click="
contactEditing ? saveContactDetails() : (contactEditing = true)
@@ -1035,14 +1065,27 @@ onBeforeUnmount(() => {
</span>
</span>
<h2>{{ activeTitle }}</h2>
<small>{{ messages.activeNumber }}</small>
<small>
{{ messages.activeNumber }}
<template v-if="activeContact?.readonly">
· {{ phone.t('Apps.phone.officialContact') }}
</template>
</small>
</div>
<div class="messages-contact-details__actions">
<button type="button" @click="callActiveContact">
<button
v-if="activeContact?.canCall !== false"
type="button"
@click="callActiveContact"
>
<PhoneIcon :size="20" />
<span>{{ phone.t('Apps.messages.call') }}</span>
</button>
<button type="button" @click="contactDetailsOpen = false">
<button
v-if="activeCanMessage"
type="button"
@click="contactDetailsOpen = false"
>
<MessageCircle :size="20" />
<span>{{ phone.t('Apps.messages.messageAction') }}</span>
</button>
@@ -1052,7 +1095,7 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.messages.contactName') }}</span>
<input
v-model="contactNameDraft"
:readonly="!contactEditing"
:readonly="!contactEditing || activeContact?.readonly"
:placeholder="phone.t('Apps.messages.contactName')"
/>
</label>
@@ -1060,7 +1103,7 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.messages.phoneNumber') }}</span>
<input
v-model="contactNumberDraft"
:readonly="!contactEditing"
:readonly="!contactEditing || activeContact?.readonly"
inputmode="tel"
/>
</label>
@@ -1075,7 +1118,7 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.messages.addContact') }}
</button>
<button
v-else
v-else-if="!activeContact.readonly"
type="button"
class="messages-contact-details__delete"
@click="deleteActiveContact"
@@ -1119,7 +1162,10 @@ onBeforeUnmount(() => {
</template>
</k-messages>
<section v-if="attachmentMenuOpen" class="messages-attachment-menu">
<section
v-if="activeCanMessage && attachmentMenuOpen"
class="messages-attachment-menu"
>
<button type="button" @click="openMediaApp('photos', 'photo')">
<span><Images :size="20" /></span>
{{ phone.t('Apps.messages.attachPhoto') }}
@@ -1142,7 +1188,10 @@ onBeforeUnmount(() => {
</button>
</section>
<section v-if="attachmentPicker" class="messages-media-picker">
<section
v-if="activeCanMessage && attachmentPicker"
class="messages-media-picker"
>
<header>
<strong>
{{ phone.t('Apps.messages.gifs') }}
@@ -1190,12 +1239,12 @@ onBeforeUnmount(() => {
</section>
<FullEmojiPicker
v-if="emojiOpen"
v-if="activeCanMessage && emojiOpen"
@close="emojiOpen = false"
@pick="appendEmoji"
/>
<section v-if="recording" class="messages-recorder">
<section v-if="activeCanMessage && recording" class="messages-recorder">
<button
type="button"
class="messages-recorder__cancel"
@@ -1224,7 +1273,7 @@ onBeforeUnmount(() => {
</section>
<k-messagebar
v-else
v-else-if="activeCanMessage"
class="messages-messagebar"
:placeholder="phone.t('Apps.messages.message')"
:value="draft"
@@ -1270,6 +1319,9 @@ onBeforeUnmount(() => {
</k-toolbar-pane>
</template>
</k-messagebar>
<k-block v-else class="text-center text-sm text-[#8e8e93]">
{{ phone.t('Apps.messages.messagingUnavailable') }}
</k-block>
</k-page>
<k-toast :opened="toastOpened" position="center" @click="toastOpened = false">
+31 -5
View File
@@ -102,6 +102,10 @@ function openContact(contact?: PhoneContact, number = ''): void {
}
async function saveContact(): Promise<void> {
if (editingContact.value?.readonly) {
error.value = phone.t('Apps.phone.errors.readonly_contact')
return
}
const number = normalizePhoneNumber(contactNumber.value)
if (!contactName.value.trim() || !number) {
error.value = phone.t('Apps.phone.errors.invalid_contact')
@@ -137,6 +141,10 @@ async function answerCall(): Promise<void> {
async function deleteEditedContact(): Promise<void> {
if (!editingContact.value) return
if (editingContact.value.readonly) {
error.value = phone.t('Apps.phone.errors.readonly_contact')
return
}
if (await calls.deleteContact(editingContact.value.id)) {
editorOpened.value = false
}
@@ -299,12 +307,17 @@ onMounted(() => {
v-for="contact in visibleContacts"
:key="contact.id"
:title="contact.name"
:subtitle="formatPhoneNumber(contact.phone_number)"
:subtitle="
contact.readonly
? `${formatPhoneNumber(contact.phone_number)} · ${phone.t('Apps.phone.officialContact')}`
: formatPhoneNumber(contact.phone_number)
"
link
@click="openContact(contact)"
>
<template #after>
<k-button
v-if="contact.canCall !== false"
clear
rounded
@click.stop="startCall(contact.phone_number)"
@@ -402,31 +415,44 @@ onMounted(() => {
<k-dialog :opened="editorOpened" @backdropclick="editorOpened = false">
<template #title>{{
phone.t(
editingContact ? 'Apps.phone.editContact' : 'Apps.phone.addContact',
editingContact?.readonly
? 'Apps.phone.officialContact'
: editingContact
? 'Apps.phone.editContact'
: 'Apps.phone.addContact',
)
}}</template>
<k-list strong inset>
<k-list-input
:value="contactName"
:label="phone.t('Apps.phone.contactName')"
:readonly="editingContact?.readonly"
@input="contactName = eventValue($event)"
/>
<k-list-input
:value="contactNumber"
:label="phone.t('Apps.phone.phoneNumber')"
inputmode="numeric"
:readonly="editingContact?.readonly"
@input="contactNumber = eventValue($event)"
/>
</k-list>
<p v-if="error" class="text-sm text-[#ff3b30]">{{ error }}</p>
<template #buttons>
<k-dialog-button @click="editorOpened = false">{{
phone.t('Common.cancel')
phone.t(editingContact?.readonly ? 'Common.done' : 'Common.cancel')
}}</k-dialog-button>
<k-dialog-button v-if="editingContact" @click="deleteEditedContact">{{
<k-dialog-button
v-if="editingContact && !editingContact.readonly"
@click="deleteEditedContact"
>{{
phone.t('Common.delete')
}}</k-dialog-button>
<k-dialog-button strong @click="saveContact">{{
<k-dialog-button
v-if="!editingContact?.readonly"
strong
@click="saveContact"
>{{
phone.t('Common.save')
}}</k-dialog-button>
</template>
+2
View File
@@ -28,6 +28,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import { useRadioStore } from '@/stores/radio'
import type { RadioHistoryEntry } from '@/types/radio'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type RadioTab = 'radio' | 'settings'
@@ -152,6 +153,7 @@ async function saveRadioProfile(): Promise<void> {
}
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'radio:updated' && event.data.data?.members) {
memberSnapshotAt.value = Date.now()
radio.updateMembers(event.data.data.members)
+12 -6
View File
@@ -49,7 +49,11 @@ import {
} from 'vue'
import { PHONE_FRAME_COLORS } from '@/config/appearance'
import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps'
import {
getPhoneAppLabel,
isLaunchablePhoneApp,
PHONE_APPS,
} from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import PhonePasscode from '@/components/PhonePasscode.vue'
import { useAccountStore } from '@/stores/account'
@@ -281,7 +285,7 @@ const activeTitle = computed(() => {
}
if (activeView.value === 'notification-detail') {
return selectedNotificationApp.value
? phone.t(selectedNotificationApp.value.labelKey)
? getPhoneAppLabel(selectedNotificationApp.value, phone.t)
: phone.t('Apps.settings.notifications')
}
return phone.t(`Apps.settings.${activeView.value}`)
@@ -1030,7 +1034,7 @@ onBeforeUnmount(() => {
v-for="app in notificationApps"
:key="app.id"
link
:title="phone.t(app.labelKey)"
:title="getPhoneAppLabel(app, phone.t)"
:after="
phone.t(
phone.preferences.settings.notifications[app.id].enabled
@@ -1058,7 +1062,9 @@ onBeforeUnmount(() => {
"
>
<k-list strong inset>
<k-list-item :title="phone.t(selectedNotificationApp.labelKey)">
<k-list-item
:title="getPhoneAppLabel(selectedNotificationApp, phone.t)"
>
<template #media>
<img
class="w-12 h-12 object-contain"
@@ -1080,7 +1086,7 @@ onBeforeUnmount(() => {
"
:aria-label="
phone.t('Apps.settings.toggle.notifications', {
app: phone.t(selectedNotificationApp.labelKey),
app: getPhoneAppLabel(selectedNotificationApp, phone.t),
})
"
@change="
@@ -1110,7 +1116,7 @@ onBeforeUnmount(() => {
"
:aria-label="
phone.t('Apps.settings.toggle.notificationSounds', {
app: phone.t(selectedNotificationApp.labelKey),
app: getPhoneAppLabel(selectedNotificationApp, phone.t),
})
"
@change="
+2
View File
@@ -78,6 +78,7 @@ import type {
SkyRideRide,
SkyRideRideStatus,
} from '@/types/skyride'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type SkyRideTab = 'home' | 'rides' | 'activity' | 'messages' | 'profile'
type LocationTarget = 'pickup' | 'destination'
@@ -677,6 +678,7 @@ function selectTab(tab: SkyRideTab): void {
}
function handleSkyRideMessage(event: MessageEvent<unknown>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (typeof event.data !== 'object' || event.data === null) return
const message = event.data as Partial<SkyRideChangedMessage>
if (message.type !== 'skyride:changed' || !message.data) return