mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ENH - improve mail experience and interactions
This commit is contained in:
@@ -4,6 +4,9 @@ import type { MailMessage } from '@/types/mail'
|
||||
import {
|
||||
buildForwardDraft,
|
||||
buildReplyDraft,
|
||||
filterMailAddressInput,
|
||||
filterMailRecipientInput,
|
||||
mailPlainText,
|
||||
normalizeMailAddress,
|
||||
parseMailRecipients,
|
||||
} from '@/utils/mail'
|
||||
@@ -22,6 +25,23 @@ const message: MailMessage = {
|
||||
}
|
||||
|
||||
describe('mail addresses', () => {
|
||||
it('filters account fields to the supported email character set', () => {
|
||||
expect(filterMailAddressInput('Al+ex! ä@ifruit.com<script>')).toBe(
|
||||
'Alex@ifruit.comscript',
|
||||
)
|
||||
expect(filterMailAddressInput('sky.user_name-2@ifruit.com')).toBe(
|
||||
'sky.user_name-2@ifruit.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps separators but filters recipient-list special characters', () => {
|
||||
expect(
|
||||
filterMailRecipientInput(
|
||||
'alex@ifruit.com, jamie+tag@ifruit.com; müller@ifruit.com',
|
||||
),
|
||||
).toBe('alex@ifruit.com, jamietag@ifruit.com; mller@ifruit.com')
|
||||
})
|
||||
|
||||
it('normalizes local parts and the iFruit domain', () => {
|
||||
expect(normalizeMailAddress(' Sky.User ')).toBe('sky.user@ifruit.com')
|
||||
expect(normalizeMailAddress('sky.user@ifruit.com')).toBe(
|
||||
@@ -48,12 +68,24 @@ describe('mail compose helpers', () => {
|
||||
expect(
|
||||
buildReplyDraft(message, 'alex@ifruit.com', true).recipients,
|
||||
).toEqual(['morgan@ifruit.com', 'jamie@ifruit.com'])
|
||||
expect(buildReplyDraft(message, 'alex@ifruit.com').body).toContain(
|
||||
'> Meet at Legion Square.',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds forward content and avoids duplicate subject prefixes', () => {
|
||||
const forwarded = buildForwardDraft(message)
|
||||
expect(forwarded.recipients).toEqual([])
|
||||
expect(forwarded.subject).toBe('Fwd: Plans')
|
||||
expect(forwarded.body).toContain('From: morgan@ifruit.com')
|
||||
expect(forwarded.body).toContain('**From:** morgan@ifruit.com')
|
||||
})
|
||||
|
||||
it('turns formatted markdown into a compact mailbox preview', () => {
|
||||
expect(
|
||||
mailPlainText('## Update\n\n**Ready** [details](https://ifruit.com).'),
|
||||
).toBe('Update Ready details.')
|
||||
expect(mailPlainText('<script>alert(1)</script> Hello')).toBe(
|
||||
'alert(1) Hello',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,21 @@ import type { MailComposeDraft, MailMessage } from '@/types/mail'
|
||||
|
||||
export const MAIL_DOMAIN = 'ifruit.com'
|
||||
export const MAIL_MAX_RECIPIENTS = 10
|
||||
export const MAIL_ADDRESS_INPUT_MAX_LENGTH = 64
|
||||
export const MAIL_RECIPIENT_INPUT_MAX_LENGTH =
|
||||
MAIL_MAX_RECIPIENTS * (MAIL_ADDRESS_INPUT_MAX_LENGTH + 2)
|
||||
|
||||
export function filterMailAddressInput(value: string): string {
|
||||
return value
|
||||
.replace(/[^a-z0-9@._-]/gi, '')
|
||||
.slice(0, MAIL_ADDRESS_INPUT_MAX_LENGTH)
|
||||
}
|
||||
|
||||
export function filterMailRecipientInput(value: string): string {
|
||||
return value
|
||||
.replace(/[^a-z0-9@._,; -]/gi, '')
|
||||
.slice(0, MAIL_RECIPIENT_INPUT_MAX_LENGTH)
|
||||
}
|
||||
|
||||
export function normalizeMailAddress(value: string): string | null {
|
||||
const normalized = value.trim().toLocaleLowerCase('en-US')
|
||||
@@ -43,6 +58,25 @@ export function parseMailRecipients(value: string): string[] | null {
|
||||
return recipients
|
||||
}
|
||||
|
||||
export function mailPlainText(value: string): string {
|
||||
return value
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/^\s{0,3}(?:#{1,6}|>|[-+*]|\d+\.)\s+/gm, '')
|
||||
.replace(/[*_~`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function quoteMarkdown(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function prefixedSubject(prefix: 'Re' | 'Fwd', subject: string): string {
|
||||
const clean = subject.trim()
|
||||
if (new RegExp(`^${prefix}:`, 'i').test(clean)) return clean
|
||||
@@ -62,7 +96,7 @@ export function buildReplyDraft(
|
||||
)
|
||||
|
||||
return {
|
||||
body: `\n--- ${message.sender} ---\n${message.body}`,
|
||||
body: `\n\n> **${message.sender}**\n>\n${quoteMarkdown(message.body)}`,
|
||||
recipients,
|
||||
subject: prefixedSubject('Re', message.subject),
|
||||
}
|
||||
@@ -70,7 +104,7 @@ export function buildReplyDraft(
|
||||
|
||||
export function buildForwardDraft(message: MailMessage): MailComposeDraft {
|
||||
return {
|
||||
body: `\n--- Forwarded message ---\nFrom: ${message.sender}\nTo: ${message.recipients.join(', ')}\n\n${message.body}`,
|
||||
body: `\n\n---\n\n**Forwarded message**\n\n**From:** ${message.sender}\n\n**To:** ${message.recipients.join(', ')}\n\n${message.body}`,
|
||||
recipients: [],
|
||||
subject: prefixedSubject('Fwd', message.subject),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clampMailSwipeOffset,
|
||||
MAIL_SWIPE_ACTION_WIDTH,
|
||||
resolveMailSwipeAction,
|
||||
resolveMailSwipeAxis,
|
||||
} from '@/utils/mailSwipe'
|
||||
|
||||
describe('mail swipe gestures', () => {
|
||||
it('waits for a clear horizontal or vertical direction', () => {
|
||||
expect(resolveMailSwipeAxis(5, 2)).toBeNull()
|
||||
expect(resolveMailSwipeAxis(24, 5)).toBe('horizontal')
|
||||
expect(resolveMailSwipeAxis(10, 18)).toBe('vertical')
|
||||
})
|
||||
|
||||
it('limits horizontal movement and blocks unavailable directions', () => {
|
||||
expect(clampMailSwipeOffset(70, true, true)).toBe(70)
|
||||
expect(clampMailSwipeOffset(70, false, true)).toBe(0)
|
||||
expect(clampMailSwipeOffset(-70, true, false)).toBe(0)
|
||||
expect(clampMailSwipeOffset(-240, true, true)).toBeGreaterThan(
|
||||
-MAIL_SWIPE_ACTION_WIDTH - 19,
|
||||
)
|
||||
})
|
||||
|
||||
it('only commits an action after crossing its threshold', () => {
|
||||
expect(resolveMailSwipeAction(63, true, true)).toBeNull()
|
||||
expect(resolveMailSwipeAction(64, true, true)).toBe('read')
|
||||
expect(resolveMailSwipeAction(-64, true, true)).toBe('delete')
|
||||
expect(resolveMailSwipeAction(80, false, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
export type MailSwipeAction = 'delete' | 'read'
|
||||
export type MailSwipeAxis = 'horizontal' | 'vertical'
|
||||
|
||||
export const MAIL_SWIPE_ACTION_WIDTH = 84
|
||||
export const MAIL_SWIPE_AXIS_LOCK = 8
|
||||
export const MAIL_SWIPE_TRIGGER = 64
|
||||
|
||||
export function resolveMailSwipeAxis(
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
): MailSwipeAxis | null {
|
||||
const horizontalDistance = Math.abs(deltaX)
|
||||
const verticalDistance = Math.abs(deltaY)
|
||||
if (Math.max(horizontalDistance, verticalDistance) < MAIL_SWIPE_AXIS_LOCK) {
|
||||
return null
|
||||
}
|
||||
|
||||
return horizontalDistance > verticalDistance * 1.15
|
||||
? 'horizontal'
|
||||
: 'vertical'
|
||||
}
|
||||
|
||||
export function clampMailSwipeOffset(
|
||||
deltaX: number,
|
||||
canRead: boolean,
|
||||
canDelete: boolean,
|
||||
): number {
|
||||
if ((deltaX > 0 && !canRead) || (deltaX < 0 && !canDelete)) return 0
|
||||
|
||||
const direction = Math.sign(deltaX)
|
||||
const distance = Math.abs(deltaX)
|
||||
const resistedDistance =
|
||||
distance <= MAIL_SWIPE_ACTION_WIDTH
|
||||
? distance
|
||||
: MAIL_SWIPE_ACTION_WIDTH + (distance - MAIL_SWIPE_ACTION_WIDTH) * 0.18
|
||||
|
||||
return direction * Math.min(resistedDistance, MAIL_SWIPE_ACTION_WIDTH + 18)
|
||||
}
|
||||
|
||||
export function resolveMailSwipeAction(
|
||||
offset: number,
|
||||
canRead: boolean,
|
||||
canDelete: boolean,
|
||||
): MailSwipeAction | null {
|
||||
if (canRead && offset >= MAIL_SWIPE_TRIGGER) return 'read'
|
||||
if (canDelete && offset <= -MAIL_SWIPE_TRIGGER) return 'delete'
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user