ENH - complete Mail and Radio Sky UI

This commit is contained in:
DerEchteAlec
2026-08-16 22:51:36 +02:00
parent c5c9c33c2e
commit 0d351f9743
18 changed files with 2547 additions and 465 deletions
+153 -113
View File
@@ -15,6 +15,8 @@ import {
} from 'lucide-vue-next'
import { onBeforeUnmount, watch } from 'vue'
import { SkyButton, SkyIcon, SkyToolbar, SkyToolbarPane } from '@/ui'
export type MailEditorLabels = {
bold: string
bulletList: string
@@ -22,6 +24,7 @@ export type MailEditorLabels = {
numberedList: string
quote: string
redo: string
toolbar: string
undo: string
}
@@ -41,6 +44,7 @@ const props = withDefaults(
numberedList: 'Numbered list',
quote: 'Quote',
redo: 'Redo',
toolbar: 'Formatting tools',
undo: 'Undo',
}),
placeholder: '',
@@ -111,126 +115,159 @@ onBeforeUnmount(() => editor.value?.destroy())
<template>
<div class="mail-editor" :class="{ 'mail-editor--readonly': !editable }">
<div v-if="editable && editor" class="mail-editor__toolbar">
<button
type="button"
:class="{ 'is-active': editor.isActive('bold') }"
:aria-label="labels.bold"
:title="labels.bold"
@click="editor.chain().focus().toggleBold().run()"
>
<Bold :size="17" />
</button>
<button
type="button"
:class="{ 'is-active': editor.isActive('italic') }"
:aria-label="labels.italic"
:title="labels.italic"
@click="editor.chain().focus().toggleItalic().run()"
>
<Italic :size="17" />
</button>
<button
type="button"
:class="{ 'is-active': editor.isActive('bulletList') }"
:aria-label="labels.bulletList"
:title="labels.bulletList"
@click="editor.chain().focus().toggleBulletList().run()"
>
<List :size="17" />
</button>
<button
type="button"
:class="{ 'is-active': editor.isActive('orderedList') }"
:aria-label="labels.numberedList"
:title="labels.numberedList"
@click="editor.chain().focus().toggleOrderedList().run()"
>
<ListOrdered :size="17" />
</button>
<button
type="button"
:class="{ 'is-active': editor.isActive('blockquote') }"
:aria-label="labels.quote"
:title="labels.quote"
@click="editor.chain().focus().toggleBlockquote().run()"
>
<Quote :size="17" />
</button>
<span class="mail-editor__toolbar-spacer" />
<button
type="button"
:disabled="!editor.can().chain().focus().undo().run()"
:aria-label="labels.undo"
:title="labels.undo"
@click="editor.chain().focus().undo().run()"
>
<Undo2 :size="17" />
</button>
<button
type="button"
:disabled="!editor.can().chain().focus().redo().run()"
:aria-label="labels.redo"
:title="labels.redo"
@click="editor.chain().focus().redo().run()"
>
<Redo2 :size="17" />
</button>
</div>
<EditorContent v-if="editor" :editor="editor" />
<EditorContent
v-if="editor"
class="mail-editor__content"
:editor="editor"
/>
<SkyToolbar
v-if="editable && editor"
class="mail-editor__tools"
component="footer"
:aria-label="labels.toolbar"
>
<SkyToolbarPane class="mail-editor__tool-pane">
<SkyButton
class="mail-editor__tool"
:class="{
'mail-editor__tool--active': editor.isActive('bold'),
}"
variant="plain"
icon-only
:aria-label="labels.bold"
:aria-pressed="editor.isActive('bold')"
@click="editor.chain().focus().toggleBold().run()"
>
<SkyIcon :size="20"><Bold /></SkyIcon>
</SkyButton>
<SkyButton
class="mail-editor__tool"
:class="{
'mail-editor__tool--active': editor.isActive('italic'),
}"
variant="plain"
icon-only
:aria-label="labels.italic"
:aria-pressed="editor.isActive('italic')"
@click="editor.chain().focus().toggleItalic().run()"
>
<SkyIcon :size="20"><Italic /></SkyIcon>
</SkyButton>
<SkyButton
class="mail-editor__tool"
:class="{
'mail-editor__tool--active': editor.isActive('bulletList'),
}"
variant="plain"
icon-only
:aria-label="labels.bulletList"
:aria-pressed="editor.isActive('bulletList')"
@click="editor.chain().focus().toggleBulletList().run()"
>
<SkyIcon :size="20"><List /></SkyIcon>
</SkyButton>
<SkyButton
class="mail-editor__tool"
:class="{
'mail-editor__tool--active': editor.isActive('orderedList'),
}"
variant="plain"
icon-only
:aria-label="labels.numberedList"
:aria-pressed="editor.isActive('orderedList')"
@click="editor.chain().focus().toggleOrderedList().run()"
>
<SkyIcon :size="20"><ListOrdered /></SkyIcon>
</SkyButton>
<SkyButton
class="mail-editor__tool"
:class="{
'mail-editor__tool--active': editor.isActive('blockquote'),
}"
variant="plain"
icon-only
:aria-label="labels.quote"
:aria-pressed="editor.isActive('blockquote')"
@click="editor.chain().focus().toggleBlockquote().run()"
>
<SkyIcon :size="20"><Quote /></SkyIcon>
</SkyButton>
<SkyButton
class="mail-editor__tool"
variant="plain"
icon-only
:disabled="!editor.can().chain().focus().undo().run()"
:aria-label="labels.undo"
@click="editor.chain().focus().undo().run()"
>
<SkyIcon :size="20"><Undo2 /></SkyIcon>
</SkyButton>
<SkyButton
class="mail-editor__tool"
variant="plain"
icon-only
:disabled="!editor.can().chain().focus().redo().run()"
:aria-label="labels.redo"
@click="editor.chain().focus().redo().run()"
>
<SkyIcon :size="20"><Redo2 /></SkyIcon>
</SkyButton>
</SkyToolbarPane>
</SkyToolbar>
</div>
</template>
<style scoped>
.mail-editor {
min-height: 210px;
color: #f5f5f7;
}
.mail-editor__toolbar {
position: sticky;
z-index: 2;
top: 0;
width: 100%;
min-height: 0;
height: 100%;
display: flex;
align-items: center;
gap: 3px;
min-height: 43px;
padding: 5px 7px;
border-bottom: 1px solid #ffffff14;
background: #1c1c1ee8;
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
flex-direction: column;
overflow: hidden;
background: var(--sky-bg);
color: var(--sky-text);
}
.mail-editor__toolbar button {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border: 0;
border-radius: 9px;
background: transparent;
color: #f5f5f7;
cursor: pointer;
}
.mail-editor__toolbar button.is-active {
background: #0a84ff;
color: #fff;
}
.mail-editor__toolbar button:disabled {
opacity: 0.28;
cursor: default;
}
.mail-editor__toolbar-spacer {
.mail-editor__content {
min-height: 0;
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.mail-editor__content::-webkit-scrollbar {
display: none;
}
.mail-editor__tools {
padding-right: var(--sky-space-3);
padding-left: var(--sky-space-3);
}
.mail-editor__tools :deep(.sky-toolbar__inner),
.mail-editor__tool-pane {
width: 100%;
}
.mail-editor__tool {
min-width: var(--sky-touch-target);
min-height: var(--sky-touch-target);
flex: 1 1 0;
border-radius: 0;
background: transparent;
color: var(--sky-text);
}
.mail-editor__tool--active {
background: transparent;
color: var(--sky-app-accent);
}
:deep(.tiptap) {
min-height: 210px;
padding: 15px 16px 92px;
min-height: 100%;
padding: 15px 16px 124px;
outline: none;
font-size: 15px;
line-height: 1.5;
@@ -238,7 +275,14 @@ onBeforeUnmount(() => editor.value?.destroy())
}
.mail-editor--readonly {
height: auto;
display: block;
min-height: 0;
overflow: visible;
}
.mail-editor--readonly .mail-editor__content {
overflow: visible;
}
.mail-editor--readonly :deep(.tiptap) {
@@ -248,11 +292,7 @@ onBeforeUnmount(() => editor.value?.destroy())
}
:deep(.tiptap p) {
margin: 0 0 0.8em;
}
:deep(.tiptap p:last-child) {
margin-bottom: 0;
margin: 0;
}
:deep(.tiptap h2),
+96
View File
@@ -18,6 +18,7 @@ const counts: MailCounts = {
trash: 4,
unread: 1,
}
const noMailboxes = { mailboxes: [] }
function listItem(id: number): MailListItem {
return {
@@ -161,6 +162,7 @@ describe('mail store', () => {
success: true,
})
.mockResolvedValueOnce({ data: counts, success: true })
.mockResolvedValueOnce({ data: noMailboxes, success: true })
.mockResolvedValueOnce({
data: { hasMore: true, items: [listItem(1)] },
success: true,
@@ -183,6 +185,7 @@ describe('mail store', () => {
it('removes loaded cloud mail when the device becomes unlinked', async () => {
mockNuiCall
.mockResolvedValueOnce({ data: counts, success: true })
.mockResolvedValueOnce({ data: noMailboxes, success: true })
.mockResolvedValueOnce({
data: { hasMore: true, items: [listItem(1)] },
success: true,
@@ -234,6 +237,10 @@ describe('mail store', () => {
resolveCounts = resolve
}),
)
mockNuiCall.mockResolvedValueOnce({
data: noMailboxes,
success: true,
})
const mail = useMailStore()
const bootstrap = mail.bootstrap('alex@ifruit.com')
@@ -282,6 +289,7 @@ describe('mail store', () => {
}),
)
.mockResolvedValueOnce({ data: counts, success: true })
.mockResolvedValueOnce({ data: noMailboxes, success: true })
const mail = useMailStore()
const account = useAccountStore()
@@ -297,4 +305,92 @@ describe('mail store', () => {
expect(mail.accountEmail).toBe('morgan@ifruit.com')
expect(account.email).toBe('morgan@ifruit.com')
})
it('creates an account-owned mailbox and refreshes the mailbox list', async () => {
mockNuiCall
.mockResolvedValueOnce({
data: { count: 0, id: 7, name: 'Projects', sort_order: 1 },
success: true,
})
.mockResolvedValueOnce({
data: {
mailboxes: [{ count: 0, id: 7, name: 'Projects', sort_order: 1 }],
},
success: true,
})
const mail = useMailStore()
const response = await mail.createMailbox('Projects')
expect(response.success).toBe(true)
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'mail:create-mailbox', {
name: 'Projects',
})
expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'mail:mailboxes')
expect(mail.mailboxes).toEqual([
{ count: 0, id: 7, name: 'Projects', sort_order: 1 },
])
})
it('loads custom mailbox folders through the regular list contract', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { hasMore: false, items: [listItem(4)] },
success: true,
})
const mail = useMailStore()
expect(await mail.loadFolder('mailbox:7')).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('mail:list', {
folder: 'mailbox:7',
offset: 0,
search: '',
})
expect(mail.folder).toBe('mailbox:7')
})
it('keeps active mail filters in the paginated server request', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { hasMore: false, items: [listItem(5)] },
success: true,
})
const mail = useMailStore()
mail.setListFilters({
address: 'to-me',
direction: 'all',
read: 'unread',
today: true,
})
expect(await mail.loadFolder('inbox')).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('mail:list', {
filters: {
address: 'to-me',
direction: 'all',
read: 'unread',
today: true,
},
folder: 'inbox',
offset: 0,
search: '',
})
})
it('moves a custom mailbox entry back to its original built-in folder', async () => {
mockNuiCall
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ data: counts, success: true })
.mockResolvedValueOnce({ data: noMailboxes, success: true })
.mockResolvedValueOnce({
data: { hasMore: false, items: [] },
success: true,
})
const mail = useMailStore()
mail.folder = 'mailbox:7'
expect(await mail.moveEntry(4, null)).toBe(true)
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'mail:move', {
id: 4,
mailboxId: 0,
})
})
})
+86 -4
View File
@@ -7,9 +7,12 @@ import type {
MailComposeDraft,
MailCounts,
MailDraft,
MailFolder,
MailFolderKey,
MailListItem,
MailListFilters,
MailListResponse,
MailMailbox,
MailMailboxesResponse,
MailMessage,
} from '@/types/mail'
import { nuiCall } from '@/utils/nui'
@@ -21,15 +24,23 @@ const emptyCounts = (): MailCounts => ({
trash: 0,
unread: 0,
})
const emptyListFilters = (): MailListFilters => ({
address: 'all',
direction: 'all',
read: 'all',
today: false,
})
export const useMailStore = defineStore('mail', () => {
const account = useAccountStore()
const accountEmail = ref('')
const counts = ref<MailCounts>(emptyCounts())
const folder = ref<MailFolder>('inbox')
const folder = ref<MailFolderKey>('inbox')
const hasMore = ref(false)
const items = ref<MailListItem[]>([])
const loading = ref(false)
const listFilters = ref<MailListFilters>(emptyListFilters())
const mailboxes = ref<MailMailbox[]>([])
const search = ref('')
let authenticationGeneration = 0
let folderRequestGeneration = 0
@@ -41,6 +52,8 @@ export const useMailStore = defineStore('mail', () => {
folderRequestGeneration += 1
accountEmail.value = ''
counts.value = emptyCounts()
listFilters.value = emptyListFilters()
mailboxes.value = []
items.value = []
hasMore.value = false
folder.value = 'inbox'
@@ -57,7 +70,7 @@ export const useMailStore = defineStore('mail', () => {
sessionGeneration += 1
folderRequestGeneration += 1
accountEmail.value = email
await refreshCounts()
await Promise.all([refreshCounts(), loadMailboxes()])
}
async function login(email: string, password: string) {
@@ -106,7 +119,7 @@ export const useMailStore = defineStore('mail', () => {
}
async function loadFolder(
nextFolder: MailFolder,
nextFolder: MailFolderKey,
nextSearch = '',
append = false,
): Promise<boolean> {
@@ -114,10 +127,16 @@ export const useMailStore = defineStore('mail', () => {
const session = sessionGeneration
loading.value = true
const offset = append ? items.value.length : 0
const hasFilters =
listFilters.value.address !== 'all' ||
listFilters.value.direction !== 'all' ||
listFilters.value.read !== 'all' ||
listFilters.value.today
const response = await nuiCall<MailListResponse>('mail:list', {
folder: nextFolder,
offset,
search: nextSearch,
...(hasFilters ? { filters: { ...listFilters.value } } : {}),
})
if (generation === folderRequestGeneration) loading.value = false
if (
@@ -151,6 +170,60 @@ export const useMailStore = defineStore('mail', () => {
}
}
async function loadMailboxes(): Promise<boolean> {
const email = accountEmail.value
const session = sessionGeneration
const response = await nuiCall<MailMailboxesResponse>('mail:mailboxes')
if (
session !== sessionGeneration ||
email !== accountEmail.value ||
!response.success ||
!response.data
) {
return false
}
mailboxes.value = response.data.mailboxes
return true
}
async function createMailbox(name: string) {
const session = sessionGeneration
const response = await nuiCall<MailMailbox>('mail:create-mailbox', { name })
if (response.success && session === sessionGeneration) {
await loadMailboxes()
}
return response
}
async function deleteMailbox(id: number): Promise<boolean> {
const session = sessionGeneration
const response = await nuiCall('mail:delete-mailbox', { id })
if (response.success && session === sessionGeneration) {
await Promise.all([refreshCounts(), loadMailboxes()])
}
return response.success
}
async function moveEntry(
id: number,
mailboxId: number | null,
): Promise<boolean> {
const session = sessionGeneration
const response = await nuiCall('mail:move', {
id,
mailboxId: mailboxId ?? 0,
})
if (response.success && session === sessionGeneration) {
await Promise.all([
refreshCounts(),
loadMailboxes(),
loadFolder(folder.value, search.value),
])
}
return response.success
}
async function openMessage(id: number): Promise<MailMessage | null> {
const response = await nuiCall<MailMessage>('mail:get', { id })
if (!response.success || !response.data) return null
@@ -236,16 +309,22 @@ export const useMailStore = defineStore('mail', () => {
accountEmail,
bootstrap,
counts,
createMailbox,
deleteDraft,
deleteMailbox,
deleteMany,
emptyTrash,
folder,
hasMore,
items,
loadFolder,
loadMailboxes,
listFilters,
loading,
login,
logout,
mailboxes,
moveEntry,
mutateEntry,
openDraft,
openMessage,
@@ -254,6 +333,9 @@ export const useMailStore = defineStore('mail', () => {
saveDraft,
search,
send,
setListFilters(nextFilters: MailListFilters) {
listFilters.value = { ...nextFilters }
},
setCounts(nextCounts: MailCounts) {
counts.value = nextCounts
},
+26
View File
@@ -29,4 +29,30 @@ describe('phone locale fallback', () => {
'Remove from favorites',
)
})
it('keeps mailbox and filter copy translated with a partial server locale', () => {
const phone = usePhoneStore()
phone.open({ locales: { Apps: { mail: { name: 'Mail' } } } })
expect(phone.t('Apps.mail.newMailbox')).toBe('New Mailbox')
expect(phone.t('Apps.mail.mailboxNamePlaceholder')).toBe('Mailbox name')
expect(phone.t('Apps.mail.filterTitle')).toBe('Filter')
expect(phone.t('Apps.mail.filterUnreadLabel')).toBe('Unread')
expect(phone.t('Apps.mail.errors.mailbox_exists')).toBe(
'A mailbox with that name already exists.',
)
})
it('keeps provider capability copy translated with a partial server locale', () => {
const phone = usePhoneStore()
phone.open({ locales: { Apps: { radio: { name: 'Radio' } } } })
expect(phone.t('Apps.radio.notSupported')).toBe('Not supported')
expect(phone.t('Apps.radio.providerFeatureUnavailable')).toBe(
'Not supported by this voice service',
)
expect(phone.t('Apps.radio.displayNamePlaceholder')).toBe(
'Character name',
)
})
})
+37 -2
View File
@@ -2162,6 +2162,8 @@ const defaultLocales: LocaleTree = {
disconnect: 'Disconnect',
speaker: 'Speaker',
speakerDescription: 'Play radio traffic through speaker mode',
providerFeatureUnavailable: 'Not supported by this voice service',
notSupported: 'Not supported',
members: 'Currently connected ({count})',
noMembers: 'No participants',
history: 'Recently connected',
@@ -2170,9 +2172,9 @@ const defaultLocales: LocaleTree = {
badgePlaceholder: 'e.g. 231',
profileSaved: 'Radio profile saved',
displayName: 'Radio display name',
displayNamePlaceholder: 'Leave empty to use your character name',
displayNamePlaceholder: 'Character name',
displayNameDescription:
'This name is shown to other participants and in the built-in radio overlay.',
'Leave empty to use your character name. This name is shown to other participants and in the built-in radio overlay.',
displayNameNotAllowed:
'Your current job or grade is not allowed to change the radio display name.',
otherSettings: 'Other',
@@ -3618,6 +3620,17 @@ const defaultLocales: LocaleTree = {
passwordWarning:
'Use an in-character password. Do not reuse a real-world password.',
mailboxes: 'Mailboxes',
editMailboxes: 'Edit',
newMailbox: 'New Mailbox',
mailboxName: 'Name',
mailboxNamePlaceholder: 'Mailbox name',
mailboxLocation: 'Mailbox Location',
deleteMailbox: 'Delete {mailbox}',
deleteMailboxTitle: 'Delete Mailbox?',
deleteMailboxBody:
'Messages in {mailbox} will return to their original mailbox.',
moveToMailbox: 'Move to Mailbox',
movedToMailbox: 'Moved to {mailbox}.',
inbox: 'Inbox',
sent: 'Sent',
drafts: 'Drafts',
@@ -3632,6 +3645,22 @@ const defaultLocales: LocaleTree = {
messagePlaceholder: 'Write a message...',
search: 'Search mail',
messages: 'messages',
toolbar: 'Mail tools',
filterTitle: 'Filter',
filteredBy: 'Filtered by',
filterActiveLabel: 'Filtered by {filter}',
filterStatus: 'Status',
filterAddressed: 'Addressed',
filterDirection: 'Direction',
filterOther: 'Other',
filterUnreadLabel: 'Unread',
filterReadLabel: 'Read',
filterReceived: 'Received',
filterSent: 'Sent',
filterToMe: 'To Me',
filterFromMe: 'From Me',
filterToday: 'Today',
filterMultiple: '{count} Filters',
select: 'Select',
selectedCount: '{count} Selected',
deleteSelected: 'Delete Selected',
@@ -3681,6 +3710,12 @@ const defaultLocales: LocaleTree = {
not_authenticated: 'Your mail session has ended. Sign in again.',
request_failed: 'Mail is temporarily unavailable.',
invalid_request: 'The mail request was invalid.',
invalid_mailbox: 'Enter a mailbox name with up to 50 characters.',
mailbox_exists: 'A mailbox with that name already exists.',
mailbox_limit: 'You have reached the mailbox limit.',
mailbox_not_found: 'That mailbox is no longer available.',
message_not_found: 'That message is no longer available.',
invalid_filter: 'That mail filter is not available.',
default: 'The mail request failed.',
},
},
+22
View File
@@ -1,6 +1,28 @@
import type { DatabaseDateValue } from '@/utils/date'
export type MailFolder = 'inbox' | 'sent' | 'drafts' | 'trash'
export type MailFolderKey = MailFolder | `mailbox:${number}`
export type MailReadFilter = 'all' | 'read' | 'unread'
export type MailDirectionFilter = 'all' | 'inbox' | 'sent'
export type MailAddressFilter = 'all' | 'from-me' | 'to-me'
export type MailListFilters = {
address: MailAddressFilter
direction: MailDirectionFilter
read: MailReadFilter
today: boolean
}
export type MailMailbox = {
count: number
id: number
name: string
sort_order: number
}
export type MailMailboxesResponse = {
mailboxes: MailMailbox[]
}
export type MailCounts = {
drafts: number
@@ -0,0 +1,302 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./MailApp.vue', import.meta.url), 'utf8')
const editorSource = readFileSync(
new URL('../../components/MailMarkdownEditor.vue', import.meta.url),
'utf8',
)
const mailServerSource = readFileSync(
new URL('../../../../sky_phone/source/server/mail.lua', import.meta.url),
'utf8',
)
const migrationSource = readFileSync(
new URL('../../../../sky_phone/source/server/db_migrate.lua', import.meta.url),
'utf8',
)
const clientSource = readFileSync(
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
)
describe('MailApp Sky UI contract', () => {
it('uses Sky navigation for logout and mailbox back actions', () => {
expect(source).toContain('class="mail-navbar mail-folders-navbar"')
expect(source).toContain(':aria-label="phone.t(\'Apps.mail.logout\')"')
expect(source).toContain('<LogOut :size="20"')
expect(source).toContain('<sky-navbar-back-link')
expect(source).not.toContain('<header class="mail-header')
expect(source).not.toContain('class="mail-header__back"')
})
it('puts logout on the left and mailbox editing on the right', () => {
const foldersStart = source.indexOf('v-else-if="screen === \'folders\'"')
const foldersEnd = source.indexOf(
'v-else-if="screen === \'list\'"',
foldersStart,
)
const folders = source.slice(foldersStart, foldersEnd)
const left = folders.slice(
folders.indexOf('<template #left>'),
folders.indexOf('</template>', folders.indexOf('<template #left>')),
)
const right = folders.slice(
folders.indexOf('<template #right>'),
folders.indexOf('</template>', folders.indexOf('<template #right>')),
)
expect(left).toContain("phone.t('Apps.mail.logout')")
expect(right).toContain('mail-folders-navbar__edit')
expect(right).toContain("'Apps.mail.editMailboxes'")
})
it('provides a Sky UI modal to create and open custom mailboxes', () => {
const mailboxStart = source.indexOf(
'class="mail-modal mail-mailbox-modal"',
)
const mailboxEnd = source.indexOf('</sky-sheet>', mailboxStart)
const mailboxModal = source.slice(mailboxStart, mailboxEnd)
expect(source).not.toContain("screen === 'mailbox-create'")
expect(source).toContain('class="mail-modal mail-mailbox-modal"')
expect(source).toContain(':opened="mailboxCreateOpened"')
expect(source).toContain('@backdropclick="cancelMailboxCreate"')
expect(source).toContain('@escape="cancelMailboxCreate"')
expect(source).toContain('@swipeclose="cancelMailboxCreate"')
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="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('<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).not.toContain(' outline')
expect(source).toMatch(
/\.mail-mailbox-create__form\s*\{[^}]*overflow-y:\s*auto/s,
)
expect(source).toMatch(
/\.mail-mailbox-create__name\s*\{[^}]*border-radius:\s*17px[^}]*background:\s*var\(--sky-surface-variant\)/s,
)
expect(source).toMatch(
/\.mail-mailbox-modal\s+:deep\(\.sky-sheet__panel\)\s*\{[^}]*height:\s*auto[^}]*background:\s*var\(--sky-surface\)/s,
)
expect(source).toMatch(
/\.mail-mailbox-create\s*\{[^}]*height:\s*auto[^}]*background:\s*var\(--sky-surface\)/s,
)
})
it('places the Apple-style filter, Sky search and compose action together', () => {
const toolbarClass = 'class="mail-bottom-toolbar mail-list-toolbar"'
const classIndex = source.indexOf(toolbarClass)
const toolbarStart = source.lastIndexOf('<sky-toolbar', classIndex)
const toolbarEnd =
source.indexOf('</sky-toolbar>', classIndex) + '</sky-toolbar>'.length
const toolbar = source.slice(toolbarStart, toolbarEnd)
expect(toolbar).toContain('component="footer"')
expect(toolbar).toContain('@click="openMailFilters"')
expect(toolbar).toContain('<ListFilter :size="21"')
expect(toolbar).toContain('mail-filter-fab--active')
expect(toolbar).toContain("phone.t('Apps.mail.filteredBy')")
expect(toolbar).toContain('{{ activeFilterSummary }}')
expect(toolbar).toContain('<sky-searchbar')
expect(toolbar).toContain('@update:model-value="updateSearch"')
expect(toolbar).toContain('variant="neutral"')
expect(toolbar).toContain('@click="beginCompose()"')
expect(source).not.toContain('class="mail-search"')
expect(source).not.toContain('class="mail-compose-fab"')
expect(source).toMatch(
/\.mail-bottom-toolbar\s*\{[^}]*padding-bottom:\s*calc\([\s\S]*var\(--sky-space-6\)/,
)
})
it('offers multiple filter criteria in one scrolling Sky UI modal', () => {
const filterStart = source.indexOf(
'class="mail-modal mail-filter-modal"',
)
const filterEnd = source.indexOf('</sky-sheet>', filterStart)
const filterScreen = source.slice(filterStart, filterEnd)
expect(source).not.toContain("screen === 'filters'")
expect(filterScreen).toContain(':opened="filtersOpened"')
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('@click="applyMailFilters"')
expect(filterScreen).toContain("toggleDraftReadFilter('unread')")
expect(filterScreen).toContain("toggleDraftReadFilter('read')")
expect(filterScreen).toContain("toggleDraftAddressFilter('to-me')")
expect(filterScreen).toContain("toggleDraftAddressFilter('from-me')")
expect(filterScreen).toContain("toggleDraftDirectionFilter('inbox')")
expect(filterScreen).toContain("toggleDraftDirectionFilter('sent')")
expect(filterScreen).toContain(
'draftMailFilters.today = !draftMailFilters.today',
)
expect(source).toMatch(
/\.mail-filters__content\s*\{[^}]*overflow-y:\s*auto/s,
)
})
it('keeps the filtered empty state as only No Mail', () => {
expect(source).toContain('<template v-if="hasActiveFilters">')
expect(source).toContain("<h2>{{ phone.t('Apps.mail.noMail') }}</h2>")
expect(source).not.toContain('mail.search || showUnreadOnly')
})
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('m.`created_at` >= CURRENT_DATE()')
})
it('keeps authentication hints legible and placeholders muted', () => {
expect(source).toMatch(
/\.mail-auth__field input::placeholder\s*\{[^}]*color:\s*var\(--mail-muted\)[^}]*opacity:\s*0\.72/s,
)
expect(source).toMatch(
/\.mail-auth__warning\s*\{[^}]*color:\s*var\(--sky-text\)[^}]*font-size:\s*14px[^}]*font-weight:\s*500[^}]*line-height:\s*19px/s,
)
expect(source).toContain('<ShieldCheck :size="17" />')
})
it('uses icon actions in the new-message navbar', () => {
const composeStart = source.indexOf('v-else-if="screen === \'compose\'"')
const compose = source.slice(composeStart)
expect(compose).toContain(':aria-label="phone.t(\'Common.cancel\')"')
expect(compose).toContain('<X :size="21"')
expect(compose).toContain(':aria-label="phone.t(\'Common.send\')"')
expect(compose).toContain('<Send :size="20"')
expect(source).toMatch(
/\.mail-navbar\s*\{[^}]*--sky-safe-area-top:\s*calc\(46px \+ var\(--sky-space-2\)\)/s,
)
expect(source).not.toContain('deleteCurrentDraft')
expect(source).not.toContain("phone.t('Apps.mail.deleteDraft')")
})
it('keeps mail rows and navbar actions geometrically stable', () => {
expect(source).not.toContain(
'.mail-row-shell:not(.is-dragging) .mail-row:hover',
)
expect(source).toMatch(
/\.mail-row\s*\{[^}]*color:\s*var\(--sky-text\)[^}]*transition:\s*transform/s,
)
expect(source).toContain('class="mail-navbar__select"')
expect(source).toMatch(
/\.mail-navbar__select\.sky-link\)\s*\{[^}]*width:\s*auto[^}]*height:\s*var\(--sky-touch-target\)[^}]*padding:\s*0 var\(--sky-space-3\)[^}]*color:\s*#fff[^}]*font-size:\s*15px/s,
)
expect(source).toMatch(
/\.mail-navbar__select\.sky-link:focus-visible\)\s*\{[^}]*outline:\s*0[^}]*box-shadow:\s*inset 0 0 0 1px #fff/s,
)
expect(source).toMatch(
/\.sky-navbar-back-link__icon\)\s*\{[^}]*transform:\s*translateX\(2px\)/s,
)
expect(source).toMatch(
/\.mail-navbar\s+:deep\(\.sky-link\)\s*\{[^}]*color:\s*#fff/s,
)
expect(source).toMatch(
/\.mail-navbar\s+:deep\(\.sky-navbar-back-link\)\s*\{[^}]*color:\s*#fff/s,
)
expect(source).not.toMatch(
/\.mail-navbar\s+:deep\(\.sky-link\)\s*\{[^}]*color:\s*var\(--mail-blue\)/s,
)
})
})
describe('MailMarkdownEditor Sky toolbar contract', () => {
it('places formatting actions in a Sky toolbar instead of navigation', () => {
expect(editorSource.indexOf('<EditorContent')).toBeLessThan(
editorSource.indexOf('<SkyToolbar'),
)
expect(editorSource).toContain('class="mail-editor__tools"')
expect(editorSource).toContain('<SkyToolbarPane')
expect(editorSource.match(/<SkyButton\b/g)).toHaveLength(7)
expect(editorSource).toContain('<SkyIcon')
expect(editorSource).toContain(':aria-pressed="editor.isActive')
expect(editorSource).not.toContain('<SkyTabBar')
expect(editorSource).not.toContain('<SkyTabButton')
expect(editorSource).toMatch(
/\.mail-editor\s*\{[^}]*width:\s*100%[^}]*display:\s*flex[^}]*flex-direction:\s*column[^}]*overflow:\s*hidden/s,
)
expect(editorSource).toMatch(
/\.mail-editor__content\s*\{[^}]*flex:\s*1[^}]*overflow-y:\s*auto/s,
)
expect(editorSource).toMatch(
/\.mail-editor__tool--active\s*\{[^}]*background:\s*transparent[^}]*color:\s*var\(--sky-app-accent\)/s,
)
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).not.toContain(':deep(.tiptap p:last-child)')
})
})
describe('Mail list toolbar styling contract', () => {
it('keeps the outer action buttons on the same hairline as the search field', () => {
expect(source).toMatch(
/\.mail-list-toolbar\s+:deep\(\.sky-fab\)\s*\{[^}]*border:\s*1px solid var\(--sky-hairline\)/s,
)
})
it('right-aligns the mailbox compose action and keeps folders compact', () => {
expect(source).toMatch(
/\.mail-folders-toolbar\s+:deep\(\.sky-toolbar__inner\)\s*\{[^}]*width:\s*100%[^}]*justify-content:\s*flex-end/s,
)
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,
)
})
})
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(mailServerSource).toContain(
'Bridge.Callbacks.Register("sky_phone:mail:create-mailbox"',
)
expect(mailServerSource).toContain(
'Bridge.Callbacks.Register("sky_phone:mail:delete-mailbox"',
)
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` = ? AND `trashed_at` IS NULL',
)
})
it('exposes every mailbox callback through the client allowlist', () => {
for (const endpoint of [
'mail:mailboxes',
'mail:create-mailbox',
'mail:delete-mailbox',
'mail:move',
]) {
expect(clientSource).toContain(`"${endpoint}"`)
}
})
})
File diff suppressed because it is too large Load Diff
@@ -114,8 +114,45 @@ describe('RadioApp Sky UI contract', () => {
/:deep\(\.radio-speaker-content \.sky-list-item__subtitle\)\s*\{[^}]*color:\s*var\(--sky-muted\)[^}]*font-size:\s*12px[^}]*line-height:\s*16px/s,
)
expect(source).toContain(':aria-busy="radio.speakerPending || undefined"')
expect(source).toContain(
':disabled="!radio.data.connected || radio.speakerPending"',
expect(source).toContain('!radio.data.speakerSupported ||')
expect(source).toContain('!radio.data.connected ||')
expect(source).toContain('radio.speakerPending')
})
it('keeps provider capability rows stable when PMA lacks secondary and speaker support', () => {
const secondaryFieldStart = source.lastIndexOf(
'<SkyField',
source.indexOf("phone.t('Apps.radio.secondaryFrequency')"),
)
const secondaryField = source.slice(
secondaryFieldStart,
source.indexOf('</SkyField>', secondaryFieldStart),
)
const speakerRowStart = source.lastIndexOf(
'<SkyListItem',
source.indexOf("phone.t('Apps.radio.speaker')"),
)
const speakerRow = source.slice(
speakerRowStart,
source.indexOf('</SkyListItem>', speakerRowStart),
)
expect(secondaryField.slice(0, secondaryField.indexOf('>'))).not.toContain(
'v-if="radio.data.secondarySupported"',
)
expect(secondaryField).toContain(
':disabled="!radio.data.secondarySupported"',
)
expect(secondaryField).toContain("'Apps.radio.notSupported'")
expect(speakerRow).not.toContain('v-if="radio.data.speakerSupported"')
expect(speakerRow).toContain(':disabled="!radio.data.speakerSupported"')
expect(source).toContain("'Apps.radio.providerFeatureUnavailable'")
})
it('uses compact profile placeholders without dropping the full guidance', () => {
expect(source.match(/class="radio-profile-field"/g)).toHaveLength(2)
expect(source).toMatch(
/:deep\(\.radio-profile-field \.sky-field__input\)\s*\{[^}]*min-width:\s*0[^}]*font-size:\s*15px/s,
)
})
+31 -6
View File
@@ -67,6 +67,14 @@ const providerText = computed(() => {
return provider.replace(/^./, (character) => character.toLocaleUpperCase())
})
const speakerDescription = computed(() =>
phone.t(
radio.data.speakerSupported
? 'Apps.radio.speakerDescription'
: 'Apps.radio.providerFeatureUnavailable',
),
)
function parseFrequency(value: string): number {
return Number.parseFloat(value.replace(',', '.'))
}
@@ -274,12 +282,18 @@ onBeforeUnmount(() => {
</template>
</SkyField>
<SkyField
v-if="radio.data.secondarySupported"
v-model="secondaryInput"
type="number"
input-mode="decimal"
:disabled="!radio.data.secondarySupported"
:label="phone.t('Apps.radio.secondaryFrequency')"
:placeholder="phone.t('Apps.radio.optional')"
:placeholder="
phone.t(
radio.data.secondarySupported
? 'Apps.radio.optional'
: 'Apps.radio.notSupported',
)
"
:min="radio.data.frequencyMin"
:max="radio.data.frequencyMax"
:step="radio.data.frequencyStep"
@@ -287,7 +301,7 @@ onBeforeUnmount(() => {
<template #leading>
<RadioTower :size="20" aria-hidden="true" />
</template>
<template #trailing>
<template v-if="radio.data.secondarySupported" #trailing>
<span class="radio-unit">{{ phone.t('Apps.radio.mhz') }}</span>
</template>
</SkyField>
@@ -320,14 +334,14 @@ onBeforeUnmount(() => {
</template>
</SkyListItem>
<SkyListItem
v-if="radio.data.speakerSupported"
inner-class="radio-speaker-content"
media-class="radio-audio-control-icon"
:aria-busy="radio.speakerPending || undefined"
:disabled="!radio.data.speakerSupported"
:strong-title="false"
:title="phone.t('Apps.radio.speaker')"
title-font-size-ios="radio-audio-control-title"
:subtitle="phone.t('Apps.radio.speakerDescription')"
:subtitle="speakerDescription"
>
<template #media>
<Volume2 :size="20" aria-hidden="true" />
@@ -335,7 +349,11 @@ onBeforeUnmount(() => {
<template #after>
<SkyToggle
:model-value="radio.data.speakerEnabled"
:disabled="!radio.data.connected || radio.speakerPending"
:disabled="
!radio.data.speakerSupported ||
!radio.data.connected ||
radio.speakerPending
"
:aria-label="phone.t('Apps.radio.speaker')"
@update:model-value="setSpeaker"
/>
@@ -435,6 +453,7 @@ onBeforeUnmount(() => {
>
<SkyField
v-model="displayNameInput"
class="radio-profile-field"
type="text"
:aria-label="phone.t('Apps.radio.displayName')"
:readonly="!radio.data.displayNameAllowed"
@@ -450,6 +469,7 @@ onBeforeUnmount(() => {
>
<SkyField
v-model="badgeInput"
class="radio-profile-field"
type="text"
:aria-label="phone.t('Apps.radio.badge')"
:maxlength="radio.data.badgeMaxLength"
@@ -593,6 +613,11 @@ onBeforeUnmount(() => {
line-height: 16px;
}
:deep(.radio-profile-field .sky-field__input) {
min-width: 0;
font-size: 15px;
}
.radio-primary-action {
margin-top: 12px;
}
+112 -5
View File
@@ -85,6 +85,10 @@ function memoWaveform(phase = 0) {
let authenticated = true
let draft = null
let mockMailboxes = [
{ count: 0, id: 7, name: 'Projects', sort_order: 0 },
]
let nextMockMailboxId = 8
const radioData = {
badge: '231',
badgeEnabled: true,
@@ -3339,17 +3343,33 @@ function counts() {
return {
drafts: draft ? 1 : 0,
inbox: messages.filter(
(item) => item.folder === 'inbox' && !item.trashed_at,
(item) =>
item.folder === 'inbox' && !item.mailbox_id && !item.trashed_at,
).length,
sent: messages.filter(
(item) => item.folder === 'sent' && !item.mailbox_id && !item.trashed_at,
).length,
sent: messages.filter((item) => item.folder === 'sent' && !item.trashed_at)
.length,
trash: messages.filter((item) => item.trashed_at).length,
unread: messages.filter(
(item) => item.folder === 'inbox' && !item.trashed_at && !item.is_read,
(item) =>
item.folder === 'inbox' &&
!item.mailbox_id &&
!item.trashed_at &&
!item.is_read,
).length,
}
}
function mailboxViews() {
return mockMailboxes.map((mailbox) => ({
...mailbox,
count: messages.filter(
(message) =>
message.mailbox_id === mailbox.id && !message.trashed_at,
).length,
}))
}
let flareProfile = {
id: 1,
name: 'Alex',
@@ -9998,17 +10018,104 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: counts() })
return
}
if (endpoint === 'mail:mailboxes') {
response.json({ success: true, data: { mailboxes: mailboxViews() } })
return
}
if (endpoint === 'mail:create-mailbox') {
const name = String(request.body.name ?? '').trim()
if (!name || name.length > 50) {
response.json({ success: false, error: 'invalid_mailbox' })
return
}
if (
mockMailboxes.some(
(mailbox) => mailbox.name.toLowerCase() === name.toLowerCase(),
)
) {
response.json({ success: false, error: 'mailbox_exists' })
return
}
if (mockMailboxes.length >= 20) {
response.json({ success: false, error: 'mailbox_limit' })
return
}
const mailbox = {
count: 0,
id: nextMockMailboxId,
name,
sort_order: mockMailboxes.length,
}
nextMockMailboxId += 1
mockMailboxes.push(mailbox)
response.json({ success: true, data: mailbox })
return
}
if (endpoint === 'mail:delete-mailbox') {
const mailboxId = Number(request.body.id)
const mailboxIndex = mockMailboxes.findIndex(
(mailbox) => mailbox.id === mailboxId,
)
if (mailboxIndex < 0) {
response.json({ success: false, error: 'mailbox_not_found' })
return
}
mockMailboxes.splice(mailboxIndex, 1)
for (const message of messages) {
if (message.mailbox_id === mailboxId) message.mailbox_id = null
}
response.json({ success: true })
return
}
if (endpoint === 'mail:move') {
const message = messages.find(
(item) => item.id === Number(request.body.id) && !item.trashed_at,
)
const mailboxId = Number(request.body.mailboxId)
const mailboxExists = mockMailboxes.some(
(mailbox) => mailbox.id === mailboxId,
)
if (!message) {
response.json({ success: false, error: 'message_not_found' })
return
}
if (mailboxId !== 0 && !mailboxExists) {
response.json({ success: false, error: 'mailbox_not_found' })
return
}
message.mailbox_id = mailboxId || null
response.json({ success: true })
return
}
if (endpoint === 'mail:list') {
const { folder, search = '' } = request.body
const mailboxMatch = /^mailbox:(\d+)$/.exec(String(folder))
const mailboxId = mailboxMatch ? Number(mailboxMatch[1]) : null
if (
mailboxId &&
!mockMailboxes.some((mailbox) => mailbox.id === mailboxId)
) {
response.json({ success: false, error: 'mailbox_not_found' })
return
}
let items =
folder === 'drafts'
? draft
? [{ ...draft, created_at: draft.updated_at, preview: draft.body }]
: []
: mailboxId
? messages.filter(
(item) => item.mailbox_id === mailboxId && !item.trashed_at,
)
: messages.filter((item) =>
folder === 'trash'
? item.trashed_at
: item.folder === folder && !item.trashed_at,
: item.folder === folder &&
!item.mailbox_id &&
!item.trashed_at,
)
const query = String(search).toLowerCase()
if (query) {
+38
View File
@@ -39,6 +39,7 @@ const browserDataRequests = [
['housing:key-candidates', { action: 'give' }],
['mail:counts', {}],
['mail:list', { folder: 'inbox' }],
['mail:mailboxes', {}],
['map:getPlayerCoords', {}],
['map:markers', {}],
['marketplace:counts', {}],
@@ -147,6 +148,10 @@ function verifyBrowserTestData(dataByEndpoint) {
5,
)
expectItems(dataByEndpoint.get('mail:list').items, 'inbox mail', 2)
expectItems(
dataByEndpoint.get('mail:mailboxes').mailboxes,
'custom mailboxes',
)
expectItems(dataByEndpoint.get('map:markers'), 'map markers')
expectItems(
dataByEndpoint.get('marketplace:list').items,
@@ -597,6 +602,39 @@ async function verifyStatefulActions(baseUrl) {
assert.equal(payphoneCall.state, 'connected')
await expectSuccess(baseUrl, 'payphone:hangup')
const mailbox = await expectSuccess(
baseUrl,
'mail:create-mailbox',
{ name: 'Browser Test' },
true,
)
const mailboxes = await expectSuccess(
baseUrl,
'mail:mailboxes',
{},
true,
)
assert(
mailboxes.mailboxes.some((item) => item.id === mailbox.id),
'mail:create-mailbox did not update the mock mailbox list',
)
await expectSuccess(baseUrl, 'mail:move', {
id: 2,
mailboxId: mailbox.id,
})
const mailboxItems = await expectSuccess(
baseUrl,
'mail:list',
{ folder: `mailbox:${mailbox.id}` },
true,
)
assert(
mailboxItems.items.some((message) => message.id === 2),
'mail:move did not add the message to the custom mailbox',
)
await expectSuccess(baseUrl, 'mail:move', { id: 2, mailboxId: 0 })
await expectSuccess(baseUrl, 'mail:delete-mailbox', { id: mailbox.id })
const draft = await expectSuccess(
baseUrl,
'mail:save-draft',