Merge branch 'dev' into feature/funk

# Conflicts:
#	frontend/testserver/index.cjs
#	sky_phone/config/config.lua
#	sky_phone/source/html/index.html
This commit is contained in:
DerEchteAlec
2026-08-06 22:04:38 +02:00
44 changed files with 5498 additions and 284 deletions
+13
View File
@@ -17,6 +17,19 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m
sent to NUI because clients receive temporary presigned upload URLs instead.
- `yaca-voice`, `pma-voice`, or `saltychat` when the Radio app is enabled. `Config.Radio.VoiceProvider = "auto"` selects the first running provider in that order.
## Messages GIF provider
Configure GIF search in `sky_phone/config/config.lua`:
```lua
Config.Media.GiphyApiKey = "YOUR_GIPHY_API_KEY"
```
GIPHY provides trending and searched GIFs through a paginated server-side proxy. The shared
`config.lua` is loaded by both FiveM runtimes, so its values are available to clients even though
only the server uses the GIPHY key. Photo and video actions in Messages are intentionally inactive
until their dedicated implementation is available.
Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password.
Camera and Gallery media is stored in `sky_phone_media`. Signed-out captures belong to the current
+1
View File
@@ -14,6 +14,7 @@
"test": "vitest run"
},
"dependencies": {
"emoji-picker-element-data": "^1.8.0",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-placeholder": "^3.29.2",
"@tiptap/markdown": "^3.29.2",
+8
View File
@@ -29,6 +29,9 @@ importers:
dompurify:
specifier: ^3.4.13
version: 3.4.13
emoji-picker-element-data:
specifier: ^1.8.0
version: 1.8.0
fix-webm-duration:
specifier: ^1.0.6
version: 1.0.6
@@ -1161,6 +1164,9 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
emoji-picker-element-data@1.8.0:
resolution: {integrity: sha512-VfRuRJNEDLS1JKlNS4olaqhjX5S1nnZ+ZHG73b/dV8QeZyi0yPruTPEE72EmF6XO3k/9hj3lybMIYMOYXb/57A==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -3199,6 +3205,8 @@ snapshots:
ee-first@1.1.1: {}
emoji-picker-element-data@1.8.0: {}
emoji-regex@8.0.0: {}
encodeurl@2.0.0: {}
+51 -1
View File
@@ -26,9 +26,11 @@ import { useGamesStore } from '@/features/games/store'
import { useCallsStore } from '@/stores/calls'
import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import { useMessagesStore } from '@/stores/messages'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppStoreStore } from '@/stores/app-store'
import { isPhoneAppId } from '@/config/apps'
import { useNotesStore } from '@/stores/notes'
import { useWeatherStore } from '@/stores/weather'
import {
@@ -51,6 +53,7 @@ type AppMessage = {
| CalendarReminderData
| MailEventData
| MarketplaceEventData
| MessagesEventData
| PhoneCall
| PhoneNotificationInput
| PhoneOpenPayload
@@ -70,6 +73,14 @@ type MailEventData = {
title?: string
}
type MessagesEventData = {
device?: PhoneNotificationDevicePayload
phoneNumber?: string
sender?: string
text?: string
title?: string
}
type MarketplaceEventData = {
counts?: MarketplaceCounts
device?: PhoneNotificationDevicePayload
@@ -88,7 +99,6 @@ type CalendarReminderData = {
text?: string
title?: string
}
const REFERENCE_VIEWPORT_WIDTH = 1920
const REFERENCE_VIEWPORT_HEIGHT = 1080
const PHONE_BASE_SCALE = 0.69
@@ -100,6 +110,7 @@ const clock = useClockStore()
const games = useGamesStore()
const calls = useCallsStore()
const mail = useMailStore()
const messages = useMessagesStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
const appStore = useAppStoreStore()
@@ -149,6 +160,7 @@ function hydratePhone(payload: PhoneOpenPayload): void {
if (payload.account?.email) void marketplace.loadCounts()
else marketplace.setCounts({ active: 0, unread: 0 })
void calls.bootstrap()
void messages.loadConversations()
}
async function hydrateDevelopmentPhone(): Promise<void> {
@@ -274,6 +286,35 @@ function onMessage(event: MessageEvent<AppMessage>): void {
notifications.show(notification)
} else if (event.data?.type === 'contacts:changed') {
void calls.loadContacts()
} 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 === 'calls:changed') {
void calls.loadRecents()
} else if (
@@ -378,6 +419,15 @@ onMounted(() => {
}
})
watch(
() => route.params.appId,
(appId) => {
if (typeof appId === 'string' && isPhoneAppId(appId)) {
appStore.recordLaunch(appId)
}
},
)
watch(
[() => notifications.requiresAttention, () => calls.activeCall],
([requiresAttention, activeCall]) => {
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import emojiData from 'emoji-picker-element-data/en/emojibase/data.json'
import { Search, X } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
type EmojiEntry = {
annotation: string
emoji: string
group: number
skins?: Array<{ emoji: string; tone: number }>
shortcodes?: string[]
tags?: string[]
}
const emit = defineEmits<{
close: []
pick: [emoji: string]
}>()
const phone = usePhoneStore()
const query = ref('')
const activeGroup = ref(0)
const skinTone = ref(0)
const groups = ['😀', '👋', '🧑', '🐻', '🍔', '⚽', '🚗', '💡', '❤️', '🏳️']
const skinTones = ['✋', '✋🏻', '✋🏼', '✋🏽', '✋🏾', '✋🏿']
const emojis = computed(() => {
const needle = query.value.trim().toLocaleLowerCase(phone.lang)
return (emojiData as EmojiEntry[])
.filter((entry) => {
if (!needle) return entry.group === activeGroup.value
return `${entry.annotation} ${entry.shortcodes?.join(' ') ?? ''} ${entry.tags?.join(' ') ?? ''}`
.toLocaleLowerCase(phone.lang)
.includes(needle)
})
.map((entry) => ({
...entry,
emoji:
skinTone.value === 0
? entry.emoji
: entry.skins?.find((skin) => skin.tone === skinTone.value)?.emoji ??
entry.emoji,
}))
})
</script>
<template>
<section class="messages-full-emoji-picker" aria-label="Emoji picker">
<header>
<strong>{{ phone.t('Apps.messages.emoji') }}</strong>
<button type="button" :aria-label="phone.t('Common.done')" @click="emit('close')">
<X :size="18" />
</button>
</header>
<label class="messages-full-emoji-picker__search">
<Search :size="15" />
<input
v-model="query"
type="search"
:placeholder="phone.t('Common.search')"
autocomplete="off"
/>
</label>
<nav v-if="!query" aria-label="Emoji categories">
<button
v-for="(group, index) in groups"
:key="group"
type="button"
:class="{ active: activeGroup === index }"
@click="activeGroup = index"
>
{{ group }}
</button>
<i aria-hidden="true" />
<button
v-for="(tone, index) in skinTones"
:key="tone"
type="button"
:class="{ active: skinTone === index }"
:title="`Skin tone ${index}`"
@click="skinTone = index"
>
{{ tone }}
</button>
</nav>
<div class="messages-full-emoji-picker__grid">
<button
v-for="entry in emojis"
:key="`${entry.emoji}-${entry.annotation}`"
type="button"
:title="entry.annotation"
@click="emit('pick', entry.emoji)"
>
{{ entry.emoji }}
</button>
</div>
</section>
</template>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { Camera, Pause, Play } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import type { SmsMessage } from '@/types/messages'
const props = defineProps<{ message: SmsMessage }>()
const playing = ref(false)
const video = ref<HTMLVideoElement>()
const imageStyles: Record<string, string> = {
'camera-1': 'linear-gradient(145deg, #ff6b6b, #845ec2 52%, #0f2027)',
'camera-2': 'linear-gradient(150deg, #00c9a7, #4d8076 46%, #1f3a5f)',
'camera-3': 'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)',
'city-lights': 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)',
'desert-road': 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)',
'ocean-air': 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)',
'sunset-drive': 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)',
}
const videoStyles: Record<string, string> = {
'city-loop': 'linear-gradient(135deg, #302b63, #a6c1ee 48%, #fbc2eb)',
'ocean-loop': 'linear-gradient(145deg, #0b132b, #26648e 55%, #67d5b5)',
'sunset-loop': 'linear-gradient(145deg, #141e30, #5f2c82 55%, #ff9a62)',
}
const gifContent: Record<string, { emoji: string; label: string }> = {
celebrate: { emoji: '🎉', label: 'Celebrate!' },
hearts: { emoji: '💖', label: 'Love it' },
party: { emoji: '🥳', label: 'Party time' },
thumbs_up: { emoji: '👍', label: 'Perfect' },
wow: { emoji: '🤯', label: 'WOW' },
}
const background = computed(() =>
props.message.message_type === 'video'
? videoStyles[props.message.media_asset_id ?? '']
: imageStyles[props.message.media_asset_id ?? ''],
)
const gif = computed(
() => gifContent[props.message.media_asset_id ?? ''] ?? gifContent.wow,
)
const mediaUrl = computed(() =>
props.message.media_asset_id?.startsWith('https://')
? props.message.media_asset_id
: '',
)
async function toggleVideo(): Promise<void> {
if (!video.value) {
playing.value = !playing.value
return
}
if (video.value.paused) await video.value.play()
else video.value.pause()
playing.value = !video.value.paused
}
function durationLabel(milliseconds: number | null): string {
const seconds = Math.max(0, Math.floor((milliseconds ?? 0) / 1000))
return `0:${String(seconds).padStart(2, '0')}`
}
</script>
<template>
<div
v-if="message.message_type === 'image'"
class="messages-attachment messages-attachment--image"
:style="{ background }"
>
<img v-if="mediaUrl" :src="mediaUrl" alt="" loading="lazy" referrerpolicy="no-referrer" />
<Camera v-else :size="18" />
</div>
<button
v-else-if="message.message_type === 'video'"
type="button"
class="messages-attachment messages-attachment--video"
:class="{ playing }"
:style="{ background }"
@click="toggleVideo"
>
<video
v-if="mediaUrl"
ref="video"
:src="mediaUrl"
playsinline
preload="metadata"
@ended="playing = false"
/>
<span><Pause v-if="playing" :size="22" fill="currentColor" /><Play v-else :size="22" fill="currentColor" /></span>
<small>{{ durationLabel(message.media_duration_ms) }}</small>
</button>
<div v-else class="messages-attachment messages-attachment--gif">
<img v-if="mediaUrl" :src="mediaUrl" alt="GIF" loading="lazy" referrerpolicy="no-referrer" />
<template v-else>
<span>{{ gif.emoji }}</span>
<strong>{{ gif.label }}</strong>
</template>
</div>
</template>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { Pause, Play } from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
import type { SmsMessage } from '@/types/messages'
const props = defineProps<{ message: SmsMessage }>()
const phone = usePhoneStore()
const messages = useMessagesStore()
const audio = ref<HTMLAudioElement>()
const currentTime = ref(0)
const playing = ref(false)
const loading = ref(false)
const failed = ref(false)
const duration = computed(() => (props.message.media_duration_ms ?? 0) / 1000)
const progress = computed(() =>
duration.value > 0 ? Math.min(1, currentTime.value / duration.value) : 0,
)
const displayTime = computed(() =>
formatDuration(playing.value || currentTime.value > 0 ? currentTime.value : duration.value),
)
const source = computed(() => messages.mediaSources[props.message.id] ?? '')
function formatDuration(seconds: number): string {
const rounded = Math.max(0, Math.floor(seconds))
return `${Math.floor(rounded / 60)}:${String(rounded % 60).padStart(2, '0')}`
}
async function togglePlayback(): Promise<void> {
if (loading.value) return
failed.value = false
if (playing.value) {
audio.value?.pause()
return
}
if (!source.value) {
loading.value = true
const loaded = await messages.loadMedia(props.message.id)
loading.value = false
if (!loaded) {
failed.value = true
return
}
await nextTick()
}
try {
await audio.value?.play()
} catch (error) {
failed.value = true
console.error(`[Messages] Could not play audio message ${props.message.id}:`, error)
}
}
function updateProgress(): void {
currentTime.value = audio.value?.currentTime ?? 0
}
function finishPlayback(): void {
playing.value = false
currentTime.value = 0
}
onBeforeUnmount(() => audio.value?.pause())
</script>
<template>
<div class="voice-message" :class="{ 'voice-message--failed': failed }">
<button
type="button"
class="voice-message__play"
:disabled="loading"
:aria-label="phone.t(playing ? 'Apps.messages.pauseAudio' : 'Apps.messages.playAudio')"
@click="togglePlayback"
>
<span v-if="loading" class="voice-message__loader" />
<Pause v-else-if="playing" :size="16" fill="currentColor" />
<Play v-else :size="16" fill="currentColor" />
</button>
<div class="voice-message__content">
<div class="voice-message__waveform" aria-hidden="true">
<i
v-for="(sample, index) in message.media_waveform ?? []"
:key="index"
:class="{ active: index / Math.max(1, (message.media_waveform?.length ?? 1) - 1) <= progress }"
:style="{ height: `${Math.max(4, sample * 22)}px` }"
/>
</div>
<span>{{ displayTime }}</span>
</div>
<audio
ref="audio"
:src="source"
preload="metadata"
@play="playing = true"
@pause="playing = false"
@timeupdate="updateProgress"
@ended="finishPlayback"
@error="failed = true"
/>
</div>
</template>
+28 -12
View File
@@ -21,17 +21,17 @@ describe('app registry', () => {
route: '/apps/phone',
})
expect(PHONE_APPS.find((app) => app.id === 'mail')).toMatchObject({
gridOrder: 5,
gridOrder: 6,
labelKey: 'Apps.mail.name',
route: '/apps/mail',
})
expect(PHONE_APPS.find((app) => app.id === 'weather')).toMatchObject({
gridOrder: 4,
gridOrder: 5,
labelKey: 'Apps.weather.name',
route: '/apps/weather',
})
expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({
gridOrder: 20,
gridOrder: 21,
labelKey: 'Apps.calendar.name',
route: '/apps/calendar',
})
@@ -43,49 +43,49 @@ describe('app registry', () => {
})
expect(PHONE_APPS.find((app) => app.id === 'snake')).toMatchObject({
dockOrder: null,
gridOrder: 11,
gridOrder: 12,
labelKey: 'Apps.snake.name',
route: '/apps/snake',
})
expect(PHONE_APPS.find((app) => app.id === 'memory')).toMatchObject({
dockOrder: null,
gridOrder: 12,
gridOrder: 13,
labelKey: 'Apps.memory.name',
route: '/apps/memory',
})
expect(PHONE_APPS.find((app) => app.id === 'number-merge')).toMatchObject({
dockOrder: null,
gridOrder: 13,
gridOrder: 14,
labelKey: 'Apps.numberMerge.name',
route: '/apps/number-merge',
})
expect(PHONE_APPS.find((app) => app.id === 'minesweeper')).toMatchObject({
dockOrder: null,
gridOrder: 14,
gridOrder: 15,
labelKey: 'Apps.minesweeper.name',
route: '/apps/minesweeper',
})
expect(PHONE_APPS.find((app) => app.id === 'tower-stack')).toMatchObject({
dockOrder: null,
gridOrder: 15,
gridOrder: 16,
labelKey: 'Apps.towerStack.name',
route: '/apps/tower-stack',
})
expect(PHONE_APPS.find((app) => app.id === 'sky-flappy')).toMatchObject({
dockOrder: null,
gridOrder: 16,
gridOrder: 17,
labelKey: 'Apps.skyFlappy.name',
route: '/apps/sky-flappy',
})
expect(PHONE_APPS.find((app) => app.id === 'neon-drop')).toMatchObject({
dockOrder: null,
gridOrder: 17,
gridOrder: 18,
labelKey: 'Apps.neonDrop.name',
route: '/apps/neon-drop',
})
expect(PHONE_APPS.find((app) => app.id === 'citymarkt')).toMatchObject({
dockOrder: null,
gridOrder: 18,
gridOrder: 19,
labelKey: 'Apps.citymarkt.name',
route: '/apps/citymarkt',
})
@@ -98,10 +98,26 @@ describe('app registry', () => {
expect(isPhoneAppId('camera')).toBe(true)
expect(isPhoneAppId('photos')).toBe(true)
expect(isPhoneAppId('clock')).toBe(true)
expect(
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
).toEqual([
'snake',
'memory',
'number-merge',
'minesweeper',
'tower-stack',
'sky-flappy',
'neon-drop',
])
expect(
PHONE_APPS.filter((app) => app.category === 'social').map(
(app) => app.id,
),
).toEqual(['local-pages', 'radio', 'phone', 'mail'])
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
.map((app) => app.id),
).toEqual(['phone', 'calculator', 'camera', 'clock'])
).toEqual(['phone', 'messages', 'camera', 'clock'])
})
})
+59 -21
View File
@@ -12,6 +12,7 @@ import {
Layers3,
Mail,
MapPinned,
MessageCircle,
NotebookPen,
Phone,
RadioTower,
@@ -31,6 +32,7 @@ import clockIcon from '@/assets/img/app-icons/clock.webp'
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 notesIcon from '@/assets/img/app-icons/notes.webp'
import radioIcon from '@/assets/img/app-icons/radio.svg'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
@@ -54,11 +56,12 @@ import type {
export const PHONE_APPS: PhoneAppDefinition[] = [
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CalendarApp.vue')),
),
dockOrder: null,
gridOrder: 20,
gridOrder: 21,
icon: markRaw(CalendarDays),
iconClass: 'app-icon--calendar',
iconImage: calendarIcon,
@@ -67,11 +70,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/calendar',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/LocalPagesApp.vue')),
),
dockOrder: null,
gridOrder: 19,
gridOrder: 20,
icon: markRaw(MapPinHouse),
iconClass: 'app-icon--local-pages',
iconImage: localPagesIcon,
@@ -80,6 +84,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/local-pages',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/RadioApp.vue')),
),
@@ -93,6 +98,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/radio',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')),
),
@@ -106,11 +112,26 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/phone',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MessagesApp.vue')),
),
dockOrder: 1,
gridOrder: 1,
icon: markRaw(MessageCircle),
iconClass: '',
iconImage: messagesIcon,
id: 'messages',
labelKey: 'Apps.messages.name',
route: '/apps/messages',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MapApp.vue')),
),
dockOrder: null,
gridOrder: 10,
gridOrder: 11,
icon: markRaw(MapPinned),
iconClass: '',
iconImage: mapIcon,
@@ -119,11 +140,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/map',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MailApp.vue')),
),
dockOrder: null,
gridOrder: 5,
gridOrder: 6,
icon: markRaw(Mail),
iconClass: '',
iconImage: mailIcon,
@@ -132,11 +154,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/mail',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
),
dockOrder: null,
gridOrder: 6,
gridOrder: 7,
icon: markRaw(NotebookPen),
iconClass: '',
iconImage: notesIcon,
@@ -145,11 +168,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/notes',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')),
),
dockOrder: 1,
gridOrder: 1,
dockOrder: null,
gridOrder: 2,
icon: markRaw(Calculator),
iconClass: 'app-icon--calculator',
iconImage: calculatorIcon,
@@ -158,11 +182,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/calculator',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CameraApp.vue')),
),
dockOrder: 2,
gridOrder: 2,
gridOrder: 3,
icon: markRaw(Camera),
iconClass: 'app-icon--camera',
iconImage: cameraIcon,
@@ -171,11 +196,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/camera',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/ClockApp.vue')),
),
dockOrder: 3,
gridOrder: 3,
gridOrder: 4,
icon: markRaw(Clock3),
iconClass: 'app-icon--clock',
iconImage: clockIcon,
@@ -184,11 +210,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/clock',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/WeatherApp.vue')),
),
dockOrder: null,
gridOrder: 4,
gridOrder: 5,
icon: markRaw(CloudSun),
iconClass: 'app-icon--weather',
iconImage: weatherIcon,
@@ -197,11 +224,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/weather',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/GalleryApp.vue')),
),
dockOrder: null,
gridOrder: 7,
gridOrder: 8,
icon: markRaw(Images),
iconClass: 'app-icon--photos',
iconImage: photosIcon,
@@ -210,11 +238,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/photos',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
),
dockOrder: null,
gridOrder: 8,
gridOrder: 9,
icon: markRaw(ShoppingBag),
iconClass: 'app-icon--store',
iconImage: appStoreIcon,
@@ -223,11 +252,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/app-store',
},
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
),
dockOrder: null,
gridOrder: 9,
gridOrder: 10,
icon: markRaw(Settings),
iconClass: 'app-icon--settings',
iconImage: settingsIcon,
@@ -236,11 +266,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/settings',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SnakeApp.vue')),
),
dockOrder: null,
gridOrder: 11,
gridOrder: 12,
icon: markRaw(Gamepad2),
iconClass: 'app-icon--snake',
iconImage: snakeIcon,
@@ -249,11 +280,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/snake',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MemoryApp.vue')),
),
dockOrder: null,
gridOrder: 12,
gridOrder: 13,
icon: markRaw(Brain),
iconClass: 'app-icon--memory',
iconImage: memoryIcon,
@@ -262,11 +294,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/memory',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NumberMergeApp.vue')),
),
dockOrder: null,
gridOrder: 13,
gridOrder: 14,
icon: markRaw(Grid2X2),
iconClass: 'app-icon--number-merge',
iconImage: numberMergeIcon,
@@ -275,11 +308,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/number-merge',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MinesweeperApp.vue')),
),
dockOrder: null,
gridOrder: 14,
gridOrder: 15,
icon: markRaw(Bomb),
iconClass: 'app-icon--minesweeper',
iconImage: minesweeperIcon,
@@ -288,11 +322,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/minesweeper',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/TowerStackApp.vue')),
),
dockOrder: null,
gridOrder: 15,
gridOrder: 16,
icon: markRaw(Layers3),
iconClass: 'app-icon--tower-stack',
iconImage: towerStackIcon,
@@ -301,11 +336,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/tower-stack',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SkyFlappyApp.vue')),
),
dockOrder: null,
gridOrder: 16,
gridOrder: 17,
icon: markRaw(Wind),
iconClass: 'app-icon--sky-flappy',
iconImage: skyFlappyIcon,
@@ -314,11 +350,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/sky-flappy',
},
{
category: 'shopping',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/CityMarktApp.vue')),
),
dockOrder: null,
gridOrder: 18,
gridOrder: 19,
icon: markRaw(Tag),
iconClass: 'app-icon--citymarkt',
iconImage: citymarktIcon,
@@ -327,11 +364,12 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
route: '/apps/citymarkt',
},
{
category: 'games',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NeonDropApp.vue')),
),
dockOrder: null,
gridOrder: 17,
gridOrder: 18,
icon: markRaw(Blocks),
iconClass: 'app-icon--neon-drop',
iconImage: neonDropIcon,
+72
View File
@@ -0,0 +1,72 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStoreStore } from '@/stores/app-store'
const mocks = vi.hoisted(() => ({ saveDeviceNamespace: vi.fn() }))
vi.mock('@/stores/phone', () => ({
usePhoneStore: () => ({
saveDeviceNamespace: mocks.saveDeviceNamespace,
}),
}))
describe('app store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.saveDeviceNamespace.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('hydrates valid launch counts and persists launches with claimed apps', () => {
const apps = useAppStoreStore()
apps.hydrate({
claimedApps: ['snake'],
launchCounts: { mail: 3, invalid: 20, phone: -1 },
})
apps.recordLaunch('mail')
expect(apps.launchCounts).toEqual({ mail: 4 })
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: ['snake'],
launchCounts: { mail: 4 },
})
})
it('installs an app after showing a three second loading state', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.installApp('snake')
expect(apps.installingApps.snake).toBe(true)
expect(apps.claimedApps).toEqual([])
vi.advanceTimersByTime(2999)
expect(apps.claimedApps).toEqual([])
vi.advanceTimersByTime(1)
expect(apps.installingApps.snake).toBeUndefined()
expect(apps.claimedApps).toEqual(['snake'])
expect(mocks.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: ['snake'],
launchCounts: {},
})
})
it('ignores duplicate installation requests and invalid persisted ids', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['memory', 'not-an-app'] })
apps.installApp('snake')
apps.installApp('snake')
vi.advanceTimersByTime(3000)
expect(apps.claimedApps).toEqual(['memory', 'snake'])
expect(mocks.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
})
+44 -4
View File
@@ -1,27 +1,67 @@
import { defineStore } from 'pinia'
import { isPhoneAppId } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppId } from '@/types/apps'
const INSTALL_DURATION_MS = 3000
export const useAppStoreStore = defineStore('app-store', {
state: () => ({
claimedApps: [] as string[],
claimedApps: [] as LaunchablePhoneAppId[],
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
}),
actions: {
claimApp(id: string): void {
claimApp(id: LaunchablePhoneAppId): void {
if (!this.claimedApps.includes(id)) {
this.claimedApps.push(id)
this.persist()
}
},
installApp(id: LaunchablePhoneAppId): void {
if (this.claimedApps.includes(id) || this.installingApps[id]) return
this.installingApps[id] = true
globalThis.setTimeout(() => {
this.claimApp(id)
delete this.installingApps[id]
}, INSTALL_DURATION_MS)
},
hydrate(payload: unknown): void {
const data = payload as { claimedApps?: unknown } | null
const data = payload as {
claimedApps?: unknown
launchCounts?: unknown
} | null
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter((id): id is string => typeof id === 'string')
? data.claimedApps.filter(
(id): id is LaunchablePhoneAppId =>
typeof id === 'string' && isPhoneAppId(id),
)
: []
this.installingApps = {}
this.launchCounts = {}
if (data?.launchCounts && typeof data.launchCounts === 'object') {
for (const [appId, count] of Object.entries(data.launchCounts)) {
if (
isPhoneAppId(appId) &&
typeof count === 'number' &&
Number.isFinite(count) &&
count > 0
) {
this.launchCounts[appId] = Math.floor(count)
}
}
}
},
recordLaunch(appId: LaunchablePhoneAppId): void {
this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1
this.persist()
},
persist(): void {
usePhoneStore().saveDeviceNamespace('apps', {
claimedApps: this.claimedApps,
launchCounts: this.launchCounts,
})
},
},
+17 -2
View File
@@ -3,32 +3,38 @@ import { defineStore } from 'pinia'
import { usePhoneStore } from '@/stores/phone'
export type PhonePhoto = {
attachmentId: string
capturedAt: number
gradient: string
id: string
titleKey: string
url?: string
}
const samplePhotos: PhonePhoto[] = [
{
attachmentId: 'sunset-drive',
capturedAt: Date.now() - 86_400_000,
gradient: 'linear-gradient(145deg, #ff9a62, #5f2c82 58%, #141e30)',
id: 'sunset-drive',
titleKey: 'Apps.photos.samples.sunset',
},
{
attachmentId: 'ocean-air',
capturedAt: Date.now() - 172_800_000,
gradient: 'linear-gradient(160deg, #67d5b5, #26648e 55%, #0b132b)',
id: 'ocean-air',
titleKey: 'Apps.photos.samples.ocean',
},
{
attachmentId: 'city-lights',
capturedAt: Date.now() - 259_200_000,
gradient: 'linear-gradient(135deg, #fbc2eb, #a6c1ee 48%, #302b63)',
id: 'city-lights',
titleKey: 'Apps.photos.samples.city',
},
{
attachmentId: 'desert-road',
capturedAt: Date.now() - 345_600_000,
gradient: 'linear-gradient(150deg, #f6d365, #fda085 45%, #512b58)',
id: 'desert-road',
@@ -55,10 +61,12 @@ export const useMediaStore = defineStore('media', {
'linear-gradient(135deg, #ffc75f, #f96d80 48%, #4b4453)',
]
const captureNumber = this.captures.length
const id = `capture-${Date.now()}-${captureNumber + 1}`
const photo: PhonePhoto = {
attachmentId: id,
capturedAt: Date.now(),
gradient: gradients[captureNumber % gradients.length],
id: `capture-${Date.now()}-${captureNumber + 1}`,
id,
titleKey: 'Apps.photos.samples.capture',
}
this.captures.unshift(photo)
@@ -76,7 +84,14 @@ export const useMediaStore = defineStore('media', {
captures: PhonePhoto[]
claimedApps: string[]
}> | null
this.captures = Array.isArray(data?.captures) ? data.captures : []
this.captures = Array.isArray(data?.captures)
? data.captures.filter(
(photo) =>
typeof photo.id === 'string' &&
typeof photo.url === 'string' &&
photo.url.startsWith('https://'),
)
: []
this.claimedApps = Array.isArray(data?.claimedApps)
? data.claimedApps.filter((id): id is string => typeof id === 'string')
: []
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from 'pinia'
import type { MediaType, PhoneMedia } from '@/types/media'
type MessageMediaRequest = {
mediaType: MediaType
phoneNumber: string
}
type MessageMediaResult = MessageMediaRequest & {
media: PhoneMedia
}
export const useMessageMediaStore = defineStore('message-media', {
state: () => ({
request: null as MessageMediaRequest | null,
result: null as MessageMediaResult | null,
}),
actions: {
begin(phoneNumber: string, mediaType: MediaType): void {
this.request = { mediaType, phoneNumber }
this.result = null
},
cancel(): void {
this.request = null
},
complete(media: PhoneMedia): void {
if (!this.request || this.request.mediaType !== media.mediaType) return
this.result = { ...this.request, media }
this.request = null
},
consume(phoneNumber: string): PhoneMedia | null {
if (!this.result || this.result.phoneNumber !== phoneNumber) return null
const media = this.result.media
this.result = null
return media
},
},
})
+116
View File
@@ -0,0 +1,116 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMessagesStore } from '@/stores/messages'
import type { SmsMessage } from '@/types/messages'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(),
}))
const mockNuiCall = vi.mocked(nuiCall)
function sentMessage(id: string): SmsMessage {
return {
body: 'Hello',
created_at: '2026-08-06 15:00:00',
direction: 'sent',
id,
media_duration_ms: null,
media_asset_id: null,
media_mime: null,
media_waveform: null,
message_type: 'text',
read_at: null,
recipient_number: '4205550196',
sender_number: '4205550100',
}
}
describe('messages store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('shows an outgoing message immediately and marks it delivered', async () => {
let resolveSend: ((value: { data: SmsMessage; success: true }) => void) | undefined
const response = new Promise<{ data: SmsMessage; success: true }>((resolve) => {
resolveSend = resolve
})
mockNuiCall
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ data: [], success: true })
.mockImplementationOnce(() => response)
.mockResolvedValueOnce({ data: [], success: true })
const messages = useMessagesStore()
await messages.openThread('4205550196')
const sending = messages.send({ body: 'Hello', messageType: 'text' })
expect(messages.messages).toHaveLength(1)
expect(messages.messages[0]).toMatchObject({
body: 'Hello',
delivery_status: 'sending',
message_type: 'text',
})
resolveSend?.({ data: sentMessage('server-id'), success: true })
await sending
expect(messages.messages[0]).toMatchObject({
delivery_status: 'delivered',
id: 'server-id',
})
})
it('keeps a failed optimistic message visible', async () => {
mockNuiCall
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ error: 'recipient_unavailable', success: false })
const messages = useMessagesStore()
await messages.openThread('4205550196')
await messages.send({ body: 'Hello', messageType: 'text' })
expect(messages.messages[0].delivery_status).toBe('failed')
})
it('normalizes numeric phone data from the NUI boundary', async () => {
mockNuiCall.mockResolvedValueOnce({
data: [
{
lastMessage: 'Hello',
lastMessageAt: 1_786_034_600,
lastMessageType: 'text',
phoneNumber: 4_205_550_196,
unread: 0,
},
],
success: true,
})
const messages = useMessagesStore()
await messages.loadConversations()
expect(messages.conversations[0].phoneNumber).toBe('4205550196')
})
it('loads protected voice media once and caches the data URI', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { mime: 'audio/webm;codecs=opus', payload: 'ZmFrZQ==' },
success: true,
})
const messages = useMessagesStore()
expect(await messages.loadMedia('voice-id')).toBe(true)
expect(await messages.loadMedia('voice-id')).toBe(true)
expect(messages.mediaSources['voice-id']).toBe(
'data:audio/webm;codecs=opus;base64,ZmFrZQ==',
)
expect(mockNuiCall).toHaveBeenCalledTimes(1)
})
})
+165
View File
@@ -0,0 +1,165 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type {
GifSearchPage,
SmsConversation,
SmsMedia,
SmsMessage,
SmsOutgoingMessage,
} from '@/types/messages'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useMessagesStore = defineStore('messages', () => {
const conversations = ref<SmsConversation[]>([])
const messages = ref<SmsMessage[]>([])
const mediaSources = ref<Record<string, string>>({})
const activeNumber = ref<string | null>(null)
const loading = ref(false)
const unreadCount = computed(() =>
conversations.value.reduce((total, item) => total + item.unread, 0),
)
async function loadConversations(): Promise<boolean> {
const response = await nuiCall<SmsConversation[]>('messages:conversations')
if (response.success && response.data) {
conversations.value = response.data.map((conversation) => ({
...conversation,
phoneNumber: String(conversation.phoneNumber),
}))
}
else if (!response.success) conversations.value = []
return response.success
}
async function openThread(phoneNumber: string): Promise<boolean> {
loading.value = true
const response = await nuiCall<SmsMessage[]>('messages:thread', {
phoneNumber,
})
loading.value = false
if (!response.success || !response.data) return false
activeNumber.value = phoneNumber
messages.value = response.data.map((message) => ({
...message,
delivery_status:
message.direction === 'sent' ? 'delivered' : undefined,
recipient_number: String(message.recipient_number),
sender_number: String(message.sender_number),
}))
await loadConversations()
return true
}
async function send(
outgoing: SmsOutgoingMessage,
): Promise<NuiResponse<SmsMessage>> {
if (!activeNumber.value) return { success: false, error: 'invalid_number' }
const clientId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const optimistic: SmsMessage = {
body: outgoing.messageType === 'text' ? outgoing.body.trim() : '',
client_id: clientId,
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
delivery_status: 'sending',
direction: 'sent',
id: clientId,
media_asset_id:
outgoing.messageType === 'image' ||
outgoing.messageType === 'gif' ||
outgoing.messageType === 'video'
? outgoing.mediaAssetId
: null,
media_duration_ms:
outgoing.messageType === 'voice' || outgoing.messageType === 'video'
? outgoing.mediaDurationMs ?? null
: null,
media_mime:
outgoing.messageType === 'voice' ? outgoing.mediaMime : null,
media_waveform:
outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null,
message_type: outgoing.messageType,
read_at: null,
recipient_number: activeNumber.value,
sender_number: '',
}
messages.value.push(optimistic)
if (outgoing.messageType === 'voice') {
mediaSources.value[clientId] =
`data:${outgoing.mediaMime};base64,${outgoing.mediaPayload}`
}
const response = await nuiCall<SmsMessage>('messages:send', {
...outgoing,
phoneNumber: activeNumber.value,
})
const index = messages.value.findIndex(
(message) => message.client_id === clientId,
)
if (!response.success || !response.data) {
if (index >= 0) messages.value[index].delivery_status = 'failed'
return response
}
const source = mediaSources.value[clientId]
delete mediaSources.value[clientId]
if (source) mediaSources.value[response.data.id] = source
if (index >= 0) {
messages.value[index] = {
...response.data,
client_id: clientId,
delivery_status: 'delivered',
}
}
await loadConversations()
return response
}
async function loadMedia(id: string): Promise<boolean> {
if (mediaSources.value[id]) return true
const response = await nuiCall<SmsMedia>('messages:media', { id })
if (!response.success || !response.data) return false
mediaSources.value[id] =
`data:${response.data.mime};base64,${response.data.payload}`
return true
}
async function deleteConversations(phoneNumbers: string[]): Promise<boolean> {
if (!phoneNumbers.length) return false
const response = await nuiCall('messages:delete', { phoneNumbers })
if (!response.success) return false
if (activeNumber.value && phoneNumbers.includes(activeNumber.value)) {
closeThread()
}
await loadConversations()
return true
}
async function searchGifs(
query: string,
offset = 0,
): Promise<NuiResponse<GifSearchPage>> {
return nuiCall<GifSearchPage>('messages:gifs', { offset, query })
}
function closeThread(): void {
activeNumber.value = null
messages.value = []
mediaSources.value = {}
}
return {
activeNumber,
closeThread,
conversations,
deleteConversations,
loadConversations,
loadMedia,
loading,
mediaSources,
messages,
openThread,
searchGifs,
send,
unreadCount,
}
})
+99 -32
View File
@@ -29,44 +29,103 @@ const namespaceQueues = new Map<string, Promise<void>>()
const defaultLocales: LocaleTree = {
Apps: {
messages: {
name: 'Messages',
newMessage: 'New message from {sender}',
compose: 'New Message',
search: 'Search',
to: 'To:',
message: 'Message',
send: 'Send',
details: 'Details',
filterUnread: 'Show Unread Messages',
smsLabel: 'Text Message · SMS',
photo: 'Photo',
gif: 'GIF',
video: 'Video',
attachPhoto: 'Attach Photo',
takePhoto: 'Take Photo',
attachGif: 'Attach GIF',
attachVideo: 'Attach Video',
photos: 'Photos',
gifs: 'GIFs',
videos: 'Videos',
noPhotos: 'Take a photo first to attach it here.',
noVideos: 'Record a video in Camera first.',
searchGifs: 'Search GIPHY',
loadMore: 'Load More',
retryGifs: 'Try Again',
moreActions: 'More Actions',
contactDetails: 'Contact Details',
contactName: 'Name',
phoneNumber: 'Phone Number',
call: 'Call',
messageAction: 'Message',
addContact: 'Add Contact',
deleteContact: 'Delete Contact',
selectedCount: '{count} Selected',
deleteSelected: 'Delete',
contactSaveFailed: 'The contact could not be saved.',
contactDeleteFailed: 'The contact could not be deleted.',
callFailed: 'The call could not be started.',
emoji: 'Emoji',
voiceMessage: 'Audio Message',
recordVoice: 'Record Audio',
playAudio: 'Play Audio',
pauseAudio: 'Pause Audio',
recording: 'Recording',
stopAndSend: 'Stop and Send',
cancelRecording: 'Cancel Recording',
sending: 'Sending...',
delivered: 'Delivered',
notDelivered: 'Not Delivered',
microphoneUnavailable: 'The microphone is unavailable.',
recordingTooLarge: 'The recording is too large.',
noSim: 'No SIM',
noSimBody: 'Insert a SIM card in Settings to send and receive messages.',
noMessages: 'No Messages',
noMessagesBody: 'Start a conversation with a phone number.',
noResults: 'No Results',
today: 'Today',
yesterday: 'Yesterday',
errors: {
invalid_number: 'Enter a valid phone number.',
invalid_message: 'Enter a message.',
invalid_voice: 'The audio message is invalid.',
invalid_attachment: 'The attachment is invalid.',
media_provider_unconfigured: 'Photo uploads are not configured.',
capture_provider_unavailable: 'The screenshot resource is unavailable.',
capture_failed: 'The photo could not be captured.',
video_provider_unavailable: 'Video capture requires the screencapture resource.',
video_capture_failed: 'The video could not be recorded.',
recording_in_progress: 'A video is already being recorded.',
recording_not_found: 'No active video recording was found.',
gif_provider_unconfigured: 'GIF search is not configured.',
gif_provider_unauthorized: 'The configured GIPHY API key is invalid.',
gif_provider_rate_limited: 'GIPHY is busy. Try again in a moment.',
gif_provider_failed: 'GIF search is temporarily unavailable.',
self_message: 'You cannot message your own number.',
recipient_not_found: 'That number is unavailable.',
no_sim: 'This phone has no SIM card.',
rate_limited: 'Too many messages. Try again in a minute.',
request_failed: 'Messages are temporarily unavailable.',
default: 'The message could not be sent.',
},
},
appStore: {
name: 'App Store',
eyebrow: 'Discover',
featured: 'Featured',
heroTitle: 'Apps for every day',
heroBody: 'Fresh ideas, built for your life in the city.',
get: 'GET',
open: 'OPEN',
searchPlaceholder: 'Games, Apps, Stories and More',
communityTitle: 'Black Voices and Creators',
communityBody: 'Apps and games from the community',
playing: "What We're Playing",
recommended: 'Recommended for You',
selected: 'Great apps selected by our editors',
installing: 'Installing',
searchPlaceholder: 'Search apps and games',
appsTitle: 'Built-in Apps',
gamesTitle: 'Games',
selected: 'Apps available for your Sky Phone',
tabs: {
today: 'Today',
apps: 'Apps',
games: 'Games',
arcade: 'Arcade',
search: 'Search',
},
catalog: {
orbit: 'Plan your day',
studio: 'Create something new',
trail: 'Explore nearby',
prism: 'A colorful puzzle',
},
card: {
oneEyebrow: 'App of the Day',
oneTitle: 'A universe in your pocket',
oneBody: 'Explore something extraordinary today.',
twoEyebrow: 'Now Trending',
twoTitle: 'Turn up your afternoon',
twoBody: 'Fresh sounds and stories picked for you.',
threeEyebrow: "Editors' Choice",
threeTitle: 'Play without limits',
threeBody: 'A new world is waiting.',
},
},
phone: {
name: 'Phone',
@@ -991,7 +1050,15 @@ const defaultLocales: LocaleTree = {
noApps: 'No apps found',
page: 'Page',
pages: 'Home screen pages',
groups: { 0: 'Suggestions', 1: 'Recently Added', other: 'Other' },
groups: {
suggestions: 'Suggestions',
recentlyAdded: 'Recently Added',
games: 'Games',
productivity: 'Productivity',
shopping: 'Shopping',
social: 'Social Networks',
utilities: 'Utilities',
},
widgets: {
label: 'Widgets',
weather: { city: 'Los Santos', condition: 'Partly Cloudy' },
@@ -1080,8 +1147,8 @@ export const usePhoneStore = defineStore('phone', {
})
namespaceQueues.set(namespace, tracked)
},
setCurrentPage(page: number): void {
this.currentPage = clampPage(page)
setCurrentPage(page: number, pageCount?: number): void {
this.currentPage = clampPage(page, pageCount)
},
setCameraLandscape(landscape: boolean): void {
this.cameraLandscape = landscape
+9
View File
@@ -2,6 +2,7 @@ import type { Component } from 'vue'
export type PhoneAppId =
| 'phone'
| 'messages'
| 'calculator'
| 'camera'
| 'clock'
@@ -26,6 +27,13 @@ export type PhoneAppId =
export type LaunchablePhoneAppId = PhoneAppId
export type PhoneAppCategory =
| 'games'
| 'productivity'
| 'shopping'
| 'social'
| 'utilities'
export type AppLaunchOrigin = {
borderRadius: number
scaleX: number
@@ -35,6 +43,7 @@ export type AppLaunchOrigin = {
}
export type PhoneAppDefinition = {
category: PhoneAppCategory
component: Component | null
dockOrder: number | null
gridOrder: number
+66
View File
@@ -0,0 +1,66 @@
import type { DatabaseDateValue } from '@/utils/date'
export type SmsDirection = 'sent' | 'received'
export type SmsAttachmentType = 'image' | 'gif' | 'video'
export type SmsMessageType = 'text' | 'voice' | SmsAttachmentType
export type SmsDeliveryStatus = 'sending' | 'delivered' | 'failed'
export type SmsConversation = {
lastMessage: string
lastMessageAt: DatabaseDateValue
lastMessageType: SmsMessageType
phoneNumber: string
unread: number
}
export type SmsMessage = {
body: string
client_id?: string
created_at: DatabaseDateValue
delivery_status?: SmsDeliveryStatus
direction: SmsDirection
id: string
media_asset_id: string | null
media_duration_ms: number | null
media_mime: string | null
media_waveform: number[] | null
message_type: SmsMessageType
read_at: string | null
recipient_number: string
sender_number: string
}
export type SmsOutgoingMessage =
| { body: string; messageType: 'text' }
| {
mediaDurationMs: number
mediaMime: string
mediaPayload: string
mediaWaveform: number[]
messageType: 'voice'
}
| {
mediaAssetId: string
mediaDurationMs?: number
messageType: SmsAttachmentType
}
export type SmsMedia = {
mime: string
payload: string
}
export type GifSearchResult = {
height: number
id: string
previewUrl: string
title: string
url: string
width: number
}
export type GifSearchPage = {
hasMore: boolean
nextOffset: number
results: GifSearchResult[]
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { parseDatabaseDate } from './date'
describe('database dates', () => {
it('parses SQL date strings', () => {
expect(parseDatabaseDate('2026-08-06 17:30:00').getTime()).toBe(
new Date('2026-08-06T17:30:00').getTime(),
)
})
it('parses Unix timestamps in seconds and milliseconds', () => {
expect(parseDatabaseDate(1_786_034_600).getTime()).toBe(1_786_034_600_000)
expect(parseDatabaseDate(1_786_034_600_000).getTime()).toBe(
1_786_034_600_000,
)
})
})
+10
View File
@@ -0,0 +1,10 @@
export type DatabaseDateValue = string | number
export function parseDatabaseDate(value: DatabaseDateValue): Date {
if (typeof value === 'number') {
const timestamp = Math.abs(value) < 1_000_000_000_000 ? value * 1000 : value
return new Date(timestamp)
}
return new Date(value.replace(' ', 'T'))
}
+13 -1
View File
@@ -1,9 +1,21 @@
import { describe, expect, it } from 'vitest'
import { clampPage } from './pages'
import { clampPage, paginateItems } from './pages'
describe('page clamping', () => {
it('keeps pages in range', () => {
expect(clampPage(-4)).toBe(0)
expect(clampPage(1)).toBe(1)
expect(clampPage(9)).toBe(2)
})
it('splits overflowing app grids into additional pages', () => {
expect(
paginateItems(
Array.from({ length: 21 }, (_, index) => index),
20,
),
).toEqual([
Array.from({ length: 20 }, (_, index) => index),
[20],
])
})
})
+9
View File
@@ -1,5 +1,14 @@
export const SPRINGBOARD_PAGE_COUNT = 3
export function paginateItems<T>(items: readonly T[], pageSize: number): T[][] {
if (!Number.isInteger(pageSize) || pageSize <= 0) return []
const pages: T[][] = []
for (let index = 0; index < items.length; index += pageSize) {
pages.push(items.slice(index, index + pageSize))
}
return pages
}
export function clampPage(
page: number,
pageCount = SPRINGBOARD_PAGE_COUNT,
+1
View File
@@ -11,5 +11,6 @@ describe('phone numbers', () => {
it('formats keypad input incrementally', () => {
expect(formatPhoneNumber('5551234567')).toBe('555 123 4567')
expect(formatPhoneNumber('5551')).toBe('555 1')
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
})
})
+2 -2
View File
@@ -5,8 +5,8 @@ export function normalizePhoneNumber(value: string): string | null {
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
}
export function formatPhoneNumber(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
export function formatPhoneNumber(value: string | number): string {
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
return groups.filter(Boolean).join(' ')
}
+5
View File
@@ -16,6 +16,7 @@ describe('preferences', () => {
notificationVolume: 45,
notificationDurationSeconds: 14,
notifications: {
messages: { enabled: false, sounds: false },
clock: { enabled: false, sounds: false },
},
phoneScale: 110,
@@ -26,6 +27,10 @@ describe('preferences', () => {
expect(value.settings.appearanceMode).toBe('light')
expect(value.settings.notificationVolume).toBe(45)
expect(value.settings.notificationDurationSeconds).toBe(14)
expect(value.settings.notifications.messages).toEqual({
enabled: false,
sounds: false,
})
expect(value.settings.notifications.clock).toEqual({
enabled: false,
sounds: false,
+1
View File
@@ -49,6 +49,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
AppNotificationPreferences
> = {
phone: { enabled: true, sounds: true },
messages: { enabled: true, sounds: true },
'app-store': { enabled: true, sounds: true },
calculator: { enabled: true, sounds: true },
snake: { enabled: true, sounds: true },
+76 -30
View File
@@ -5,11 +5,21 @@ import { computed, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import SpringboardWidgets from '@/components/SpringboardWidgets.vue'
import { PHONE_APPS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
import { clampPage, SPRINGBOARD_PAGE_COUNT } from '@/utils/pages'
import type { PhoneAppCategory, PhoneAppDefinition } from '@/types/apps'
import { paginateItems } from '@/utils/pages'
const APPS_PER_HOME_PAGE = 20
const APP_LIBRARY_CATEGORIES: PhoneAppCategory[] = [
'games',
'productivity',
'social',
'utilities',
'shopping',
]
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const searchQuery = ref('')
const searchFocused = ref(false)
const showAllApps = ref(false)
@@ -19,7 +29,17 @@ let pointerStart = 0
let pointerStartedAt = 0
const gridApps = computed(() =>
[...PHONE_APPS].sort((a, b) => a.gridOrder - b.gridOrder),
PHONE_APPS.filter(
(app) => app.category !== 'games' || appStore.claimedApps.includes(app.id),
).sort((a, b) => a.gridOrder - b.gridOrder),
)
const appPages = computed(() =>
paginateItems(gridApps.value, APPS_PER_HOME_PAGE),
)
const pageCount = computed(() => appPages.value.length + 2)
const libraryPage = computed(() => pageCount.value - 1)
const isAppPage = computed(
() => phone.currentPage > 0 && phone.currentPage < libraryPage.value,
)
const dockApps = computed(() =>
PHONE_APPS.filter((app) => app.dockOrder !== null).sort(
@@ -34,14 +54,31 @@ const filteredApps = computed(() => {
)
})
const appGroups = computed(() => {
const groups: PhoneAppDefinition[][] = []
for (let index = 0; index < gridApps.value.length; index += 3) {
groups.push(gridApps.value.slice(index, index + 3))
}
return groups.map((apps, index) => ({
apps,
moreApps: groups[(index + 1) % groups.length] ?? [],
}))
const suggestions = [...gridApps.value]
.sort(
(a, b) =>
(appStore.launchCounts[b.id] ?? 0) -
(appStore.launchCounts[a.id] ?? 0) || a.gridOrder - b.gridOrder,
)
.slice(0, 7)
const recentlyAdded = [...gridApps.value]
.sort((a, b) => b.gridOrder - a.gridOrder)
.slice(0, 7)
const groups = [
{ apps: suggestions, key: 'suggestions' },
{ apps: recentlyAdded, key: 'recentlyAdded' },
...APP_LIBRARY_CATEGORIES.map((category) => ({
apps: gridApps.value.filter((app) => app.category === category),
key: category,
})),
]
return groups
.filter((group) => group.apps.length > 0)
.map((group) => ({
...group,
apps: group.apps.slice(0, 3),
moreApps: group.apps.slice(3),
}))
})
const alphabeticalGroups = computed(() => {
const groups: Array<{ apps: PhoneAppDefinition[]; letter: string }> = []
@@ -57,8 +94,10 @@ const alphabeticalGroups = computed(() => {
})
const trackStyle = computed(() => ({
'--drag-offset': `${dragOffset.value}px`,
'--springboard-page': phone.currentPage,
'--springboard-offset': `${(-phone.currentPage * 100) / pageCount.value}%`,
width: `${pageCount.value * 100}%`,
}))
const pageStyle = computed(() => ({ width: `${100 / pageCount.value}%` }))
function onPointerDown(event: PointerEvent): void {
const target = event.target as HTMLElement
@@ -80,7 +119,10 @@ function finishPointer(event: PointerEvent): void {
const elapsed = Math.max(1, Date.now() - pointerStartedAt)
const velocity = Math.abs(distance) / elapsed
if (Math.abs(distance) > 48 || velocity > 0.45) {
phone.setCurrentPage(phone.currentPage + (distance < 0 ? 1 : -1))
phone.setCurrentPage(
phone.currentPage + (distance < 0 ? 1 : -1),
pageCount.value,
)
}
dragging.value = false
dragOffset.value = 0
@@ -116,22 +158,27 @@ function openAllApps(): void {
>
<section
class="springboard-page springboard-page--widgets"
:style="pageStyle"
:aria-label="phone.t('Home.widgets.label')"
>
<SpringboardWidgets />
</section>
<section
v-for="(apps, pageIndex) in appPages"
:key="`apps-${pageIndex}`"
class="springboard-page springboard-page--apps"
:style="pageStyle"
:aria-label="phone.t('Home.apps')"
>
<div class="app-grid">
<AppIcon v-for="app in gridApps" :key="app.id" :app="app" />
<AppIcon v-for="app in apps" :key="app.id" :app="app" />
</div>
</section>
<section
class="springboard-page springboard-page--library"
:style="pageStyle"
:aria-label="phone.t('Home.appLibrary')"
>
<div
@@ -160,8 +207,8 @@ function openAllApps(): void {
:class="{ 'app-library-groups--behind': showAllApps }"
>
<article
v-for="(group, index) in appGroups"
:key="index"
v-for="group in appGroups"
:key="group.key"
class="app-library-group"
>
<div class="app-library-group__icons">
@@ -173,6 +220,7 @@ function openAllApps(): void {
:show-label="false"
/>
<button
v-if="group.moreApps.length"
class="app-library-more"
type="button"
:aria-label="phone.t('Home.allApps')"
@@ -187,9 +235,7 @@ function openAllApps(): void {
/>
</button>
</div>
<span>{{
phone.t(index < 2 ? `Home.groups.${index}` : 'Home.groups.other')
}}</span>
<span>{{ phone.t(`Home.groups.${group.key}`) }}</span>
</article>
</div>
@@ -223,11 +269,7 @@ function openAllApps(): void {
</div>
<Transition name="dock">
<nav
v-if="phone.currentPage === 1"
class="app-dock"
:aria-label="phone.t('Home.dock')"
>
<nav v-if="isAppPage" class="app-dock" :aria-label="phone.t('Home.dock')">
<AppIcon
v-for="app in dockApps"
:key="app.id"
@@ -237,14 +279,18 @@ function openAllApps(): void {
</nav>
</Transition>
<nav class="page-indicator" :aria-label="phone.t('Home.pages')">
<nav
v-if="isAppPage && appPages.length > 1"
class="page-indicator"
:aria-label="phone.t('Home.pages')"
>
<button
v-for="page in SPRINGBOARD_PAGE_COUNT"
:key="page"
v-for="(_, pageIndex) in appPages"
:key="pageIndex"
type="button"
:class="{ active: phone.currentPage === page - 1 }"
:aria-label="`${phone.t('Home.page')} ${page}`"
@click="phone.setCurrentPage(clampPage(page - 1))"
:class="{ active: phone.currentPage === pageIndex + 1 }"
:aria-label="`${phone.t('Home.page')} ${pageIndex + 1}`"
@click="phone.setCurrentPage(pageIndex + 1, pageCount)"
></button>
</nav>
</section>
+94 -112
View File
@@ -1,144 +1,127 @@
<script setup lang="ts">
import {
Gamepad2,
Grid2X2,
Rocket,
Search,
Sparkles,
UserRound,
} from 'lucide-vue-next'
import { kPreloader } from 'konsta/vue'
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 { useAppStoreStore } from '@/stores/app-store'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const tab = ref<'today' | 'apps' | 'games' | 'arcade' | 'search'>('today')
const router = useRouter()
const tab = ref<'apps' | 'games' | 'search'>('apps')
const query = ref('')
const tabs = [
{ id: 'today', icon: Sparkles },
{ id: 'apps', icon: Grid2X2 },
{ id: 'games', icon: Gamepad2 },
{ id: 'arcade', icon: Rocket },
{ id: 'search', icon: Search },
] as const
const catalog = [
{
id: 'orbit',
title: 'Orbit',
subtitle: 'Apps.appStore.catalog.orbit',
gradient: 'linear-gradient(145deg,#453bd1,#75e8ff)',
},
{
id: 'studio',
title: 'Studio',
subtitle: 'Apps.appStore.catalog.studio',
gradient: 'linear-gradient(145deg,#ff8b4a,#ff2d75)',
},
{
id: 'trail',
title: 'Trail',
subtitle: 'Apps.appStore.catalog.trail',
gradient: 'linear-gradient(145deg,#4bd37b,#147ec1)',
},
{
id: 'prism',
title: 'Prism',
subtitle: 'Apps.appStore.catalog.prism',
gradient: 'linear-gradient(145deg,#ffd84d,#7b36dc)',
},
]
const shown = computed(() =>
catalog.filter((item) =>
item.title.toLowerCase().includes(query.value.toLowerCase()),
),
const catalog = PHONE_APPS.filter((app) => app.id !== 'app-store').sort(
(a, b) => a.gridOrder - b.gridOrder,
)
const date = new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
month: 'long',
}).format(new Date())
const shownApps = computed(() => {
if (tab.value === 'games') {
return catalog.filter((app) => app.category === 'games')
}
if (tab.value === 'apps') {
return catalog.filter((app) => app.category !== 'games')
}
const search = query.value.trim().toLocaleLowerCase(phone.lang)
if (!search) return catalog
return catalog.filter((app) =>
phone.t(app.labelKey).toLocaleLowerCase(phone.lang).includes(search),
)
})
function isInstalled(app: PhoneAppDefinition): boolean {
return app.category !== 'games' || appStore.claimedApps.includes(app.id)
}
function handleApp(app: PhoneAppDefinition): void {
if (isInstalled(app)) {
if (app.route) void router.push(app.route)
return
}
appStore.installApp(app.id)
}
</script>
<template>
<main class="native-app reference-store">
<section class="store-scroll">
<header class="store-title">
<div>
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
<strong v-if="tab === 'today'">{{ date }}</strong>
</div>
<span><UserRound :size="22" /></span>
<h1>{{ phone.t(`Apps.appStore.tabs.${tab}`) }}</h1>
</header>
<div v-if="tab === 'search'" class="app-search">
<Search :size="17" /><input
<Search :size="17" />
<input
v-model="query"
:placeholder="phone.t('Apps.appStore.searchPlaceholder')"
/>
</div>
<template v-if="tab === 'today'">
<article class="editorial-card editorial-card--sky">
<small>{{ phone.t('Apps.appStore.card.oneEyebrow') }}</small>
<h2>{{ phone.t('Apps.appStore.card.oneTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.card.oneBody') }}</p>
<div class="editorial-orbit"></div>
</article>
<article class="editorial-card editorial-card--music">
<small>{{ phone.t('Apps.appStore.card.twoEyebrow') }}</small>
<h2>{{ phone.t('Apps.appStore.card.twoTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.card.twoBody') }}</p>
<div class="editorial-wave" />
</article>
<div class="store-section-title">
<h2>{{ phone.t('Apps.appStore.communityTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.communityBody') }}</p>
</div>
<article class="editorial-card editorial-card--play">
<small>{{ phone.t('Apps.appStore.card.threeEyebrow') }}</small>
<h2>{{ phone.t('Apps.appStore.card.threeTitle') }}</h2>
<p>{{ phone.t('Apps.appStore.card.threeBody') }}</p>
</article>
</template>
<template v-else>
<article v-if="tab !== 'search'" class="store-feature">
<p>{{ phone.t('Apps.appStore.featured') }}</p>
<h2>{{ phone.t('Apps.appStore.heroTitle') }}</h2>
<span>{{ phone.t('Apps.appStore.heroBody') }}</span>
</article>
<div class="store-section-title">
<h2>
{{
phone.t(
tab === 'games'
? 'Apps.appStore.playing'
: 'Apps.appStore.recommended',
)
}}
</h2>
<p>{{ phone.t('Apps.appStore.selected') }}</p>
</div>
<section class="store-list">
<article v-for="item in shown" :key="item.id">
<div class="store-icon" :style="{ background: item.gradient }">
{{ item.title[0] }}
</div>
<div>
<strong>{{ item.title }}</strong
><small>{{ phone.t(item.subtitle) }}</small>
</div>
<button type="button" @click="appStore.claimApp(item.id)">
<div class="store-section-title">
<h2>
{{
phone.t(
tab === 'games'
? 'Apps.appStore.gamesTitle'
: 'Apps.appStore.appsTitle',
)
}}
</h2>
<p>{{ phone.t('Apps.appStore.selected') }}</p>
</div>
<section class="store-list">
<article v-for="app in shownApps" :key="app.id">
<img
class="store-icon"
:src="app.iconImage"
alt=""
draggable="false"
/>
<div>
<strong>{{ phone.t(app.labelKey) }}</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(
appStore.installingApps[app.id]
? 'Apps.appStore.installing'
: isInstalled(app)
? 'Apps.appStore.open'
: 'Apps.appStore.get',
)}`"
@click="handleApp(app)"
>
<k-preloader
v-if="appStore.installingApps[app.id]"
class="store-installing"
/>
<template v-else>
{{
phone.t(
appStore.claimedApps.includes(item.id)
? 'Apps.appStore.open'
: 'Apps.appStore.get',
isInstalled(app) ? 'Apps.appStore.open' : 'Apps.appStore.get',
)
}}
</button>
</article>
</section>
</template>
</template>
</button>
</article>
<p v-if="shownApps.length === 0" class="store-empty">
{{ phone.t('Home.noApps') }}
</p>
</section>
</section>
<nav class="reference-tabbar">
<button
v-for="item in tabs"
@@ -147,9 +130,8 @@ const date = new Intl.DateTimeFormat(phone.lang, {
type="button"
@click="tab = item.id"
>
<component :is="item.icon" :size="21" /><span>{{
phone.t(`Apps.appStore.tabs.${item.id}`)
}}</span>
<component :is="item.icon" :size="21" />
<span>{{ phone.t(`Apps.appStore.tabs.${item.id}`) }}</span>
</button>
</nav>
</main>
+22 -3
View File
@@ -15,8 +15,9 @@ import {
ZapOff,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
import { createGameView, type GameView } from '@/utils/gameView'
@@ -33,8 +34,14 @@ type CaptureItem = {
const isDevelopment = import.meta.env.DEV
const zoomLevels = [0.5, 1, 2, 3] as const
const phone = usePhoneStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const mode = ref<MediaType>('photo')
const requestedMessageMedia = computed<MediaType | null>(() => {
const value = route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
const selectedZoom = ref<(typeof zoomLevels)[number]>(1)
const flashEnabled = ref(false)
const frontCamera = ref(false)
@@ -307,6 +314,11 @@ function onMessage(event: MessageEvent): void {
if (result.success && result.media) {
latestMedia.value = result.media
updateCapture(result.correlationId, { status: 'success' })
if (requestedMessageMedia.value === result.media.mediaType) {
messageMedia.complete(result.media)
void router.replace('/apps/messages')
return
}
showCameraNotice(phone.t('Apps.camera.saved'))
window.setTimeout(() => {
captures.value = captures.value.filter(
@@ -473,7 +485,14 @@ onBeforeUnmount(() => {
class="camera-latest"
type="button"
:aria-label="phone.t('Apps.camera.openGallery')"
@click="router.push('/apps/photos')"
@click="
router.push({
path: '/apps/photos',
query: requestedMessageMedia
? { messageAttachment: requestedMessageMedia }
: undefined,
})
"
>
<img
v-if="latestMedia?.mediaType === 'photo'"
+30 -2
View File
@@ -14,7 +14,9 @@ import {
} from 'konsta/vue'
import { Play, RotateCcw, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { DeleteResult, GalleryFilter, PhoneMedia } from '@/types/media'
import {
@@ -35,8 +37,15 @@ const filterItems = [
{ id: 'video', label: 'videos' },
] as const
const phone = usePhoneStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const requestedMessageMedia = computed<GalleryFilter | null>(() => {
const value = route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
const media = ref<PhoneMedia[]>([])
const filter = ref<GalleryFilter>('all')
const filter = ref<GalleryFilter>(requestedMessageMedia.value ?? 'all')
const loading = ref(true)
const fetching = ref(false)
const hasMore = ref(true)
@@ -193,6 +202,11 @@ function observeMore(): void {
}
function openMedia(entry: PhoneMedia): void {
if (requestedMessageMedia.value) {
messageMedia.complete(entry)
void router.replace('/apps/messages')
return
}
landscapeViewer.value = false
phone.setCameraLandscape(false)
selected.value = entry
@@ -200,6 +214,11 @@ function openMedia(entry: PhoneMedia): void {
imagePan.value = { x: 0, y: 0 }
}
function cancelMessageSelection(): void {
messageMedia.cancel()
void router.replace('/apps/messages')
}
function closeMedia(): void {
landscapeViewer.value = false
phone.setCameraLandscape(false)
@@ -326,7 +345,15 @@ onBeforeUnmount(() => {
class="gallery-page !pt-[44px]"
:aria-label="phone.t('Apps.photos.name')"
>
<k-navbar :title="phone.t('Apps.photos.name')" />
<k-navbar :title="phone.t('Apps.photos.name')">
<template v-if="requestedMessageMedia" #left>
<k-navbar-back-link
component="button"
:text="phone.t('Common.back')"
@click="cancelMessageSelection"
/>
</template>
</k-navbar>
<div class="gallery-content">
<div v-if="loading" class="gallery-state">
@@ -385,6 +412,7 @@ onBeforeUnmount(() => {
</div>
<k-navbar
v-if="!requestedMessageMedia"
component="nav"
class="gallery-filter-navbar"
:aria-label="phone.t('Apps.photos.name')"
File diff suppressed because it is too large Load Diff
+266 -11
View File
@@ -44,6 +44,44 @@ const radioData = {
settings: { autoRejoin: false, notifications: true },
volume: 50,
}
let contactSequence = 2
const contacts = [
{
created_at: '2026-08-04 12:00:00',
id: 'contact-1',
name: 'Jenica Chong',
phone_number: '5558675309',
updated_at: '2026-08-04 12:00:00',
},
]
const attachmentAssets = {
gif: new Set(['celebrate', 'hearts', 'party', 'thumbs_up', 'wow']),
image: new Set([
'camera-1', 'camera-2', 'camera-3', 'city-lights', 'desert-road',
'ocean-air', 'sunset-drive',
]),
video: new Set(['city-loop', 'ocean-loop', 'sunset-loop']),
}
const gifMocks = [
['ICOgUNjpvO0PC', 'Cat reaction'],
['MDJ9IbxxvDUQM', 'Happy dog'],
['l0HlPystfePnAI3G8', 'Celebrate'],
['26ufdipQqU2lhNA4g', 'Wow'],
['3o7abKhOpu0NwenH3O', 'Perfect'],
['xT0xeJpnrWC4XWblEk', 'Party'],
['111ebonMs90YLu', 'Thumbs up'],
['5GoVLqeAOo6PK', 'Excited'],
['TdfyKrN7HGTIY', 'Happy dance'],
['14udF3WUwwGMaA', 'Surprised'],
['3o6Zt6ML6BklcajjsA', 'Applause'],
['13CoXDiaCcCoyk', 'Let us go'],
['R6gvnAxj2ISzJdbA63', 'Yes'],
['xUPGcEliCc7bETyfO8', 'Laughing'],
['26BRuo6sLetdllPAQ', 'Dancing'],
['l46CyJmS9KUbokzsI', 'Amazing'],
['g9582DNuQppxC', 'Celebration'],
['artj92V8o75VPL7AeQ', 'High five'],
]
const accountDevices = [
{
created_at: '2026-08-04 12:00:00',
@@ -108,6 +146,38 @@ const messages = [
trashed_at: '2026-08-04 08:00:00',
},
]
const smsMessages = [
{
body: 'Bin gleich am Würfelpark. Kommst du auch?',
created_at: '2026-08-06 13:04:00',
direction: 'received',
id: 'sms-1',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: null,
recipient_number: '5551234567',
sender_number: '5558675309',
},
{
body: 'Ja, gib mir fünf Minuten.',
created_at: '2026-08-06 13:05:00',
direction: 'sent',
id: 'sms-2',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: null,
recipient_number: '5558675309',
sender_number: '5551234567',
},
]
const marketplaceListings = [
{
id: '81bc9d37-20e1-4d8a-82f8-f4b85f77cf01',
@@ -368,17 +438,7 @@ const deviceData = {
},
apps: {
payload: {
claimedApps: [
'snake',
'memory',
'number-merge',
'minesweeper',
'tower-stack',
'sky-flappy',
'neon-drop',
'citymarkt',
'local-pages',
],
claimedApps: [],
},
revision: 2,
},
@@ -859,6 +919,51 @@ app.post('/api/:endpoint', (request, response) => {
})
return
}
if (endpoint === 'messages:conversations') {
const grouped = new Map()
for (const message of [...smsMessages].reverse()) {
const phoneNumber =
message.direction === 'sent'
? message.recipient_number
: message.sender_number
const conversation = grouped.get(phoneNumber)
if (conversation) {
if (message.direction === 'received' && !message.read_at)
conversation.unread += 1
continue
}
grouped.set(phoneNumber, {
lastMessage: message.body,
lastMessageAt: message.created_at,
lastMessageType: message.message_type,
phoneNumber,
unread: message.direction === 'received' && !message.read_at ? 1 : 0,
})
}
response.json({ success: true, data: [...grouped.values()] })
return
}
if (endpoint === 'messages:gifs') {
const offset = Math.max(0, Number(request.body.offset ?? 0))
const pageSize = 6
const results = gifMocks.slice(offset, offset + pageSize).map(([id, title]) => ({
height: 200,
id,
previewUrl: `https://media.giphy.com/media/${id}/200w.gif`,
title,
url: `https://media.giphy.com/media/${id}/giphy.gif`,
width: 200,
}))
response.json({
success: true,
data: {
hasMore: offset + results.length < gifMocks.length,
nextOffset: offset + results.length,
results,
},
})
return
}
if (endpoint === 'development:bootstrap') {
response.json({
success: true,
@@ -881,6 +986,156 @@ app.post('/api/:endpoint', (request, response) => {
})
return
}
if (endpoint === 'messages:thread') {
const number = String(request.body.phoneNumber)
const thread = smsMessages.filter(
(message) =>
message.sender_number === number || message.recipient_number === number,
)
for (const message of thread) {
if (message.direction === 'received') message.read_at = message.read_at ?? '2026-08-06 13:06:00'
}
response.json({
success: true,
data: thread.map(({ media_payload, ...message }) => ({
...message,
media_asset_id: ['image', 'gif', 'video'].includes(message.message_type)
? media_payload
: null,
})),
})
return
}
if (endpoint === 'messages:media') {
const message = smsMessages.find(
(item) => item.id === request.body.id && item.message_type === 'voice',
)
response.json(
message
? {
success: true,
data: {
mime: message.media_mime,
payload: message.media_payload,
},
}
: { success: false, error: 'message_not_found' },
)
return
}
if (endpoint === 'messages:send') {
const body = String(request.body.body ?? '').trim()
const phoneNumber = String(request.body.phoneNumber ?? '')
const messageType = request.body.messageType ?? 'text'
const isAttachment = ['image', 'gif', 'video'].includes(messageType)
const requestedAttachmentId = String(request.body.mediaAssetId ?? '')
const selectedMedia = /^\d+$/.test(requestedAttachmentId)
? mockMedia.find((item) => String(item.id) === requestedAttachmentId)
: null
const attachmentId = selectedMedia?.url ?? requestedAttachmentId
if (
!phoneNumber ||
(messageType === 'text' && !body) ||
(messageType === 'voice' && !request.body.mediaPayload) ||
(isAttachment &&
!attachmentAssets[messageType].has(attachmentId) &&
!attachmentId.startsWith('https://') &&
!attachmentId.startsWith('data:image/'))
) {
response.json({ success: false, error: 'invalid_message' })
return
}
const message = {
body,
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
direction: 'sent',
id: `sms-${Date.now()}`,
media_duration_ms:
['voice', 'video'].includes(messageType)
? request.body.mediaDurationMs ?? null
: null,
media_mime:
messageType === 'voice'
? request.body.mediaMime
: messageType === 'image'
? 'image/jpeg'
: messageType === 'gif'
? 'image/gif'
: messageType === 'video'
? 'video/mp4'
: null,
media_payload:
messageType === 'voice'
? request.body.mediaPayload
: isAttachment
? attachmentId
: null,
media_waveform:
messageType === 'voice' ? request.body.mediaWaveform : null,
message_type: messageType,
media_asset_id: isAttachment ? attachmentId : null,
read_at: null,
recipient_number: phoneNumber,
sender_number: '5551234567',
}
smsMessages.push(message)
const { media_payload, ...publicMessage } = message
response.json({ success: true, data: publicMessage })
return
}
if (endpoint === 'messages:delete') {
const phoneNumbers = new Set(
Array.isArray(request.body.phoneNumbers)
? request.body.phoneNumbers.map(String)
: [],
)
for (let index = smsMessages.length - 1; index >= 0; index -= 1) {
const message = smsMessages[index]
const otherNumber =
message.direction === 'sent'
? message.recipient_number
: message.sender_number
if (phoneNumbers.has(otherNumber)) smsMessages.splice(index, 1)
}
response.json({ success: true })
return
}
if (endpoint === 'contacts:list') {
response.json({ success: true, data: contacts })
return
}
if (endpoint === 'contacts:save') {
const name = String(request.body.name ?? '').trim()
const phoneNumber = String(request.body.phoneNumber ?? '').trim()
if (!name || !phoneNumber) {
response.json({ success: false, error: 'invalid_contact' })
return
}
let contact = contacts.find((item) => item.id === request.body.id)
if (contact) {
contact.name = name
contact.phone_number = phoneNumber
contact.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ')
} else {
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
contact = {
created_at: now,
id: `contact-${contactSequence++}`,
name,
phone_number: phoneNumber,
updated_at: now,
}
contacts.push(contact)
}
response.json({ success: true, data: contact })
return
}
if (endpoint === 'contacts:delete') {
const index = contacts.findIndex((item) => item.id === request.body.id)
if (index >= 0) contacts.splice(index, 1)
response.json({ success: true })
return
}
if (endpoint === 'media:config') {
response.json({ success: true, data: { videoBitrateKbps: 1500 } })
return
+14 -1
View File
@@ -2,7 +2,7 @@ Config.Bridge = {
Framework = "auto", -- auto, esx, qbox, qb
Inventory = "auto", -- auto, ox, qb, lj, qs, codem, core, mf, smx
Locale = "en",
CallbackTimeout = 5000,
CallbackTimeout = 15000,
Debug = false,
DebugLevels = {
info = true,
@@ -84,6 +84,19 @@ Config.Radio = {
},
}
Config.Messages = {
BodyMaxLength = 2000,
ConversationScanLimit = 1000,
ThreadPageSize = 200,
SendsPerMinute = 30,
MediaLoadsPerMinute = 120,
VoiceMaxDurationMs = 30000,
VoiceMaxBase64Length = 180000,
VoiceWaveformSamples = 48,
VideoMaxDurationMs = 30000,
DeleteBatchSize = 20,
}
Config.Mail = {
Domain = "ifruit.com",
LocalPartMinLength = 3,
+41 -13
View File
@@ -26,7 +26,10 @@ Locales["en"] = {
Home = {
appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", page = "Page", pages = "Home screen pages",
groups = { ["0"] = "Suggestions", ["1"] = "Recently Added", other = "Other" },
groups = {
suggestions = "Suggestions", recentlyAdded = "Recently Added", games = "Games",
productivity = "Productivity", shopping = "Shopping", social = "Social Networks", utilities = "Utilities",
},
widgets = {
label = "Widgets",
weather = { city = "Los Santos", condition = "Partly Cloudy" },
@@ -36,6 +39,39 @@ Locales["en"] = {
},
},
Apps = {
messages = {
name = "Messages", newMessage = "New message from {sender}", compose = "New Message",
search = "Search", to = "To:", message = "Message", send = "Send", details = "Details",
filterUnread = "Show Unread Messages", smsLabel = "Text Message · SMS",
photo = "Photo", gif = "GIF", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo",
attachGif = "Attach GIF", attachVideo = "Attach Video", photos = "Photos", gifs = "GIFs", videos = "Videos",
noPhotos = "Take a photo first to attach it here.", noVideos = "Record a video in Camera first.", searchGifs = "Search GIPHY", loadMore = "Load More",
moreActions = "More Actions", contactDetails = "Contact Details", contactName = "Name", phoneNumber = "Phone Number",
call = "Call", messageAction = "Message", addContact = "Add Contact", deleteContact = "Delete Contact",
selectedCount = "{count} Selected", deleteSelected = "Delete",
contactSaveFailed = "The contact could not be saved.", contactDeleteFailed = "The contact could not be deleted.",
callFailed = "The call could not be started.",
emoji = "Emoji", voiceMessage = "Audio Message", recordVoice = "Record Audio",
playAudio = "Play Audio", pauseAudio = "Pause Audio",
recording = "Recording", stopAndSend = "Stop and Send", cancelRecording = "Cancel Recording",
sending = "Sending...", delivered = "Delivered", notDelivered = "Not Delivered",
microphoneUnavailable = "The microphone is unavailable.", recordingTooLarge = "The recording is too large.",
noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to send and receive messages.",
noMessages = "No Messages", noMessagesBody = "Start a conversation with a phone number.",
noResults = "No Results", today = "Today", yesterday = "Yesterday",
errors = {
invalid_number = "Enter a valid phone number.", invalid_message = "Enter a message.", invalid_voice = "The audio message is invalid.",
invalid_attachment = "The attachment is invalid.",
media_provider_unconfigured = "Photo uploads are not configured.",
capture_provider_unavailable = "The screenshot resource is unavailable.", capture_failed = "The photo could not be captured.",
video_provider_unavailable = "Video capture requires the screencapture resource.", video_capture_failed = "The video could not be recorded.",
recording_in_progress = "A video is already being recorded.", recording_not_found = "No active video recording was found.",
gif_provider_unconfigured = "GIF search is not configured.", gif_provider_failed = "GIF search is temporarily unavailable.",
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.",
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.",
},
},
phone = {
name = "Phone", recents = "Recents", contacts = "Contacts", keypad = "Keypad",
noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to make calls.",
@@ -402,18 +438,10 @@ Locales["en"] = {
},
},
appStore = {
name = "App Store", eyebrow = "Discover", featured = "Featured", heroTitle = "Apps for every day",
heroBody = "Fresh ideas, built for your life in the city.", get = "GET", open = "OPEN",
searchPlaceholder = "Games, Apps, Stories and More",
communityTitle = "Black Voices and Creators", communityBody = "Apps and games from the community",
playing = "What We're Playing", recommended = "Recommended for You", selected = "Great apps selected by our editors",
tabs = { today = "Today", apps = "Apps", games = "Games", arcade = "Arcade", search = "Search" },
catalog = { orbit = "Plan your day", studio = "Create something new", trail = "Explore nearby", prism = "A colorful puzzle" },
card = {
oneEyebrow = "App of the Day", oneTitle = "A universe in your pocket", oneBody = "Explore something extraordinary today.",
twoEyebrow = "Now Trending", twoTitle = "Turn up your afternoon", twoBody = "Fresh sounds and stories picked for you.",
threeEyebrow = "Editors' Choice", threeTitle = "Play without limits", threeBody = "A new world is waiting.",
},
name = "App Store", get = "GET", open = "OPEN", installing = "Installing",
searchPlaceholder = "Search apps and games", appsTitle = "Built-in Apps", gamesTitle = "Games",
selected = "Apps available for your Sky Phone",
tabs = { apps = "Apps", games = "Games", search = "Search" },
},
settings = {
name = "Settings", searchPlaceholder = "Search", airplaneMode = "Airplane Mode", streamerMode = "Streamer Mode",
+5
View File
@@ -1,4 +1,9 @@
Config.Media = {
GiphyApiKey = "",
GifPageSize = 24,
GifRating = "pg-13",
UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" },
FiveManage = {
ApiKey = "e4UZ9y39JfkxHoZMAgRUVK6KMQsNCKPJ", -- Dashboard -> Tokens -> create a token with Media access.
BaseUrl = "https://api.fivemanage.com/api/v3/file",
+2 -1
View File
@@ -45,11 +45,12 @@ server_scripts {
'source/server/phone.lua',
'source/server/sim.lua',
'source/server/calls.lua',
'source/server/media.lua',
'source/server/messages.lua',
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/marketplace.lua',
'source/server/pages.lua',
'source/server/media.lua',
'source/server/calendar.lua',
'source/server/radio.lua',
}
+17
View File
@@ -70,6 +70,12 @@ local server_callbacks = {
"calls:answer",
"calls:decline",
"calls:hangup",
"messages:conversations",
"messages:thread",
"messages:send",
"messages:media",
"messages:delete",
"messages:gifs",
"gallery:list",
"media:config",
}
@@ -364,6 +370,17 @@ RegisterNetEvent("sky_phone:calls:changed", function()
SendNUIMessage({ type = "calls:changed" })
end)
RegisterNetEvent("sky_phone:messages:changed", function(data)
SendNUIMessage({ type = "messages:changed", data = data })
end)
RegisterNetEvent("sky_phone:messages:new", function(data)
local messages_locale = get_locale().Nui.Apps.messages
data.title = messages_locale.name
data.text = messages_locale.newMessage:gsub("{sender}", tostring(data.sender))
SendNUIMessage({ type = "messages:new", data = data })
end)
RegisterNetEvent("sky_phone:call:incoming", function(data)
notification_focus = true
SetNuiFocus(true, true)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-DT-iq-Xa.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DtjEpgd_.css">
<script type="module" crossorigin src="./assets/sky-index-6ojC5e0v.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-Ch7PsoqS.css">
</head>
<body>
<div id="app"></div>
+32
View File
@@ -351,6 +351,34 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_sms_messages",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "recipient_sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "recipient_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "message_type", type = "ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'" },
{ name = "body", type = "VARCHAR(2000) NOT NULL" },
{ name = "media_payload", type = "MEDIUMTEXT NULL" },
{ name = "media_mime", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "media_duration_ms", type = "INT UNSIGNED NULL" },
{ name = "media_waveform", type = "TEXT NULL" },
{ name = "read_at", type = "DATETIME NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_sms_sender", columns = "(`sender_sim_id`, `created_at`)" },
{ name = "idx_sky_phone_sms_recipient", columns = "(`recipient_sim_id`, `created_at`)" },
},
foreignKeys = {
{ column = "sender_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE SET NULL" },
{ column = "recipient_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_marketplace_listings",
columns = {
@@ -650,6 +678,10 @@ local schema = {
}
Bridge.Database.Migrate("sky_phone", schema)
Bridge.Database.Query([[
ALTER TABLE `sky_phone_sms_messages`
MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "(`sim_id`)", { unique = true })
Bridge.Database.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
Bridge.Database.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
+122 -1
View File
@@ -34,7 +34,7 @@ local function http_request(url, method, body, headers, timeout_ms)
settled = true
request:resolve({ status = 0, body = "request_timeout" })
end)
return Citizen.Await(request)
return Await(request)
end
local function decode_response(response)
@@ -245,6 +245,127 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data)
return { success = true, data = rows }
end)
local function current_messaging_device(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local device = SkyPhone.LoadDevice(session.imei)
if not device then
return nil, { success = false, error = "device_not_found" }
end
if not device.sim_id then
return nil, { success = false, error = "no_sim" }
end
return device
end
local function await_giphy_http(url)
local request = promise.new()
PerformHttpRequest(url, function(status, response_body)
request:resolve({
body = response_body,
status = status,
})
end, "GET", "", {})
return Await(request)
end
local function parse_giphy_json(value)
if type(value) == "table" then
return value
end
if type(value) ~= "string" or value == "" then
return nil
end
return json.decode(value)
end
local function url_encode(value)
return tostring(value):gsub("\n", "\r\n"):gsub("([^%w%-_%.~])", function(character)
return ("%%%02X"):format(character:byte())
end)
end
Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
if not SkyPhone.AllowOperation(source, "gif_search", 30, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local device, error_response = current_messaging_device(source)
if not device then
return error_response
end
local query = type(data.query) == "string" and data.query:match("^%s*(.-)%s*$") or ""
local offset = math.floor(tonumber(data.offset) or 0)
if #query > 60 or offset < 0 or offset > 500 then
return { success = false, error = "invalid_request" }
end
local api_key = Config.Media.GiphyApiKey
if api_key == "" then
return { success = false, error = "gif_provider_unconfigured" }
end
local endpoint = query == "" and "trending" or "search"
local url = ("https://api.giphy.com/v1/gifs/%s?api_key=%s&limit=%s&offset=%s&rating=%s"):format(
endpoint,
url_encode(api_key),
Config.Media.GifPageSize,
offset,
url_encode(Config.Media.GifRating)
)
if query ~= "" then
url = url .. "&q=" .. url_encode(query)
end
local response = await_giphy_http(url)
if response.status == 401 or response.status == 403 then
Bridge.Debug("error", "[sky_phone] GIPHY rejected the configured API key with HTTP %s.", tostring(response.status))
return { success = false, error = "gif_provider_unauthorized" }
end
if response.status == 429 then
Bridge.Debug("error", "[sky_phone] GIPHY rate limit reached.")
return { success = false, error = "gif_provider_rate_limited" }
end
if response.status < 200 or response.status >= 300 then
Bridge.Debug("error", "[sky_phone] GIPHY request failed with HTTP %s.", tostring(response.status))
return { success = false, error = "gif_provider_failed" }
end
local payload = parse_giphy_json(response.body)
if type(payload) ~= "table" or type(payload.data) ~= "table" then
return { success = false, error = "gif_provider_failed" }
end
local results = {}
for _, item in ipairs(payload.data) do
local preview = item.images and (item.images.fixed_width or item.images.downsized)
local original = item.images and item.images.original
if type(item.id) == "string" and type(preview) == "table" and type(preview.url) == "string"
and type(original) == "table" and type(original.url) == "string" then
results[#results + 1] = {
height = tonumber(preview.height) or 200,
id = item.id,
previewUrl = preview.url,
title = type(item.title) == "string" and item.title or "GIF",
url = original.url,
width = tonumber(preview.width) or 200,
}
end
end
local pagination = type(payload.pagination) == "table" and payload.pagination or {}
local page_offset = math.floor(tonumber(pagination.offset) or offset)
local page_count = math.floor(tonumber(pagination.count) or #payload.data)
local total_count = math.floor(tonumber(pagination.total_count) or (page_offset + page_count))
local next_offset = page_offset + page_count
return {
success = true,
data = {
hasMore = page_count > 0 and next_offset < total_count,
nextOffset = next_offset,
results = results,
},
}
end)
Bridge.Callbacks.Register("sky_phone:media:config", function(source)
local owner, error_response = session_owner(source)
if not owner then
+479
View File
@@ -0,0 +1,479 @@
Bridge.Database.AfterMigration("sky_phone", function()
local allowed_voice_mimes = {
["audio/webm"] = true,
["audio/webm;codecs=opus"] = true,
}
local attachment_assets = {
gif = {
celebrate = true,
hearts = true,
party = true,
thumbs_up = true,
wow = true,
},
image = {
["camera-1"] = true,
["camera-2"] = true,
["camera-3"] = true,
["city-lights"] = true,
["desert-road"] = true,
["ocean-air"] = true,
["sunset-drive"] = true,
},
video = {
["city-loop"] = true,
["ocean-loop"] = true,
["sunset-loop"] = true,
},
}
local attachment_mimes = {
gif = "image/gif",
image = "image/jpeg",
video = "video/mp4",
}
local function allowed_media_url(value)
if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then
return false
end
local host = value:lower():match("^https://([^/:?#]+)")
if not host then
return false
end
for _, allowed_host in ipairs(Config.Media.AllowedGifHosts) do
local suffix = "." .. allowed_host
if host == allowed_host or host:sub(-#suffix) == suffix then
return true
end
end
return false
end
local function valid_attachment_asset(message_type, value)
return attachment_assets[message_type][value]
or message_type == "gif" and allowed_media_url(value)
end
local function valid_stored_attachment(message_type, value)
if valid_attachment_asset(message_type, value) then
return true
end
return (message_type == "image" or message_type == "video")
and type(value) == "string"
and #value <= Config.Media.UrlMaxLength
and value:match("^https://") ~= nil
end
local function uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
error("[sky_phone] Database did not generate an SMS UUID.")
end
return rows[1].id
end
local function trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function current_device(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local device = SkyPhone.LoadDevice(session.imei)
if not device then
return nil, { success = false, error = "device_not_found" }
end
if not device.sim_id then
return nil, { success = false, error = "no_sim" }
end
return device
end
local function format_message(row)
row.media_duration_ms = tonumber(row.media_duration_ms)
row.media_asset_id = nil
if row.message_type == "voice" then
local waveform = row.media_waveform and json.decode(row.media_waveform) or nil
if type(waveform) ~= "table" then
error(("[sky_phone] Voice message %s has an invalid waveform payload."):format(tostring(row.id)))
end
row.media_waveform = waveform
row.media_payload = nil
elseif attachment_assets[row.message_type] then
if not valid_stored_attachment(row.message_type, row.media_payload) then
error(("[sky_phone] Message %s has an invalid attachment asset."):format(tostring(row.id)))
end
row.media_asset_id = row.media_payload
row.media_payload = nil
row.media_waveform = nil
if row.message_type ~= "video" then
row.media_duration_ms = nil
end
else
row.media_payload = nil
row.media_duration_ms = nil
row.media_mime = nil
row.media_waveform = nil
end
return row
end
local function validate_attachment(source, device, message_type, data)
if type(data.mediaAssetId) ~= "string" then
return nil
end
local payload = data.mediaAssetId
if not valid_attachment_asset(message_type, payload) then
if message_type == "gif" then
return nil
end
local media_id = tonumber(payload)
if not media_id or media_id < 1 or media_id ~= math.floor(media_id) then
return nil
end
local condition
local params
if device.account_id then
condition = "`account_id` = ?"
params = { media_id, tonumber(device.account_id) }
else
condition = "`account_id` IS NULL AND `device_imei` = ?"
params = { media_id, device.imei }
end
local rows = Bridge.Database.Query(([[
SELECT `url`, `media_type` FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
local media = rows[1]
local expected_type = message_type == "image" and "photo" or "video"
if not media or media.media_type ~= expected_type
or type(media.url) ~= "string"
or #media.url > Config.Media.UrlMaxLength
or not media.url:match("^https://")
then
Bridge.Debug("warn", ("[sky_phone] Rejected unowned SMS media from source %s."):format(tostring(source)))
return nil
end
payload = media.url
end
local duration = nil
if message_type == "video" and data.mediaDurationMs ~= nil then
duration = tonumber(data.mediaDurationMs)
if not duration or duration < 1000 or duration > Config.Messages.VideoMaxDurationMs then
return nil
end
duration = math.floor(duration)
end
return {
duration = duration,
mime = attachment_mimes[message_type],
payload = payload,
}
end
local function validate_voice(data)
if type(data.mediaPayload) ~= "string"
or #data.mediaPayload == 0
or #data.mediaPayload > Config.Messages.VoiceMaxBase64Length
or data.mediaPayload:find("[^A-Za-z0-9+/=]") then
return nil
end
if type(data.mediaMime) ~= "string" or not allowed_voice_mimes[data.mediaMime] then
return nil
end
local duration = tonumber(data.mediaDurationMs)
if not duration or duration < 300 or duration > Config.Messages.VoiceMaxDurationMs then
return nil
end
if type(data.mediaWaveform) ~= "table"
or #data.mediaWaveform < 8
or #data.mediaWaveform > Config.Messages.VoiceWaveformSamples then
return nil
end
local waveform = {}
for index = 1, #data.mediaWaveform do
local sample = tonumber(data.mediaWaveform[index])
if not sample or sample < 0 or sample > 1 then
return nil
end
waveform[index] = math.floor(sample * 1000 + 0.5) / 1000
end
return {
duration = math.floor(duration),
mime = data.mediaMime,
payload = data.mediaPayload,
waveform = json.encode(waveform),
}
end
local function notify_sim(sim_id, event_name, data)
local devices = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_device_data` settings
ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings'
WHERE d.`sim_id` = ?
]], { sim_id })
for _, device in ipairs(devices) do
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local source = tonumber(player_source) or player_source
if SkyPhone.FindDeviceSlots(source, device.imei)[1] then
local payload = {}
for key, value in pairs(data) do
payload[key] = value
end
payload.device = {
imei = device.imei,
name = device.device_name,
settings = device.settings,
}
TriggerClientEvent(event_name, source, payload)
end
end
end
end
Bridge.Callbacks.Register("sky_phone:messages:conversations", function(source)
local device, error_response = current_device(source)
if not device then
return error_response
end
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`, `read_at`, `created_at`
FROM `sky_phone_sms_messages`
WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ?
ORDER BY `created_at` DESC, `id` DESC LIMIT ?
]], { device.sim_id, device.sim_id, Config.Messages.ConversationScanLimit })
local conversations = {}
local ordered = {}
for _, row in ipairs(rows) do
local received = row.recipient_sim_id == device.sim_id
local number = received and row.sender_number or row.recipient_number
local conversation = conversations[number]
if not conversation then
conversation = {
phoneNumber = number,
lastMessage = row.body,
lastMessageAt = row.created_at,
lastMessageType = row.message_type,
unread = 0,
}
conversations[number] = conversation
ordered[#ordered + 1] = conversation
end
if received and not row.read_at then
conversation.unread = conversation.unread + 1
end
end
return { success = true, data = ordered }
end)
Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data)
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
local rows = Bridge.Database.Query([[
SELECT * FROM (
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`, `media_payload`, `media_mime`,
`media_duration_ms`, `media_waveform`, `read_at`, `created_at`,
CASE WHEN `sender_sim_id` = ? THEN 'sent' ELSE 'received' END AS `direction`
FROM `sky_phone_sms_messages`
WHERE (`sender_sim_id` = ? AND `recipient_number` = ?)
OR (`recipient_sim_id` = ? AND `sender_number` = ?)
ORDER BY `created_at` DESC, `id` DESC LIMIT ?
) recent_messages
ORDER BY `created_at` ASC, `id` ASC
]], { device.sim_id, device.sim_id, number, device.sim_id, number, Config.Messages.ThreadPageSize })
for index = 1, #rows do
rows[index] = format_message(rows[index])
end
Bridge.Database.Query([[
UPDATE `sky_phone_sms_messages` SET `read_at` = CURRENT_TIMESTAMP
WHERE `recipient_sim_id` = ? AND `sender_number` = ? AND `read_at` IS NULL
]], { device.sim_id, number })
return { success = true, data = rows }
end)
Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data)
if not SkyPhone.AllowOperation(source, "message_delete", 10, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or type(data.phoneNumbers) ~= "table"
or #data.phoneNumbers == 0 or #data.phoneNumbers > Config.Messages.DeleteBatchSize then
return { success = false, error = "invalid_request" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local numbers = {}
local seen = {}
for index = 1, #data.phoneNumbers do
local number = SkyPhoneSimNumber.Normalize(
data.phoneNumbers[index],
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not number then
return { success = false, error = "invalid_number" }
end
if not seen[number] then
seen[number] = true
numbers[#numbers + 1] = number
end
end
local placeholders = {}
for index = 1, #numbers do
placeholders[index] = "?"
end
local values = { device.sim_id }
for _, number in ipairs(numbers) do
values[#values + 1] = number
end
values[#values + 1] = device.sim_id
for _, number in ipairs(numbers) do
values[#values + 1] = number
end
local list = table.concat(placeholders, ", ")
Bridge.Database.Query(([[
DELETE FROM `sky_phone_sms_messages`
WHERE (`sender_sim_id` = ? AND `recipient_number` IN (%s))
OR (`recipient_sim_id` = ? AND `sender_number` IN (%s))
]]):format(list, list), values)
TriggerClientEvent("sky_phone:messages:changed", source, {})
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:messages:media", function(source, data)
if not SkyPhone.AllowOperation(source, "message_media", Config.Messages.MediaLoadsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or type(data.id) ~= "string" then
return { success = false, error = "invalid_request" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local rows = Bridge.Database.Query([[
SELECT `media_payload`, `media_mime`
FROM `sky_phone_sms_messages`
WHERE `id` = ? AND `message_type` = 'voice'
AND (`sender_sim_id` = ? OR `recipient_sim_id` = ?)
LIMIT 1
]], { data.id, device.sim_id, device.sim_id })
if not rows[1] or type(rows[1].media_payload) ~= "string" then
return { success = false, error = "message_not_found" }
end
return {
success = true,
data = {
mime = rows[1].media_mime,
payload = rows[1].media_payload,
},
}
end)
Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
if not SkyPhone.AllowOperation(source, "message_send", Config.Messages.SendsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
if number == device.phone_number then
return { success = false, error = "self_message" }
end
local message_type = data.messageType or "text"
local body = trim(data.body) or ""
local voice = nil
local attachment = nil
if message_type == "text" then
if body == "" or #body > Config.Messages.BodyMaxLength then
return { success = false, error = "invalid_message" }
end
elseif message_type == "voice" then
voice = validate_voice(data)
if not voice then
return { success = false, error = "invalid_voice" }
end
body = ""
elseif attachment_assets[message_type] then
attachment = validate_attachment(source, device, message_type, data)
if not attachment then
return { success = false, error = "invalid_attachment" }
end
body = ""
else
return { success = false, error = "invalid_request" }
end
local recipients = Bridge.Database.Query(
"SELECT `id`, `phone_number` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1",
{ number }
)
local recipient = recipients[1]
if not recipient then
return { success = false, error = "recipient_not_found" }
end
local id = uuid()
Bridge.Database.Query([[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, `message_type`,
`body`, `media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
id,
device.sim_id,
recipient.id,
device.phone_number,
number,
message_type,
body,
voice and voice.payload or attachment and attachment.payload or nil,
voice and voice.mime or attachment and attachment.mime or nil,
voice and voice.duration or attachment and attachment.duration or nil,
voice and voice.waveform or nil,
})
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`, `media_payload`, `media_mime`,
`media_duration_ms`, `media_waveform`, `read_at`, `created_at`, 'sent' AS `direction`
FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1
]], { id })
local message = format_message(rows[1])
TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number })
notify_sim(recipient.id, "sky_phone:messages:new", {
message = message,
phoneNumber = device.phone_number,
sender = device.phone_number,
voice = message_type == "voice",
})
return { success = true, data = message }
end)
end)
+21
View File
@@ -177,6 +177,27 @@ CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` (
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_sms_messages` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`sender_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`recipient_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`sender_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`recipient_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`message_type` ENUM('text', 'voice', 'image', 'gif', 'video') NOT NULL DEFAULT 'text',
`body` VARCHAR(2000) NOT NULL,
`media_payload` MEDIUMTEXT NULL,
`media_mime` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NULL,
`media_duration_ms` INT UNSIGNED NULL,
`media_waveform` TEXT NULL,
`read_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_sms_sender` (`sender_sim_id`, `created_at`),
KEY `idx_sky_phone_sms_recipient` (`recipient_sim_id`, `created_at`),
FOREIGN KEY (`sender_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL,
FOREIGN KEY (`recipient_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_calendar_events` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`account_id` BIGINT UNSIGNED NOT NULL,