ENH - complete Mail and Radio Sky UI

This commit is contained in:
DerEchteAlec
2026-08-16 22:53:20 +02:00
parent c5c9c33c2e
commit 0d351f9743
18 changed files with 2547 additions and 465 deletions
+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.',
},
},