mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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
@@ -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]) => {
|
||||
|
||||
+1911
-33
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
: []
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user