mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 16:23:22 +00:00
feat(messages): overhaul messaging and contact flows
This commit is contained in:
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
|
||||
async function saveContact(contact: {
|
||||
avatarMediaId?: number | null
|
||||
email?: string
|
||||
id?: string
|
||||
name: string
|
||||
notes?: string
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -61,6 +61,7 @@ export type SmsOutgoingMessage =
|
||||
messageType: 'voice'
|
||||
}
|
||||
| {
|
||||
body?: string
|
||||
mediaAssetId: string
|
||||
mediaDurationMs?: number
|
||||
messageType: SmsAttachmentType
|
||||
|
||||
@@ -15,6 +15,7 @@ export type PhoneContact = {
|
||||
canMessage?: boolean
|
||||
companyId?: string
|
||||
created_at?: string
|
||||
email?: string | null
|
||||
favorite?: boolean | number
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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\(\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user