mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ENH - complete Mail and Radio Sky UI
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}"`)
|
||||
}
|
||||
})
|
||||
})
|
||||
+1239
-322
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,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user