feat(messages): overhaul messaging and contact flows

This commit is contained in:
Dominik
2026-08-16 23:37:40 +02:00
parent 49bd7f358f
commit 1c085f975f
36 changed files with 1937 additions and 478 deletions
+21 -2
View File
@@ -6401,7 +6401,8 @@ button {
font-size: 12px;
}
.messages-media-picker__gifs button {
min-height: 92px;
min-height: 0;
align-self: start;
overflow: hidden;
padding: 0;
background: #e9e9ed;
@@ -6409,7 +6410,8 @@ button {
.messages-media-picker__gifs button img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
object-fit: contain;
}
.messages-media-picker__gifs .messages-gif-more {
min-height: 36px;
@@ -6597,6 +6599,23 @@ button {
.messages-attachment--gif {
background: #e9e9ed;
}
.messages-attachment--gif.messages-attachment--remote {
width: fit-content;
max-width: 205px;
height: auto;
max-height: 240px;
background: transparent;
}
.messages-attachment--gif.messages-attachment--remote img {
position: static;
inset: auto;
width: auto;
max-width: 205px;
height: auto;
max-height: 240px;
display: block;
object-fit: contain;
}
/* Konsta's k-app dark state drives the complete iOS Messages palette. */
.phone-app.dark .messages-page {
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const component = readFileSync(
new URL('./MessageAttachmentBubble.vue', import.meta.url),
'utf8',
)
const styles = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
describe('MessageAttachmentBubble contract', () => {
it('renders an optional caption below media', () => {
expect(component).toContain('message.body?.trim()')
expect(component).toContain('messages-attachment-caption')
})
it('gives image-only messages an accessible name', () => {
expect(component).toContain('role="img"')
expect(component).toContain('Apps.photos.photoAlt')
})
it('keeps remote GIFs proportional instead of cover-cropping them', () => {
expect(component).toContain('messages-attachment--remote')
expect(styles).toMatch(
/\.messages-attachment--gif\.messages-attachment--remote img\s*\{[^}]*width:\s*auto;[^}]*height:\s*auto;[^}]*object-fit:\s*contain;/s,
)
})
})
@@ -6,6 +6,7 @@ import { usePhoneStore } from '@/stores/phone'
import type { SmsMessageType } from '@/types/messages'
type MessageAttachment = {
body?: string
media_asset_id: string | null
media_duration_ms: number | null
message_type: SmsMessageType
@@ -82,6 +83,8 @@ function durationLabel(milliseconds: number | null): string {
<div
v-if="message.message_type === 'image'"
class="messages-attachment messages-attachment--image"
role="img"
:aria-label="phone.t('Apps.photos.photoAlt')"
:style="{ background }"
>
<img
@@ -121,18 +124,18 @@ function durationLabel(milliseconds: number | null): string {
><TriangleAlert v-if="playbackFailed" :size="22" /><Pause
v-else-if="playing"
:size="22"
fill="currentColor"
/><Play
v-else
:size="22"
fill="currentColor"
fill="currentColor" /><Play v-else :size="22" fill="currentColor"
/></span>
<small v-if="playbackFailed">{{
phone.t('Apps.photos.errors.unsupported')
}}</small>
<small v-else>{{ durationLabel(message.media_duration_ms) }}</small>
</button>
<div v-else class="messages-attachment messages-attachment--gif">
<div
v-else
class="messages-attachment messages-attachment--gif"
:class="{ 'messages-attachment--remote': mediaUrl }"
>
<img
v-if="mediaUrl"
:src="mediaUrl"
@@ -145,4 +148,16 @@ function durationLabel(milliseconds: number | null): string {
<strong>{{ gif.label }}</strong>
</template>
</div>
<p v-if="message.body?.trim()" class="messages-attachment-caption">
{{ message.body }}
</p>
</template>
<style scoped>
.messages-attachment-caption {
max-width: 205px;
margin: 7px 1px 1px;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
</style>
@@ -0,0 +1,30 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./VoiceMessageBubble.vue', import.meta.url),
'utf8',
)
describe('VoiceMessageBubble contract', () => {
it('uses the central accessible range to seek protected audio', () => {
expect(source).toContain("import { SkyRange } from '@/ui'")
expect(source).toContain('<SkyRange')
expect(source).toContain("phone.t('Apps.messages.seekAudio')")
expect(source).toContain('@input="seekPlayback"')
expect(source).toContain('audio.value.currentTime = nextTime')
expect(source).toContain('await ensureSource()')
expect(source).toContain(
'let sourceLoadPromise: Promise<boolean> | undefined',
)
expect(source).toContain('return sourceLoadPromise')
expect(source).toContain(':aria-value-text="seekValueText"')
})
it('fits every stored sample into the available waveform width', () => {
expect(source).toMatch(
/\.voice-message__waveform i\s*\{[^}]*min-width:\s*1px;[^}]*flex:\s*1 1 1px;/s,
)
})
})
+118 -20
View File
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
import type { SmsMessage } from '@/types/messages'
import { SkyRange } from '@/ui'
const props = defineProps<{ message: SmsMessage }>()
const phone = usePhoneStore()
@@ -14,13 +15,20 @@ 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 metadataDuration = ref(0)
let sourceLoadPromise: Promise<boolean> | undefined
const duration = computed(
() => metadataDuration.value || (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),
formatDuration(
playing.value || currentTime.value > 0 ? currentTime.value : duration.value,
),
)
const seekValueText = computed(() => formatDuration(currentTime.value))
const source = computed(() => messages.mediaSources[props.message.id] ?? '')
function formatDuration(seconds: number): string {
@@ -28,6 +36,26 @@ function formatDuration(seconds: number): string {
return `${Math.floor(rounded / 60)}:${String(rounded % 60).padStart(2, '0')}`
}
async function ensureSource(): Promise<boolean> {
if (source.value) return Boolean(audio.value)
if (!sourceLoadPromise) {
loading.value = true
sourceLoadPromise = (async () => {
const loaded = await messages.loadMedia(props.message.id)
if (!loaded) {
failed.value = true
return false
}
await nextTick()
return Boolean(audio.value && source.value)
})().finally(() => {
loading.value = false
sourceLoadPromise = undefined
})
}
return sourceLoadPromise
}
async function togglePlayback(): Promise<void> {
if (loading.value) return
failed.value = false
@@ -35,24 +63,27 @@ async function togglePlayback(): Promise<void> {
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()
}
if (!(await ensureSource())) return
try {
await audio.value?.play()
} catch (error) {
failed.value = true
console.error(`[Messages] Could not play audio message ${props.message.id}:`, error)
console.error(
`[Messages] Could not play audio message ${props.message.id}:`,
error,
)
}
}
async function seekPlayback(event: Event): Promise<void> {
const value = Number((event.target as HTMLInputElement).value)
failed.value = false
if (!Number.isFinite(value) || !(await ensureSource()) || !audio.value) return
const nextTime = Math.min(duration.value, Math.max(0, value))
audio.value.currentTime = nextTime
currentTime.value = nextTime
}
function updateProgress(): void {
currentTime.value = audio.value?.currentTime ?? 0
}
@@ -62,6 +93,11 @@ function finishPlayback(): void {
currentTime.value = 0
}
function updateDuration(): void {
const value = audio.value?.duration ?? 0
if (Number.isFinite(value) && value > 0) metadataDuration.value = value
}
onBeforeUnmount(() => audio.value?.pause())
</script>
@@ -71,7 +107,11 @@ onBeforeUnmount(() => audio.value?.pause())
type="button"
class="voice-message__play"
:disabled="loading"
:aria-label="phone.t(playing ? 'Apps.messages.pauseAudio' : 'Apps.messages.playAudio')"
:aria-label="
phone.t(
playing ? 'Apps.messages.pauseAudio' : 'Apps.messages.playAudio',
)
"
@click="togglePlayback"
>
<span v-if="loading" class="voice-message__loader" />
@@ -79,12 +119,29 @@ onBeforeUnmount(() => audio.value?.pause())
<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 class="voice-message__timeline">
<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>
<SkyRange
class="voice-message__range"
:model-value="currentTime"
:min="0"
:max="Math.max(0.1, duration)"
:step="0.1"
:aria-label="phone.t('Apps.messages.seekAudio')"
:aria-value-text="seekValueText"
@input="seekPlayback"
/>
</div>
<span>{{ displayTime }}</span>
@@ -93,6 +150,7 @@ onBeforeUnmount(() => audio.value?.pause())
ref="audio"
:src="source"
preload="metadata"
@loadedmetadata="updateDuration"
@play="playing = true"
@pause="playing = false"
@timeupdate="updateProgress"
@@ -101,3 +159,43 @@ onBeforeUnmount(() => audio.value?.pause())
/>
</div>
</template>
<style scoped>
.voice-message__timeline {
position: relative;
min-width: 0;
height: 24px;
}
.voice-message__waveform {
min-width: 0;
overflow: hidden;
gap: 1px;
pointer-events: none;
}
.voice-message__waveform i {
width: auto;
min-width: 1px;
flex: 1 1 1px;
}
.voice-message__range {
position: absolute;
inset: 0;
cursor: pointer;
}
.voice-message__range :deep(.sky-range__input) {
width: 100%;
height: 24px;
opacity: 0;
cursor: pointer;
}
.voice-message__timeline:focus-within {
border-radius: var(--sky-radius-pill);
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style>
@@ -0,0 +1,84 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const callsServer = readFileSync(
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
'utf8',
)
const messagesServer = readFileSync(
new URL('../../sky_phone/source/server/messages.lua', import.meta.url),
'utf8',
)
const migration = readFileSync(
new URL('../../sky_phone/source/server/db_migrate.lua', import.meta.url),
'utf8',
)
const migrationFramework = readFileSync(
new URL(
'../../sky_phone/source/bridge/server/migrations.lua',
import.meta.url,
),
'utf8',
)
const install = readFileSync(
new URL('../../sky_phone/sql/install.sql', import.meta.url),
'utf8',
)
describe('shared contact block contract', () => {
it('stores the current SIM as blocker and the selected contact as blocked', () => {
const callback = callsServer.slice(
callsServer.indexOf('Bridge.Callbacks.Register("sky_phone:calls:block"'),
)
expect(callback).toContain('INSERT IGNORE INTO `sky_phone_call_blocks`')
expect(callback).toContain('{ scope.device.sim_id, rows[1].id }')
})
it('rejects SMS before insertion when the recipient blocked the sender', () => {
const callback = messagesServer.slice(
messagesServer.indexOf(
'Bridge.Callbacks.Register("sky_phone:messages:send"',
),
)
const blockCheck = callback.indexOf('FROM `sky_phone_call_blocks`')
const insert = callback.indexOf('INSERT INTO `sky_phone_sms_messages`')
expect(callback).toContain(
'WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ?',
)
expect(callback).toContain('{ recipient.id, device.sim_id }')
expect(callback).toContain('return { success = false, error = "blocked" }')
expect(blockCheck).toBeGreaterThanOrEqual(0)
expect(insert).toBeGreaterThan(blockCheck)
})
it('creates the block table during runtime upgrades with matching constraints', () => {
const table = migration.slice(
migration.indexOf('name = "sky_phone_call_blocks"'),
migration.indexOf('name = "sky_phone_call_entries"'),
)
expect(table).toContain('name = "blocker_sim_id"')
expect(table).toContain('name = "blocked_sim_id"')
expect(table).toContain(
'primaryKey = { "blocker_sim_id", "blocked_sim_id" }',
)
expect(table).toContain('idx_sky_phone_call_blocks_blocked')
expect(table.match(/ON DELETE CASCADE/g)).toHaveLength(2)
expect(migrationFramework).toContain('if type(primary_key) == "table"')
expect(migrationFramework).toContain('table.concat(quoted_columns, ", ")')
})
})
describe('SMS batch ordering contract', () => {
it('persists sub-second timestamps before applying the stable id tie-breaker', () => {
expect(install).toMatch(
/CREATE TABLE IF NOT EXISTS `sky_phone_sms_messages`[\s\S]*`created_at` DATETIME\(6\) NOT NULL DEFAULT CURRENT_TIMESTAMP\(6\)/,
)
expect(migration).toMatch(
/ALTER TABLE `sky_phone_sms_messages`[\s\S]*MODIFY COLUMN `created_at` DATETIME\(6\) NOT NULL DEFAULT CURRENT_TIMESTAMP\(6\)/,
)
expect(messagesServer).toContain('ORDER BY `created_at` ASC, `id` ASC')
})
})
+34 -2
View File
@@ -167,6 +167,39 @@ describe('calls store', () => {
expect(calls.contacts[0]?.favorite).toBe(true)
})
it('sends a contact email and refreshes the contact list after saving', async () => {
const savedContact = {
email: 'alex.rivera@ifruit.com',
id: 'contact-alex',
name: 'Alex Rivera',
organization: 'Maze Bank',
phone_number: '5551110001',
}
vi.mocked(nuiCall)
.mockResolvedValueOnce({ success: true, data: savedContact })
.mockResolvedValueOnce({ success: true, data: [savedContact] })
const calls = useCallsStore()
const response = await calls.saveContact({
email: 'alex.rivera@ifruit.com',
id: 'contact-alex',
name: 'Alex Rivera',
organization: 'Maze Bank',
phoneNumber: '5551110001',
})
expect(response).toEqual({ success: true, data: savedContact })
expect(nuiCall).toHaveBeenNthCalledWith(1, 'contacts:save', {
email: 'alex.rivera@ifruit.com',
id: 'contact-alex',
name: 'Alex Rivera',
organization: 'Maze Bank',
phoneNumber: '5551110001',
})
expect(nuiCall).toHaveBeenNthCalledWith(2, 'contacts:list')
expect(calls.contacts[0]?.email).toBe('alex.rivera@ifruit.com')
})
it('keeps configured company branding on system contacts', async () => {
vi.mocked(nuiCall).mockResolvedValueOnce({
success: true,
@@ -189,8 +222,7 @@ describe('calls store', () => {
await calls.loadContacts()
expect(calls.contacts[0]).toMatchObject({
avatar_url:
'https://picsum.photos/seed/companies-police-logo/180/180',
avatar_url: 'https://picsum.photos/seed/companies-police-logo/180/180',
organization: 'Los Santos Police Department',
source: 'company',
})
+1
View File
@@ -36,6 +36,7 @@ export const useCallsStore = defineStore('calls', () => {
async function saveContact(contact: {
avatarMediaId?: number | null
email?: string
id?: string
name: string
notes?: string
+66 -4
View File
@@ -35,10 +35,14 @@ describe('messages store', () => {
})
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
})
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 })
@@ -78,6 +82,64 @@ describe('messages store', () => {
expect(messages.messages[0].delivery_status).toBe('failed')
})
it('discards a failed optimistic attachment when its preview remains retryable', async () => {
mockNuiCall
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ error: 'request_failed', success: false })
const messages = useMessagesStore()
await messages.openThread('4205550196')
await messages.send(
{
body: 'Retry this photo',
mediaAssetId: '17',
messageType: 'image',
},
{ discardFailedOptimistic: true },
)
expect(messages.messages).toEqual([])
})
it('keeps a photo caption on the optimistic media message', async () => {
const serverMessage: SmsMessage = {
...sentMessage('photo-server-id'),
body: 'Look at this',
media_asset_id: 'https://media.example/photo.jpg',
media_mime: 'image/jpeg',
message_type: 'image',
}
mockNuiCall
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ data: [], success: true })
.mockResolvedValueOnce({ data: serverMessage, success: true })
.mockResolvedValueOnce({ data: [], success: true })
const messages = useMessagesStore()
await messages.openThread('4205550196')
const sending = messages.send({
body: ' Look at this ',
mediaAssetId: '17',
messageType: 'image',
})
expect(messages.messages[0]).toMatchObject({
body: 'Look at this',
delivery_status: 'sending',
media_asset_id: '17',
message_type: 'image',
})
await sending
expect(mockNuiCall).toHaveBeenNthCalledWith(3, 'messages:send', {
body: ' Look at this ',
mediaAssetId: '17',
messageType: 'image',
phoneNumber: '4205550196',
})
})
it('shows a shared contact immediately and sends only its id', async () => {
const contact = {
avatar_url: 'https://picsum.photos/seed/shared-alex/240/240',
+23 -10
View File
@@ -11,6 +11,10 @@ import type {
import { sortConversationsByRecency } from '@/utils/messages'
import { nuiCall, type NuiResponse } from '@/utils/nui'
type SendOptions = {
discardFailedOptimistic?: boolean
}
export const useMessagesStore = defineStore('messages', () => {
const conversations = ref<SmsConversation[]>([])
const messages = ref<SmsMessage[]>([])
@@ -30,8 +34,7 @@ export const useMessagesStore = defineStore('messages', () => {
phoneNumber: String(conversation.phoneNumber),
})),
)
}
else if (!response.success) conversations.value = []
} else if (!response.success) conversations.value = []
return response.success
}
@@ -45,8 +48,7 @@ export const useMessagesStore = defineStore('messages', () => {
activeNumber.value = phoneNumber
messages.value = response.data.map((message) => ({
...message,
delivery_status:
message.direction === 'sent' ? 'delivered' : undefined,
delivery_status: message.direction === 'sent' ? 'delivered' : undefined,
recipient_number: String(message.recipient_number),
sender_number: String(message.sender_number),
}))
@@ -56,13 +58,18 @@ export const useMessagesStore = defineStore('messages', () => {
async function send(
outgoing: SmsOutgoingMessage,
options: SendOptions = {},
): 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.messageType === 'share'
? outgoing.body?.trim() ?? ''
outgoing.messageType === 'text' ||
outgoing.messageType === 'share' ||
outgoing.messageType === 'image' ||
outgoing.messageType === 'gif' ||
outgoing.messageType === 'video'
? (outgoing.body?.trim() ?? '')
: '',
client_id: clientId,
contact: outgoing.messageType === 'contact' ? outgoing.contact : null,
@@ -78,10 +85,9 @@ export const useMessagesStore = defineStore('messages', () => {
: null,
media_duration_ms:
outgoing.messageType === 'voice' || outgoing.messageType === 'video'
? outgoing.mediaDurationMs ?? null
? (outgoing.mediaDurationMs ?? null)
: null,
media_mime:
outgoing.messageType === 'voice' ? outgoing.mediaMime : null,
media_mime: outgoing.messageType === 'voice' ? outgoing.mediaMime : null,
media_waveform:
outgoing.messageType === 'voice' ? outgoing.mediaWaveform : null,
message_type: outgoing.messageType,
@@ -113,7 +119,14 @@ export const useMessagesStore = defineStore('messages', () => {
(message) => message.client_id === clientId,
)
if (!response.success || !response.data) {
if (index >= 0) messages.value[index].delivery_status = 'failed'
if (index >= 0) {
if (options.discardFailedOptimistic) {
messages.value.splice(index, 1)
delete mediaSources.value[clientId]
} else {
messages.value[index].delivery_status = 'failed'
}
}
return response
}
+16
View File
@@ -55,4 +55,20 @@ describe('phone locale fallback', () => {
'Character name',
)
})
it('keeps Messages media controls translated with a partial server locale', () => {
const phone = usePhoneStore()
phone.open({ locales: { Apps: { messages: { name: 'Messages' } } } })
expect(phone.t('Apps.messages.attachmentPreview')).toBe(
'Selected attachments',
)
expect(phone.t('Apps.messages.attachmentLimit', { count: '6' })).toBe(
'You can attach up to 6 photos or videos.',
)
expect(phone.t('Apps.messages.removeAttachment', { number: '2' })).toBe(
'Remove attachment 2',
)
expect(phone.t('Apps.messages.seekAudio')).toBe('Seek Audio')
})
})
+19
View File
@@ -1834,12 +1834,29 @@ const defaultLocales: LocaleTree = {
loadMore: 'Load More',
retryGifs: 'Try Again',
moreActions: 'More Actions',
inboxActions: 'Conversation Actions',
sortLabel: 'Conversation Sort',
sortNewest: 'Newest First',
sortOldest: 'Oldest First',
unreadConversation: '{name}, {count} unread messages',
attachmentPreview: 'Selected attachments',
attachmentLimit: 'You can attach up to {count} photos or videos.',
removeAttachment: 'Remove attachment {number}',
contactDetails: 'Contact Details',
contactName: 'Name',
phoneNumber: 'Phone Number',
company: 'Company',
contactActions: 'Contact Actions',
call: 'Call',
messageAction: 'Message',
addContact: 'Add Contact',
showInContacts: 'Show in Contacts',
blockContact: 'Block Contact',
blockContactTitle: 'Block this contact?',
blockContactBody:
'{name} will no longer be able to call or message this SIM.',
blockContactFailed: 'The contact could not be blocked.',
contactBlocked: 'Contact blocked.',
deleteContact: 'Delete Contact',
selectedCount: '{count} Selected',
deleteSelected: 'Delete',
@@ -1851,6 +1868,7 @@ const defaultLocales: LocaleTree = {
recordVoice: 'Record Audio',
playAudio: 'Play Audio',
pauseAudio: 'Pause Audio',
seekAudio: 'Seek Audio',
recording: 'Recording',
stopAndSend: 'Stop and Send',
cancelRecording: 'Cancel Recording',
@@ -1887,6 +1905,7 @@ const defaultLocales: LocaleTree = {
gif_provider_failed: 'GIF search is temporarily unavailable.',
self_message: 'You cannot message your own number.',
recipient_not_found: 'That number is unavailable.',
blocked: 'This contact has blocked calls and messages from your SIM.',
messaging_unavailable: 'This company contact does not accept messages.',
no_sim: 'This phone has no SIM card.',
rate_limited: 'Too many messages. Try again in a minute.',
+1
View File
@@ -61,6 +61,7 @@ export type SmsOutgoingMessage =
messageType: 'voice'
}
| {
body?: string
mediaAssetId: string
mediaDurationMs?: number
messageType: SmsAttachmentType
+1
View File
@@ -15,6 +15,7 @@ export type PhoneContact = {
canMessage?: boolean
companyId?: string
created_at?: string
email?: string | null
favorite?: boolean | number
id: string
name: string
+4 -3
View File
@@ -23,6 +23,9 @@ describe('SkyDropdown', () => {
it('supports checked, submenu, destructive, disabled, and divided items', () => {
expect(component).toContain("'menuitemradio'")
expect(component).toContain(':aria-checked=')
expect(component).toContain("section.group ? 'group' : 'presentation'")
expect(component).toContain('section.group ? section.label : undefined')
expect(component).toContain('groupLabel?: string')
expect(component).toContain(':aria-haspopup=')
expect(component).toContain('item.destructive')
expect(component).toContain('item.disabled')
@@ -35,9 +38,7 @@ describe('SkyDropdown', () => {
expect(overlays).toMatch(
/\.sky-dropdown__item\s*\{[^}]*min-height:\s*var\(--sky-touch-target, 44px\)/s,
)
expect(overlays).toMatch(
/\.sky-dropdown__menu\s*\{[^}]*padding:\s*6px;/s,
)
expect(overlays).toMatch(/\.sky-dropdown__menu\s*\{[^}]*padding:\s*6px;/s)
expect(overlays).toMatch(
/\.sky-dropdown__item\s*\{[^}]*border-radius:\s*12px;/s,
)
+64 -27
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Check, ChevronRight } from 'lucide-vue-next'
import { computed } from 'vue'
import SkyPopover from './SkyPopover.vue'
@@ -9,13 +10,15 @@ interface SkyDropdownItem {
checked?: boolean
destructive?: boolean
disabled?: boolean
group?: string
groupLabel?: string
id: string
label: string
separatorBefore?: boolean
submenu?: boolean
}
withDefaults(
const props = withDefaults(
defineProps<{
items: readonly SkyDropdownItem[]
label: string
@@ -34,6 +37,33 @@ const emit = defineEmits<{
positionerror: [reason: string]
select: [id: string, event: MouseEvent]
}>()
const menuSections = computed(() => {
const sections: Array<{
group: string | null
items: SkyDropdownItem[]
key: string
label?: string
}> = []
props.items.forEach((item) => {
const group = item.group ?? null
const previous = sections[sections.length - 1]
if (!previous || previous.group !== group) {
sections.push({
group,
items: [item],
key: `${group ?? 'items'}:${sections.length}`,
label: item.groupLabel,
})
return
}
previous.items.push(item)
})
return sections
})
</script>
<template>
@@ -50,33 +80,40 @@ const emit = defineEmits<{
@positionerror="emit('positionerror', $event)"
>
<div class="sky-dropdown__menu">
<button
v-for="item in items"
:key="item.id"
class="sky-dropdown__item"
:class="{
'sky-dropdown__item--destructive': item.destructive,
'sky-dropdown__item--separator': item.separatorBefore,
}"
:aria-checked="item.checked === undefined ? undefined : item.checked"
:aria-haspopup="item.submenu ? 'menu' : undefined"
:disabled="item.disabled"
:role="item.checked === undefined ? 'menuitem' : 'menuitemradio'"
type="button"
@click="emit('select', item.id, $event)"
<div
v-for="section in menuSections"
:key="section.key"
:aria-label="section.group ? section.label : undefined"
:role="section.group ? 'group' : 'presentation'"
>
<span class="sky-dropdown__indicator" aria-hidden="true">
<Check v-if="item.checked" :size="21" :stroke-width="2.5" />
</span>
<span class="sky-dropdown__label">{{ item.label }}</span>
<ChevronRight
v-if="item.submenu"
class="sky-dropdown__chevron"
:size="21"
:stroke-width="2.5"
aria-hidden="true"
/>
</button>
<button
v-for="item in section.items"
:key="item.id"
class="sky-dropdown__item"
:class="{
'sky-dropdown__item--destructive': item.destructive,
'sky-dropdown__item--separator': item.separatorBefore,
}"
:aria-checked="item.checked === undefined ? undefined : item.checked"
:aria-haspopup="item.submenu ? 'menu' : undefined"
:disabled="item.disabled"
:role="item.checked === undefined ? 'menuitem' : 'menuitemradio'"
type="button"
@click="emit('select', item.id, $event)"
>
<span class="sky-dropdown__indicator" aria-hidden="true">
<Check v-if="item.checked" :size="21" :stroke-width="2.5" />
</span>
<span class="sky-dropdown__label">{{ item.label }}</span>
<ChevronRight
v-if="item.submenu"
class="sky-dropdown__chevron"
:size="21"
:stroke-width="2.5"
aria-hidden="true"
/>
</button>
</div>
</div>
</SkyPopover>
</template>
+6
View File
@@ -9,6 +9,12 @@ describe('database dates', () => {
)
})
it('parses fractional SQL timestamps used to order media batches', () => {
expect(parseDatabaseDate('2026-08-06 17:30:00.123456').getTime()).toBe(
new Date('2026-08-06T17:30:00.123').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(
+2 -1
View File
@@ -6,5 +6,6 @@ export function parseDatabaseDate(value: DatabaseDateValue): Date {
return new Date(timestamp)
}
return new Date(value.replace(' ', 'T'))
const normalized = value.replace(' ', 'T').replace(/(\.\d{3})\d+$/, '$1')
return new Date(normalized)
}
+13
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
bindMediaRecorderError,
compressWaveformSamples,
setBoundedMapEntry,
stopMediaRecorder,
} from '@/utils/mediaRecorder'
@@ -63,4 +64,16 @@ describe('media recorder lifecycle', () => {
['third', 3],
])
})
it('normalizes short recordings to the requested waveform width', () => {
const waveform = compressWaveformSamples([0.1, 0.45, 0.9], 48)
expect(waveform).toHaveLength(48)
expect(waveform.every((sample) => sample >= 0.08 && sample <= 1)).toBe(true)
expect(waveform).toContain(0.9)
})
it('fills silent recordings with a stable minimum level', () => {
expect(compressWaveformSamples([], 8)).toEqual(Array(8).fill(0.08))
})
})
+25 -1
View File
@@ -18,7 +18,9 @@ export function bindMediaRecorderError(
}
}
export async function stopMediaRecorder(recorder: MediaRecorder): Promise<void> {
export async function stopMediaRecorder(
recorder: MediaRecorder,
): Promise<void> {
if (recorder.state === 'inactive') return
await new Promise<void>((resolve, reject) => {
@@ -60,3 +62,25 @@ export function setBoundedMapEntry<Key, Value>(
entries.delete(oldest.value)
}
}
export function compressWaveformSamples(
samples: number[],
sampleCount: number,
minimum = 0.08,
): number[] {
const count = Math.max(1, Math.floor(sampleCount))
const floor = Math.max(0, Math.min(1, minimum))
if (!samples.length) return Array(count).fill(floor)
const bucketSize = samples.length / count
const fallback = samples.at(-1) ?? floor
return Array.from({ length: count }, (_, index) => {
const start = Math.floor(index * bucketSize)
const end = Math.max(start + 1, Math.floor((index + 1) * bucketSize))
const bucket = samples.slice(start, end)
const average = bucket.length
? bucket.reduce((total, sample) => total + sample, 0) / bucket.length
: fallback
return Math.max(floor, Math.min(1, average))
})
}
@@ -61,11 +61,17 @@ describe('GalleryApp import action', () => {
expect(source).toContain("id: 'sort-oldest'")
expect(source).toContain("id: 'show-all'")
expect(source).toContain("id: 'show-favorites'")
expect(source).toContain("group: 'sort'")
expect(source).toContain("group: 'show'")
expect(source).toContain("phone.t('Apps.photos.sorting.title')")
expect(source).toContain("phone.t('Apps.photos.sorting.show')")
expect(source).toContain('orderMedia(media.value, sortOrder.value)')
})
it('starts with the newest media at the bottom-right and loads older media above', () => {
expect(source).toContain("const sortOrder = ref<GallerySortOrder>('newest')")
expect(source).toContain(
"const sortOrder = ref<GallerySortOrder>('newest')",
)
expect(source).toContain(
'galleryContent.value.scrollTop = galleryContent.value.scrollHeight',
)
@@ -81,9 +87,9 @@ describe('GalleryApp import action', () => {
expect(source).toContain('v-if="selectionMode"')
expect(source).toContain('selectedCountText')
expect(source).toContain('shareSelection')
expect(source.match(/<SkyToolbarPane class="gallery-selection-action">/g)).toHaveLength(
2,
)
expect(
source.match(/<SkyToolbarPane class="gallery-selection-action">/g),
).toHaveLength(2)
expect(source).toContain('<div class="gallery-selection-actions">')
expect(source).toMatch(
/\.gallery-selection-actions\s*\{[^}]*display:\s*flex;[^}]*gap:\s*var\(--sky-space-2\);/s,
@@ -133,7 +139,9 @@ describe('GalleryApp import action', () => {
expect(source).toContain(
'galleryContent.value.scrollTop = galleryReturnScrollTop',
)
expect(source).toMatch(/async function closeMedia[\s\S]*?await nextTick\(\)/)
expect(source).toMatch(
/async function closeMedia[\s\S]*?await nextTick\(\)/,
)
expect(source).toContain('@wheel.prevent="zoomImageWithWheel"')
expect(source).toContain('media.clientWidth / bounds.width')
expect(source).toContain('media.clientHeight / bounds.height')
+10 -4
View File
@@ -144,22 +144,30 @@ const orderedMedia = computed(() => orderMedia(media.value, sortOrder.value))
const sortMenuItems = computed(() => [
{
checked: sortOrder.value === 'newest',
group: 'sort',
groupLabel: phone.t('Apps.photos.sorting.title'),
id: 'sort-newest',
label: phone.t('Apps.photos.sorting.newestFirst'),
},
{
checked: sortOrder.value === 'oldest',
group: 'sort',
groupLabel: phone.t('Apps.photos.sorting.title'),
id: 'sort-oldest',
label: phone.t('Apps.photos.sorting.oldestFirst'),
},
{
checked: !favoritesOnly.value,
group: 'show',
groupLabel: phone.t('Apps.photos.sorting.show'),
id: 'show-all',
label: phone.t('Apps.photos.sorting.allItems'),
separatorBefore: true,
},
{
checked: favoritesOnly.value,
group: 'show',
groupLabel: phone.t('Apps.photos.sorting.show'),
id: 'show-favorites',
label: phone.t('Apps.photos.sorting.favorites'),
},
@@ -1846,13 +1854,11 @@ onBeforeUnmount(() => {
transition: transform var(--sky-transition-fast, 100ms) ease;
}
@media (hover: hover) {
.gallery-detail-navbar
:deep(.gallery-detail-back:hover:not(:disabled)) {
.gallery-detail-navbar :deep(.gallery-detail-back:hover:not(:disabled)) {
background: rgba(255, 255, 255, 0.16);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.gallery-detail-navbar
:deep(.gallery-detail-back:hover:not(:disabled) svg) {
.gallery-detail-navbar :deep(.gallery-detail-back:hover:not(:disabled) svg) {
transform: translateX(-2px);
}
}
@@ -12,7 +12,10 @@ const mailServerSource = readFileSync(
'utf8',
)
const migrationSource = readFileSync(
new URL('../../../../sky_phone/source/server/db_migrate.lua', import.meta.url),
new URL(
'../../../../sky_phone/source/server/db_migrate.lua',
import.meta.url,
),
'utf8',
)
const clientSource = readFileSync(
@@ -52,9 +55,7 @@ describe('MailApp Sky UI contract', () => {
})
it('provides a Sky UI modal to create and open custom mailboxes', () => {
const mailboxStart = source.indexOf(
'class="mail-modal mail-mailbox-modal"',
)
const mailboxStart = source.indexOf('class="mail-modal mail-mailbox-modal"')
const mailboxEnd = source.indexOf('</sky-sheet>', mailboxStart)
const mailboxModal = source.slice(mailboxStart, mailboxEnd)
@@ -67,19 +68,19 @@ describe('MailApp Sky UI contract', () => {
expect(source).toContain('v-model="mailboxName"')
expect(source).toContain("phone.t('Apps.mail.mailboxLocation')")
expect(source).toContain('@click="createMailbox"')
expect(source).toContain('@click="openFolder(mailboxFolderKey(mailbox.id))"')
expect(source).toContain(
'@click="openFolder(mailboxFolderKey(mailbox.id))"',
)
expect(source).toContain('@click="moveMessageToMailbox(mailbox)"')
expect(source).toContain('@click="moveMessageToDefaultMailbox"')
expect(mailboxModal).toContain('class="mail-modal__nav-button"')
expect(mailboxModal).toContain(":aria-label=\"phone.t('Common.cancel')\"")
expect(mailboxModal).toContain(":aria-label=\"phone.t('Common.save')\"")
expect(mailboxModal).toContain(':aria-label="phone.t(\'Common.cancel\')"')
expect(mailboxModal).toContain(':aria-label="phone.t(\'Common.save\')"')
expect(mailboxModal).toContain('<X :size="20" />')
expect(mailboxModal).toContain('<Check :size="20" />')
expect(mailboxModal).not.toContain('<sky-spinner')
expect(mailboxModal).toContain('class="mail-mailbox-create__name"')
expect(mailboxModal).toContain(
'class="mail-mailbox-create__location-row"',
)
expect(mailboxModal).toContain('class="mail-mailbox-create__location-row"')
expect(mailboxModal).not.toContain(' outline')
expect(source).toMatch(
/\.mail-mailbox-create__form\s*\{[^}]*overflow-y:\s*auto/s,
@@ -121,9 +122,7 @@ describe('MailApp Sky UI contract', () => {
})
it('offers multiple filter criteria in one scrolling Sky UI modal', () => {
const filterStart = source.indexOf(
'class="mail-modal mail-filter-modal"',
)
const filterStart = source.indexOf('class="mail-modal mail-filter-modal"')
const filterEnd = source.indexOf('</sky-sheet>', filterStart)
const filterScreen = source.slice(filterStart, filterEnd)
@@ -132,7 +131,9 @@ describe('MailApp Sky UI contract', () => {
expect(filterScreen).toContain('@backdropclick="closeMailFilters"')
expect(filterScreen).toContain('@escape="closeMailFilters"')
expect(filterScreen).toContain('@swipeclose="closeMailFilters"')
expect(filterScreen).toContain('class="mail-modal__navbar mail-filters__navbar"')
expect(filterScreen).toContain(
'class="mail-modal__navbar mail-filters__navbar"',
)
expect(filterScreen).toContain('@click="applyMailFilters"')
expect(filterScreen).toContain("toggleDraftReadFilter('unread')")
expect(filterScreen).toContain("toggleDraftReadFilter('read')")
@@ -157,9 +158,11 @@ describe('MailApp Sky UI contract', () => {
it('sends active filters through the paginated mail list contract', () => {
expect(source).toContain('mail.setListFilters(mailFilters.value)')
expect(mailServerSource).toContain('local function normalize_list_filters')
expect(mailServerSource).toContain('local filters = normalize_list_filters(data.filters)')
expect(mailServerSource).toContain("filters.read == \"unread\"")
expect(mailServerSource).toContain("filters.address == \"to-me\"")
expect(mailServerSource).toContain(
'local filters = normalize_list_filters(data.filters)',
)
expect(mailServerSource).toContain('filters.read == "unread"')
expect(mailServerSource).toContain('filters.address == "to-me"')
expect(mailServerSource).toContain('m.`created_at` >= CURRENT_DATE()')
})
@@ -241,9 +244,7 @@ describe('MailMarkdownEditor Sky toolbar contract', () => {
expect(editorSource).toMatch(
/\.mail-editor__tools\s*\{[^}]*padding-right:\s*var\(--sky-space-3\)[^}]*padding-left:\s*var\(--sky-space-3\)/s,
)
expect(editorSource).toMatch(
/:deep\(\.tiptap p\)\s*\{[^}]*margin:\s*0;/s,
)
expect(editorSource).toMatch(/:deep\(\.tiptap p\)\s*\{[^}]*margin:\s*0;/s)
expect(editorSource).not.toContain(':deep(.tiptap p:last-child)')
})
})
@@ -262,16 +263,16 @@ describe('Mail list toolbar styling contract', () => {
expect(source).toMatch(
/\.mail-folders-toolbar\s+:deep\(\.sky-fab\)\s*\{[^}]*border:\s*1px solid var\(--sky-hairline\)/s,
)
expect(source).toMatch(
/\.mail-folder-row\s*\{[^}]*min-height:\s*52px/s,
)
expect(source).toMatch(/\.mail-folder-row\s*\{[^}]*min-height:\s*52px/s)
})
})
describe('Mail custom mailbox server contract', () => {
it('persists account-owned mailboxes and custom entry placement', () => {
expect(migrationSource).toContain('name = "sky_phone_mailboxes"')
expect(migrationSource).toContain('{ name = "mailbox_id", type = "BIGINT UNSIGNED NULL" }')
expect(migrationSource).toContain(
'{ name = "mailbox_id", type = "BIGINT UNSIGNED NULL" }',
)
expect(mailServerSource).toContain(
'Bridge.Callbacks.Register("sky_phone:mail:create-mailbox"',
)
@@ -281,9 +282,7 @@ describe('Mail custom mailbox server contract', () => {
expect(mailServerSource).toContain(
'Bridge.Callbacks.Register("sky_phone:mail:move"',
)
expect(mailServerSource).toContain(
'WHERE `id` = ? AND `account_id` = ?',
)
expect(mailServerSource).toContain('WHERE `id` = ? AND `account_id` = ?')
expect(mailServerSource).toContain(
'WHERE `id` = ? AND `account_id` = ? AND `trashed_at` IS NULL',
)
@@ -300,3 +299,58 @@ describe('Mail custom mailbox server contract', () => {
}
})
})
function functionSource(name: string, nextName: string): string {
const start = source.indexOf(`async function ${name}`)
const end = source.indexOf(`async function ${nextName}`, start + 1)
return source.slice(start, end)
}
describe('MailApp contact compose deep-link contract', () => {
it('normalizes a compose=1 recipient only after mail authentication', () => {
const consumeRequest = functionSource(
'consumeContactComposeRequest',
'closeCompose',
)
expect(source).toContain('const route = useRoute()')
expect(source).toContain('const router = useRouter()')
expect(consumeRequest).toContain(
"if (!authenticated.value || route.query.compose !== '1') return",
)
expect(consumeRequest).toContain("typeof route.query.to === 'string'")
expect(consumeRequest).toContain('normalizeMailAddress(route.query.to)')
expect(consumeRequest).toContain(
"beginCompose({ body: '', recipients: [requestedRecipient], subject: '' })",
)
})
it('consumes the route after handling the request', () => {
const consumeRequest = functionSource(
'consumeContactComposeRequest',
'closeCompose',
)
const composeIndex = consumeRequest.indexOf('beginCompose(')
const replaceIndex = consumeRequest.indexOf(
"await router.replace('/apps/mail')",
)
expect(replaceIndex).toBeGreaterThan(composeIndex)
})
it('retries the pending compose request after auth and on authenticated mount', () => {
const submitAuth = functionSource('submitAuth', 'signOut')
const mounted = source.slice(
source.indexOf('onMounted(() => {'),
source.indexOf('onBeforeUnmount(() => {'),
)
expect(submitAuth).toMatch(
/if \(!response\.success\)[\s\S]*await consumeContactComposeRequest\(\)/,
)
expect(mounted).toContain('void consumeContactComposeRequest()')
expect(source).toMatch(
/watch\(authenticated,[\s\S]*isAuthenticated && !wasAuthenticated && !submitting\.value[\s\S]*consumeContactComposeRequest\(\)/,
)
})
})
+27 -1
View File
@@ -41,6 +41,7 @@ import {
X,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import mailIcon from '@/assets/img/app-icons/mail.webp'
import MailMarkdownEditor, {
@@ -70,6 +71,7 @@ import {
MAIL_ADDRESS_INPUT_MAX_LENGTH,
MAIL_RECIPIENT_INPUT_MAX_LENGTH,
mailPlainText,
normalizeMailAddress,
parseMailRecipients,
} from '@/utils/mail'
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
@@ -106,6 +108,8 @@ const MAIL_DELETE_BATCH_SIZE = 50
const phone = usePhoneStore()
const mail = useMailStore()
const easyShare = useEasyShareStore()
const route = useRoute()
const router = useRouter()
const authMode = ref<AuthMode>('login')
const authEmail = ref('')
const authPassword = ref('')
@@ -613,6 +617,7 @@ async function submitAuth(): Promise<void> {
authPassword.value = ''
authConfirm.value = ''
screen.value = 'folders'
await consumeContactComposeRequest()
}
async function signOut(): Promise<void> {
@@ -795,6 +800,18 @@ function beginCompose(draft?: MailComposeDraft): void {
screen.value = 'compose'
}
async function consumeContactComposeRequest(): Promise<void> {
if (!authenticated.value || route.query.compose !== '1') return
const requestedRecipient =
typeof route.query.to === 'string'
? normalizeMailAddress(route.query.to)
: null
if (requestedRecipient) {
beginCompose({ body: '', recipients: [requestedRecipient], subject: '' })
}
await router.replace('/apps/mail')
}
async function closeCompose(): Promise<void> {
await saveDraftNow()
screen.value = composeReturn.value
@@ -964,7 +981,16 @@ watch(
},
)
onMounted(() => window.addEventListener('message', onMailEvent))
watch(authenticated, (isAuthenticated, wasAuthenticated) => {
if (isAuthenticated && !wasAuthenticated && !submitting.value) {
void consumeContactComposeRequest()
}
})
onMounted(() => {
window.addEventListener('message', onMailEvent)
void consumeContactComposeRequest()
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMailEvent)
@@ -15,21 +15,108 @@ describe('MessagesApp Sky UI contract', () => {
expect(source).toContain('<SkyNavbar')
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('<SkySearchbar')
expect(source).toContain('<SkySegmented')
expect(source).toContain('<SkyDropdown')
expect(source).toContain('<SkyFab')
expect(source).toContain('<SkyMessages')
expect(source).toContain('<SkyMessagebar')
expect(source).toContain('<SkyPillNavigation')
expect(source).toContain('<SkySettingsGroup')
expect(source).toContain('<SkySettingsRow')
expect(source).toContain('<SkyToolbar')
expect(source).not.toContain('<SkySegmented')
})
it('keeps inbox search focused on text and exposes clear filters', () => {
const inboxStart = source.indexOf('messages-sky-inbox')
it('uses the anchored central dropdown for sort, filter, and edit actions', () => {
const inboxStart = source.indexOf(
'class="messages-sky-page messages-sky-inbox"',
)
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
expect(inbox).toContain('<SkySearchbar')
expect(inbox).not.toContain('messages-inbox-search__voice')
expect(inbox).toContain('Apps.messages.allMessages')
expect(inbox).toContain('Apps.messages.unreadMessages')
expect(source).toContain("id: 'sort-newest'")
expect(source).toContain("id: 'sort-oldest'")
expect(source).toContain("id: 'filter-all'")
expect(source).toContain("id: 'filter-unread'")
expect(source).toContain("id: 'edit'")
expect(source).toContain("phone.t('Apps.messages.sortNewest')")
expect(source).toContain("phone.t('Apps.messages.sortOldest')")
expect(source).toContain("phone.t('Apps.messages.allMessages')")
expect(source).toContain("phone.t('Apps.messages.unreadMessages')")
expect(source).toContain("phone.t('Common.edit')")
expect(source).toContain("group: 'sort'")
expect(source).toContain("group: 'filter'")
expect(source).toContain("phone.t('Apps.messages.sortLabel')")
expect(source).toContain("phone.t('Apps.messages.filterLabel')")
expect(source).toContain(
'inboxMenuTarget.value = event.currentTarget as HTMLElement',
)
expect(source).toContain("if (id === 'edit') toggleListEditing()")
expect(inbox).toContain('class="messages-sky-inbox-menu-trigger"')
expect(inbox).toContain('aria-haspopup="menu"')
expect(inbox).toContain(':aria-expanded="inboxMenuOpened"')
expect(inbox).toContain('@click="openInboxMenu"')
expect(inbox).toContain('id="messages-inbox-menu"')
expect(inbox).toContain(':items="inboxMenuItems"')
expect(inbox).toContain(':opened="inboxMenuOpened"')
expect(inbox).toContain(':target="inboxMenuTarget"')
expect(inbox).toContain('@backdropclick="dismissInboxMenu"')
expect(inbox).toContain('@escape="dismissInboxMenu"')
expect(inbox).toContain('@positionerror="dismissInboxMenu"')
expect(inbox).toContain('@select="selectInboxMenuItem"')
})
it('places search and the compose action together in the bottom toolbar', () => {
const inboxStart = source.indexOf(
'class="messages-sky-page messages-sky-inbox"',
)
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
const scrollEnd = inbox.indexOf('</SkyScrollArea>')
const toolbarStart = inbox.indexOf('<SkyToolbar')
const toolbarEnd = inbox.indexOf('</SkyToolbar>', toolbarStart)
const toolbar = inbox.slice(toolbarStart, toolbarEnd)
const searchbarStart = toolbar.indexOf('<SkySearchbar')
const fabStart = toolbar.indexOf('<SkyFab')
expect(toolbarStart).toBeGreaterThan(scrollEnd)
expect(toolbar).toContain('class="messages-sky-inbox-toolbar"')
expect(toolbar).toContain('component="footer"')
expect(searchbarStart).toBeGreaterThan(-1)
expect(fabStart).toBeGreaterThan(searchbarStart)
expect(toolbar).toContain('v-model="search"')
expect(toolbar).toContain('variant="neutral"')
expect(toolbar).toContain('@click="beginCompose"')
expect(toolbar).toContain('<SquarePen :size="21" />')
expect(inbox).not.toContain('messages-sky-compose-navigation')
})
it('uses the results-empty state for search and unread filters', () => {
expect(source).toContain('search || showUnreadOnly')
expect(source).toContain('v-if="!search && !showUnreadOnly" #actions')
})
it('keeps the unread marker in the media slot left of the avatar', () => {
const inboxStart = source.indexOf(
'class="messages-sky-page messages-sky-inbox"',
)
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
const mediaSlotStart = inbox.indexOf('<template #media>')
const unreadSlot = inbox.indexOf(
'class="messages-sky-unread-slot"',
mediaSlotStart,
)
const avatar = inbox.indexOf('class="messages-avatar"', mediaSlotStart)
expect(mediaSlotStart).toBeGreaterThan(-1)
expect(unreadSlot).toBeGreaterThan(mediaSlotStart)
expect(avatar).toBeGreaterThan(unreadSlot)
expect(inbox).toContain(
"'is-visible': !editingList && conversation.unread > 0",
)
expect(source).toContain('count: String(conversation.unread)')
expect(inbox).toContain("'aria-pressed': selectedNumbers.includes(")
})
it('uses one compact scroll region and an in-flow composer in threads', () => {
@@ -48,8 +135,68 @@ describe('MessagesApp Sky UI contract', () => {
)
})
it('provides a full-size back target and a compact recipient row', () => {
expect(source).toContain('back-appearance="plain"')
it('keeps add and text entry in separate glass surfaces', () => {
expect(source).toContain('class="messages-sky-composer-row"')
expect(source).toMatch(
/<SkyGlass[\s\S]*?class="messages-sky-messagebar__action messages-sky-messagebar__plus"[\s\S]*?<\/SkyGlass>[\s\S]*?<SkyGlass[\s\S]*?class="messages-sky-composer-pill"/,
)
})
it('round-trips up to six media items into a removable composer preview', () => {
expect(source).toContain('const MAX_PENDING_ATTACHMENTS = 6')
expect(source).toContain(
'pendingAttachments: [...pendingAttachments.value]',
)
expect(source).toContain('messageMedia.consumeMany<MessagesMediaContext>')
expect(source).toContain('class="messages-pending-media"')
expect(source).toContain('@click="removePendingAttachment(media.id)"')
expect(source).toContain('Apps.photos.videoAlt')
expect(source).not.toMatch(/await sendAttachment\([\s\S]*?media\.mediaType/)
})
it('sends selected media in order and captions the last item', () => {
expect(source).toContain(
'for (const [index, media] of queuedAttachments.entries())',
)
expect(source).toContain(
'index === queuedAttachments.length - 1 ? body : undefined',
)
expect(source).toContain("media.mediaType === 'photo' ? 'image' : 'video'")
})
it('normalizes voice waveforms and uses the regular unfilled send icon', () => {
expect(source).toContain(
'compressWaveformSamples(recordingSamples, WAVEFORM_SAMPLES)',
)
expect(source).toContain('class="messages-recorder__send"')
expect(source).toContain('<ArrowUpCircle :size="27" :stroke-width="2.4" />')
expect(source).not.toContain('fill="currentColor" />\n </SkyButton>')
expect(source).toContain('const recordingStarting = ref(false)')
expect(source).toContain('if (requestId !== recordingRequestId)')
expect(source).toContain('requestedStream.getTracks().forEach')
expect(source).toContain(':disabled="sending || recordingStarting"')
})
it('uses provider dimensions for proportional GIF picker results', () => {
expect(source).toContain(
'aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`',
)
})
it('uses a surface back target in the thread and a compact recipient row', () => {
const threadStart = source.indexOf(
'class="messages-sky-page messages-sky-thread"',
)
const contactProfileStart = source.indexOf(
'v-if="contactDetailsOpen"',
threadStart,
)
const threadHeader = source.slice(threadStart, contactProfileStart)
expect(threadHeader).toContain('class="messages-sky-thread-navbar"')
expect(threadHeader).toContain('show-back')
expect(threadHeader).toContain('back-appearance="surface"')
expect(threadHeader).toContain('@back="goBack"')
expect(source).toContain('class="messages-recipient-field"')
expect(source).toContain('layout="inline"')
expect(source).toMatch(
@@ -57,17 +204,96 @@ describe('MessagesApp Sky UI contract', () => {
)
})
it('keeps the SMS compose action in the inbox navbar', () => {
const inboxStart = source.indexOf('messages-sky-inbox')
const composeStart = source.indexOf('v-else-if="composing"')
const inbox = source.slice(inboxStart, composeStart)
it('renders contact details read-only with icon actions and contact routing', () => {
const contactProfileStart = source.indexOf(
'<SkyAppPage\n v-if="contactDetailsOpen"',
)
const threadScrollStart = source.indexOf(
'class="messages-sky-thread-scroll"',
contactProfileStart,
)
const contactProfile = source.slice(contactProfileStart, threadScrollStart)
expect(inbox).toContain('@click="beginCompose"')
expect(inbox).toContain('<SquarePen')
expect(inbox).not.toContain('messages-sky-compose-navigation')
expect(contactProfileStart).toBeGreaterThan(-1)
expect(threadScrollStart).toBeGreaterThan(contactProfileStart)
expect(contactProfile).toContain('component="section"')
expect(contactProfile).toContain('back-appearance="surface"')
expect(contactProfile).toContain('activeContact?.avatar_url')
expect(contactProfile).toContain('<SkySettingsGroup')
expect(contactProfile).toContain('<SkySettingsRow')
expect(contactProfile).toContain('activeContact?.organization')
expect(contactProfile).toContain(':value="activeContact.organization"')
expect(contactProfile).toContain('activeContact?.notes')
expect(contactProfile).not.toContain('<SkyField')
expect(contactProfile).toMatch(
/<SkyButton\s+v-if="activeContact\?\.canCall !== false"\s+icon-only\s+rounded\s+tonal[\s\S]*?@click="callActiveContact"/,
)
expect(contactProfile).toMatch(
/<SkyButton\s+v-if="activeCanMessage"\s+icon-only\s+rounded\s+tonal[\s\S]*?@click="contactDetailsOpen = false"/,
)
expect(contactProfile).toMatch(
/<SkyButton\s+v-if="activeContactEmail"\s+icon-only\s+rounded\s+tonal[\s\S]*?@click="mailActiveContact"/,
)
expect(contactProfile).toContain('<PhoneIcon :size="22" />')
expect(contactProfile).toContain('<MessageCircle :size="22" />')
expect(contactProfile).toContain('<Mail :size="22" />')
expect(contactProfile).toContain('Apps.messages.showInContacts')
expect(contactProfile).toContain('Apps.messages.addContact')
expect(contactProfile).toContain('@activate="openActiveContactInPhone"')
expect(source).toContain("path: '/apps/phone'")
expect(source).toContain('{ contactId: activeContact.value.id }')
expect(source).toContain('{ newContactNumber: messages.activeNumber }')
expect(source).toContain("path: '/apps/mail'")
expect(source).toContain(
"query: { compose: '1', to: activeContactEmail.value }",
)
expect(contactProfile).not.toContain('Common.edit')
expect(source).not.toContain('contactEditing')
expect(source).not.toContain('saveContactDetails')
expect(source).not.toContain('deleteActiveContact')
expect(source).not.toContain('Apps.messages.deleteContact')
expect(contactProfile).not.toContain('<Pencil')
expect(source).toContain(':inert="contactDetailsOpen || undefined"')
expect(source).toContain(':aria-hidden="contactDetailsOpen"')
})
it('confirms contact blocking in a dismissible dialog', () => {
const contactProfileStart = source.indexOf(
'<SkyAppPage\n v-if="contactDetailsOpen"',
)
const threadScrollStart = source.indexOf(
'class="messages-sky-thread-scroll"',
contactProfileStart,
)
const contactProfile = source.slice(contactProfileStart, threadScrollStart)
const blockDialogStart = source.indexOf(
'<SkyDialog\n :opened="blockDialogOpened"',
)
const toastStart = source.indexOf('<SkyToast', blockDialogStart)
const blockDialog = source.slice(blockDialogStart, toastStart)
expect(contactProfile).toContain('kind="action"')
expect(contactProfile).toContain('tone="danger"')
expect(contactProfile).toContain('Apps.messages.blockContact')
expect(contactProfile).toContain('@activate="confirmBlockActiveContact"')
expect(blockDialogStart).toBeGreaterThan(-1)
expect(blockDialog).toContain('@backdropclick="blockDialogOpened = false"')
expect(blockDialog).toContain('@escape="blockDialogOpened = false"')
expect(blockDialog).toContain('Apps.messages.blockContactTitle')
expect(blockDialog).toContain('Apps.messages.blockContactBody')
expect(blockDialog).toContain('@click="blockActiveContact"')
expect(source).toContain(
'const response = await calls.blockNumber(messages.activeNumber)',
)
})
it('keeps SMS conversations and recipients in flat iMessage-style lists', () => {
expect(source).not.toMatch(
/\.messages-sky-page\s*\{[^}]*--sky-page-gutter/s,
)
expect(source).toMatch(
/v-if="filteredConversations.length"\s+flush\s+class="messages-sky-conversation-list"/,
)
File diff suppressed because it is too large Load Diff
@@ -18,4 +18,46 @@ describe('PhoneApp EasyShare contract', () => {
expect(source).toContain('<sky-tab-button')
expect(source).not.toContain('<sky-segmented')
})
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
const mounted = source.slice(
source.indexOf('onMounted(async () => {'),
source.indexOf('onBeforeUnmount(() => {'),
)
const bootstrapIndex = mounted.indexOf('await calls.bootstrap()')
const contactRequestIndex = mounted.indexOf(
"typeof route.query.contactId === 'string'",
)
expect(bootstrapIndex).toBeGreaterThanOrEqual(0)
expect(contactRequestIndex).toBeGreaterThan(bootstrapIndex)
expect(mounted).toContain(
'(contact) => contact.id === route.query.contactId',
)
expect(mounted).toContain("tab.value = 'contacts'")
expect(mounted).toContain('openRecentDetail(requestedContact.phone_number)')
expect(mounted).toMatch(
/route\.query\.contactId[\s\S]*await router\.replace\('\/apps\/phone'\)/,
)
})
it('opens new-contact deep links in the contact editor and consumes the query', () => {
const mounted = source.slice(
source.indexOf('onMounted(async () => {'),
source.indexOf('onBeforeUnmount(() => {'),
)
const bootstrapIndex = mounted.indexOf('await calls.bootstrap()')
const newContactRequestIndex = mounted.indexOf(
"typeof route.query.newContactNumber === 'string'",
)
expect(bootstrapIndex).toBeGreaterThanOrEqual(0)
expect(newContactRequestIndex).toBeGreaterThan(bootstrapIndex)
expect(mounted).toContain(
'openContact(undefined, route.query.newContactNumber)',
)
expect(mounted).toMatch(
/route\.query\.newContactNumber[\s\S]*await router\.replace\('\/apps\/phone'\)/,
)
})
})
+29 -1
View File
@@ -41,7 +41,7 @@ import {
X,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useCallsStore } from '@/stores/calls'
import { useEasyShareStore } from '@/stores/easyshare'
@@ -60,6 +60,7 @@ type ContactPhotoContext = {
avatarMediaId: number | null
avatarUrl: string
contactId?: string
email: string
firstName: string
lastName: string
notes: string
@@ -72,6 +73,7 @@ const calls = useCallsStore()
const easyShare = useEasyShareStore()
const mediaPicker = useMessageMediaStore()
const messages = useMessagesStore()
const route = useRoute()
const router = useRouter()
const tab = ref<PhoneTab>('recents')
const query = ref('')
@@ -87,6 +89,7 @@ const editingContact = ref<PhoneContact | null>(null)
const contactFirstName = ref('')
const contactLastName = ref('')
const contactOrganization = ref('')
const contactEmail = ref('')
const contactNotes = ref('')
const contactNumber = ref('')
const contactAvatarMediaId = ref<number | null>(null)
@@ -262,6 +265,7 @@ function openContact(contact?: PhoneContact, number = ''): void {
contactFirstName.value = nameParts.shift() ?? ''
contactLastName.value = nameParts.join(' ')
contactOrganization.value = contact?.organization ?? ''
contactEmail.value = contact?.email ?? ''
contactNotes.value = contact?.notes ?? ''
contactNumber.value = contact?.phone_number ?? number
contactAvatarMediaId.value = contact?.avatar_media_id ?? null
@@ -275,6 +279,7 @@ function openContactPhotoPicker(source: 'camera' | 'photos'): void {
avatarMediaId: contactAvatarMediaId.value,
avatarUrl: contactAvatarUrl.value,
contactId: editingContact.value?.id,
email: contactEmail.value,
firstName: contactFirstName.value,
lastName: contactLastName.value,
notes: contactNotes.value,
@@ -405,6 +410,7 @@ async function saveContact(): Promise<void> {
}
const response = await calls.saveContact({
avatarMediaId: contactAvatarMediaId.value,
email: contactEmail.value.trim(),
id: editingContact.value?.id,
name,
notes: contactNotes.value.trim(),
@@ -654,6 +660,7 @@ onMounted(async () => {
: null
contactFirstName.value = context?.firstName ?? ''
contactLastName.value = context?.lastName ?? ''
contactEmail.value = context?.email ?? ''
contactNotes.value = context?.notes ?? ''
contactOrganization.value = context?.organization ?? ''
contactNumber.value = context?.phoneNumber ?? ''
@@ -665,6 +672,19 @@ onMounted(async () => {
}
tab.value = 'contacts'
editorOpened.value = true
} else if (typeof route.query.contactId === 'string') {
const requestedContact = calls.contacts.find(
(contact) => contact.id === route.query.contactId,
)
if (requestedContact) {
tab.value = 'contacts'
openRecentDetail(requestedContact.phone_number)
}
await router.replace('/apps/phone')
} else if (typeof route.query.newContactNumber === 'string') {
tab.value = 'contacts'
openContact(undefined, route.query.newContactNumber)
await router.replace('/apps/phone')
}
callClock = window.setInterval(updateCallElapsed, 500)
})
@@ -1585,6 +1605,14 @@ onBeforeUnmount(() => {
:readonly="editingContact?.readonly"
@input="contactOrganization = eventValue($event)"
/>
<sky-field
:value="contactEmail"
type="email"
:placeholder="phone.t('Apps.phone.mail')"
autocomplete="email"
:readonly="editingContact?.readonly"
@input="contactEmail = eventValue($event)"
/>
</sky-list>
<sky-list strong inset class="phone-contact-editor__number-list">
+25 -2
View File
@@ -1435,12 +1435,14 @@ const mockHousingCandidates = [
]
let contactSequence = 40
let smsSequence = 1
const contacts = [
{
avatar_media_id: 1,
avatar_url: 'https://picsum.photos/seed/sky-phone-1/600/800',
created_at: isoTime(-42 * 86_400_000),
favorite: true,
email: 'alex.rivera@ifruit.com',
id: 'contact-alex',
name: 'Alex Rivera',
notes: 'Meeting on Friday at 18:00 near the bank.',
@@ -8728,6 +8730,7 @@ app.post('/api/:endpoint', (request, response) => {
(messageType === 'voice' && !request.body.mediaPayload) ||
(messageType === 'contact' && !selectedContact) ||
(messageType === 'share' && !request.body.sharePayload) ||
(isAttachment && body.length > 2000) ||
(isAttachment &&
!attachmentAssets[messageType].has(attachmentId) &&
!attachmentId.startsWith('https://') &&
@@ -8752,7 +8755,7 @@ app.post('/api/:endpoint', (request, response) => {
: null,
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
direction: 'sent',
id: `sms-${Date.now()}`,
id: `sms-${Date.now()}-${smsSequence++}`,
media_duration_ms: ['voice', 'video'].includes(messageType)
? (request.body.mediaDurationMs ?? null)
: null,
@@ -8818,6 +8821,19 @@ app.post('/api/:endpoint', (request, response) => {
}
if (endpoint === 'contacts:save') {
const name = String(request.body.name ?? '').trim()
let email = String(request.body.email ?? '')
.trim()
.toLowerCase()
if (email && !email.includes('@')) email = `${email}@ifruit.com`
const emailLocalPart = email.match(
/^([a-z0-9][a-z0-9._-]*[a-z0-9])@ifruit\.com$/,
)?.[1]
const emailValid =
!email ||
(Boolean(emailLocalPart) &&
emailLocalPart.length >= 3 &&
emailLocalPart.length <= 32 &&
!emailLocalPart.includes('..'))
const notes = String(request.body.notes ?? '')
.trim()
.slice(0, 500)
@@ -8831,7 +8847,12 @@ app.post('/api/:endpoint', (request, response) => {
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
)
: null
if (!name || !phoneNumber || (avatarMediaId && !avatarMedia)) {
if (
!name ||
!phoneNumber ||
!emailValid ||
(avatarMediaId && !avatarMedia)
) {
response.json({ success: false, error: 'invalid_contact' })
return
}
@@ -8842,6 +8863,7 @@ app.post('/api/:endpoint', (request, response) => {
}
if (contact) {
contact.name = name
contact.email = email || null
contact.notes = notes || null
contact.organization = organization || null
contact.phone_number = phoneNumber
@@ -8856,6 +8878,7 @@ app.post('/api/:endpoint', (request, response) => {
contact = {
created_at: now,
favorite: false,
email: email || null,
id: `contact-${contactSequence++}`,
name,
notes: notes || null,
+43 -2
View File
@@ -360,6 +360,40 @@ async function verifyStatefulActions(baseUrl) {
})
assert.equal(tooManyImages.success, false)
assert.equal(tooManyImages.error, 'invalid_attachment')
const firstSmsPhoto = await expectSuccess(
baseUrl,
'messages:send',
{
body: '',
mediaAssetId: String(articlePhotos[0].id),
messageType: 'image',
phoneNumber: '5558675309',
},
true,
)
const captionedSmsPhoto = await expectSuccess(
baseUrl,
'messages:send',
{
body: 'Two photos from one composer draft.',
mediaAssetId: String(articlePhotos[1].id),
messageType: 'image',
phoneNumber: '5558675309',
},
true,
)
assert.notEqual(firstSmsPhoto.id, captionedSmsPhoto.id)
assert.equal(captionedSmsPhoto.body, 'Two photos from one composer draft.')
const smsPhotoThread = await expectSuccess(
baseUrl,
'messages:thread',
{ phoneNumber: '5558675309' },
true,
)
assert(smsPhotoThread.some((message) => message.id === firstSmsPhoto.id))
assert(smsPhotoThread.some((message) => message.id === captionedSmsPhoto.id))
await expectSuccess(baseUrl, 'weazel-news:delete', {
id: updatedArticle.id,
revision: updatedArticle.revision,
@@ -486,9 +520,14 @@ async function verifyStatefulActions(baseUrl) {
const contact = await expectSuccess(
baseUrl,
'contacts:save',
{ name: 'Browser Tester', phoneNumber: '5552223333' },
{
email: 'browser.tester',
name: 'Browser Tester',
phoneNumber: '5552223333',
},
true,
)
assert.equal(contact.email, 'browser.tester@ifruit.com')
await expectSuccess(
baseUrl,
'contacts:favorite',
@@ -496,7 +535,9 @@ async function verifyStatefulActions(baseUrl) {
true,
)
let contacts = await expectSuccess(baseUrl, 'contacts:list', {}, true)
assert.equal(contacts.find((item) => item.id === contact.id)?.favorite, true)
const savedContact = contacts.find((item) => item.id === contact.id)
assert.equal(savedContact?.favorite, true)
assert.equal(savedContact?.email, 'browser.tester@ifruit.com')
await expectSuccess(baseUrl, 'contacts:delete', { id: contact.id })
contacts = await expectSuccess(baseUrl, 'contacts:list', {}, true)
assert(
+7 -4
View File
@@ -624,13 +624,16 @@ Locales["en"] = {
attachGif = "Attach GIF", attachVideo = "Attach Video", photos = "Photos", gifs = "GIFs", videos = "Videos",
contacts = "Contacts", shareContact = "Share Contact", noContactsToShare = "Save a contact first to share it here.", contactSaved = "Contact Saved",
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",
moreActions = "More Actions", inboxActions = "Conversation Actions", sortLabel = "Conversation Sort", sortNewest = "Newest First", sortOldest = "Oldest First", unreadConversation = "{name}, {count} unread messages",
attachmentPreview = "Selected attachments", attachmentLimit = "You can attach up to {count} photos or videos.", removeAttachment = "Remove attachment {number}",
contactDetails = "Contact Details", contactName = "Name", phoneNumber = "Phone Number", company = "Company", contactActions = "Contact Actions",
call = "Call", messageAction = "Message", addContact = "Add Contact", showInContacts = "Show in Contacts", blockContact = "Block Contact",
blockContactTitle = "Block this contact?", blockContactBody = "{name} will no longer be able to call or message this SIM.", blockContactFailed = "The contact could not be blocked.", contactBlocked = "Contact blocked.", 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",
playAudio = "Play Audio", pauseAudio = "Pause Audio", seekAudio = "Seek 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.",
@@ -646,7 +649,7 @@ Locales["en"] = {
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.",
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.", blocked = "This contact has blocked calls and messages from your SIM.",
messaging_unavailable = "This company contact does not accept messages.",
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.",
+10 -1
View File
@@ -26,7 +26,16 @@ local function build_create_query(table_definition)
end
if table_definition.primaryKey then
definitions[#definitions + 1] = ("PRIMARY KEY (`%s`)"):format(table_definition.primaryKey)
local primary_key = table_definition.primaryKey
if type(primary_key) == "table" then
local quoted_columns = {}
for index = 1, #primary_key do
quoted_columns[index] = ("`%s`"):format(primary_key[index])
end
definitions[#definitions + 1] = ("PRIMARY KEY (%s)"):format(table.concat(quoted_columns, ", "))
else
definitions[#definitions + 1] = ("PRIMARY KEY (`%s`)"):format(primary_key)
end
end
for _, unique_key in ipairs(table_definition.uniqueKeys or {}) do
definitions[#definitions + 1] = ("UNIQUE KEY `%s` %s"):format(unique_key.name, unique_key.columns)
+36 -9
View File
@@ -25,6 +25,27 @@ local function trim(value)
return value:match("^%s*(.-)%s*$")
end
local function normalize_contact_email(value)
local email = trim(value)
if not email or email == "" then
return ""
end
email = email:lower()
local local_part = email
if email:find("@", 1, true) then
local_part = email:match("^([^@]+)@" .. Config.Mail.Domain:gsub("%.", "%%.") .. "$")
end
if not local_part
or #local_part < Config.Mail.LocalPartMinLength
or #local_part > Config.Mail.LocalPartMaxLength
or not local_part:match("^[a-z0-9][a-z0-9._-]*[a-z0-9]$")
or local_part:find("..", 1, true)
then
return nil
end
return local_part .. "@" .. Config.Mail.Domain
end
local function scope_for_device(device)
if device.account_id then
return tonumber(device.account_id), nil
@@ -350,8 +371,8 @@ function SkyPhoneCalls.CopyCloudToDevice(account_id, imei)
{
query = [[
INSERT INTO `sky_phone_contacts`
(`id`, `contact_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`, `created_at`, `updated_at`)
SELECT UUID(), `contact_id`, ?, `name`, `notes`, `organization`, `phone_number`, NULL, `favorite`, `created_at`, `updated_at`
(`id`, `contact_id`, `device_imei`, `name`, `notes`, `organization`, `email`, `phone_number`, `avatar_media_id`, `favorite`, `created_at`, `updated_at`)
SELECT UUID(), `contact_id`, ?, `name`, `notes`, `organization`, `email`, `phone_number`, NULL, `favorite`, `created_at`, `updated_at`
FROM `sky_phone_contacts` WHERE `account_id` = ?
]],
params = { imei, account_id },
@@ -375,7 +396,7 @@ Bridge.Callbacks.Register("sky_phone:contacts:list", function(source)
end
local condition, params = scope_condition(scope)
local rows = Bridge.Database.Query(([[
SELECT `contact_id` AS `id`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`,
SELECT `contact_id` AS `id`, `name`, `notes`, `organization`, `email`, `phone_number`, `avatar_media_id`, `favorite`,
(SELECT media.`url` FROM `sky_phone_media` media WHERE media.`id` = `avatar_media_id`) AS `avatar_url`,
`created_at`, `updated_at`
FROM `sky_phone_contacts` WHERE %s ORDER BY LOWER(`name`), `phone_number`
@@ -414,9 +435,14 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
local name = trim(data.name)
local notes = trim(data.notes) or ""
local organization = trim(data.organization) or ""
local email = normalize_contact_email(data.email)
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
local avatar_media_id = tonumber(data.avatarMediaId) or 0
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or #notes > Config.Calls.ContactNotesMaxLength or #organization > Config.Calls.ContactNameMaxLength or not number then
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength
or #notes > Config.Calls.ContactNotesMaxLength
or #organization > Config.Calls.ContactNameMaxLength
or email == nil or not number
then
return { success = false, error = "invalid_contact" }
end
if (type(data.id) == "string" and data.id:sub(1, 8) == "company:")
@@ -451,19 +477,19 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
if not owned[1] then
return { success = false, error = "contact_not_found" }
end
local params = { name, notes, organization, number, avatar_media_id, id }
local params = { name, notes, organization, email, number, avatar_media_id, id }
for _, value in ipairs(condition_params) do
params[#params + 1] = value
end
Bridge.Database.Query(([[
UPDATE `sky_phone_contacts` SET `name` = ?, `notes` = NULLIF(?, ''), `organization` = NULLIF(?, ''), `phone_number` = ?, `avatar_media_id` = NULLIF(?, 0)
UPDATE `sky_phone_contacts` SET `name` = ?, `notes` = NULLIF(?, ''), `organization` = NULLIF(?, ''), `email` = NULLIF(?, ''), `phone_number` = ?, `avatar_media_id` = NULLIF(?, 0)
WHERE `contact_id` = ? AND %s
]]):format(condition), params)
else
Bridge.Database.Query([[
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`)
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, NULLIF(?, 0))
]], { uuid(), id, scope.account_id, scope.device_imei, name, notes, organization, number, avatar_media_id })
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `notes`, `organization`, `email`, `phone_number`, `avatar_media_id`)
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, NULLIF(?, 0))
]], { uuid(), id, scope.account_id, scope.device_imei, name, notes, organization, email, number, avatar_media_id })
end
if scope.account_id then
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
@@ -475,6 +501,7 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
name = name,
notes = notes ~= "" and notes or nil,
organization = organization ~= "" and organization or nil,
email = email ~= "" and email or nil,
phone_number = number,
avatar_media_id = avatar_media_id > 0 and avatar_media_id or nil,
avatar_url = avatar_url,
+33 -1
View File
@@ -472,6 +472,7 @@ local schema = {
{ name = "name", type = "VARCHAR(80) NOT NULL" },
{ name = "notes", type = "VARCHAR(500) NULL" },
{ name = "organization", type = "VARCHAR(80) NULL" },
{ name = "email", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
{ name = "favorite", type = "TINYINT(1) NOT NULL DEFAULT 0" },
@@ -516,6 +517,33 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_call_blocks",
columns = {
{
name = "blocker_sim_id",
type = "CHAR(36) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{
name = "blocked_sim_id",
type = "CHAR(36) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = { "blocker_sim_id", "blocked_sim_id" },
indexes = {
{ name = "idx_sky_phone_call_blocks_blocked", columns = "(`blocked_sim_id`)" },
},
foreignKeys = {
{ column = "blocker_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE CASCADE" },
{ column = "blocked_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_call_entries",
columns = {
@@ -681,7 +709,7 @@ local schema = {
{ 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" },
{ name = "created_at", type = "DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)" },
},
primaryKey = "id",
indexes = {
@@ -2766,6 +2794,10 @@ Bridge.Database.Query([[
ALTER TABLE `sky_phone_sms_messages`
MODIFY COLUMN `message_type` ENUM('text', 'voice', 'image', 'gif', 'video', 'contact', 'share') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_sms_messages`
MODIFY COLUMN `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages`
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'
+12 -1
View File
@@ -530,7 +530,9 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
if not attachment then
return { success = false, error = "invalid_attachment" }
end
body = ""
if body ~= "" and #body > Config.Messages.BodyMaxLength then
return { success = false, error = "invalid_message" }
end
else
return { success = false, error = "invalid_request" }
end
@@ -546,6 +548,15 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
if not recipient then
return { success = false, error = "recipient_not_found" }
end
local blocks = Bridge.Database.Query([[
SELECT 1 AS `blocked`
FROM `sky_phone_call_blocks`
WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ?
LIMIT 1
]], { recipient.id, device.sim_id })
if blocks[1] then
return { success = false, error = "blocked" }
end
local id = uuid()
Bridge.Database.Query([[
INSERT INTO `sky_phone_sms_messages`
+2 -1
View File
@@ -207,6 +207,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
`name` VARCHAR(80) NOT NULL,
`notes` VARCHAR(500) NULL,
`organization` VARCHAR(80) NULL,
`email` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NULL,
`phone_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`avatar_media_id` BIGINT UNSIGNED NULL,
`favorite` TINYINT(1) NOT NULL DEFAULT 0,
@@ -378,7 +379,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_sms_messages` (
`media_duration_ms` INT UNSIGNED NULL,
`media_waveform` TEXT NULL,
`read_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
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`),