Files
textbee/web/lib/sms.ts
isra el 9913b7cbe7 feat: rebuild the send page with recipient chips and segment counting
Brings the send screen up to the standard of the rebuilt bulk send flow.

Fixes real defects:
- The single-device auto-select never worked. It was computed in
  defaultValues from devices?.data?.length === 1, but react-hook-form reads
  defaultValues once on mount, while the devices query is still pending, so
  the condition was always false. It now preselects once devices resolve.
- Submitting before devices loaded showed "Required" on the device field,
  which then auto-filled a moment later, leaving a stale error beside a
  populated field. Submit is disabled until devices load.
- Moved off the legacy raw useQuery(['devices']) plus devices.data, the shared
  cache key with a different unwrapped shape that caused the devices?.filter
  crash previously.
- Every field used a placeholder as its label. Placeholders vanish on focus
  and are not reliably announced, so all three now have real labels.

Recipients are chips instead of a growing stack of inputs. They commit on
Enter, comma or blur, so a number typed and left uncommitted is not silently
dropped at send time. Pasting a list adds several at once.

Splitting a pasted list needed care, and an e2e caught it: splitting on
whitespace shredded "+1 (415) 555-0101" into three fragments. Only unambiguous
separators split first; whitespace splitting is a fallback for tokens that
cannot be a single number.

Also adds a segment counter, inline alerts for success and failure instead of
bare text, and a form reset after sending that keeps the device selected.

Phone helpers moved to lib/sms.ts alongside the segment helpers, so the send
page and bulk send share one tested implementation.

One test correction: the desktop footer assertion required links to sit on
exactly one row, but the row layout wraps by design, so it failed whenever
font metrics pushed a link to a second line. It now asserts the property the
design actually guarantees, that links are not one-per-row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:04:35 +03:00

75 lines
2.4 KiB
TypeScript

// SMS encoding helpers, shared by the send page, the bulk send flow and the
// reply composer so all three report segment counts identically.
export type SegmentInfo = {
length: number
segments: number
perSegment: number
// GSM-7 fits more per segment than UCS-2, which is needed for non-Latin
// characters and most emoji.
encoding: 'GSM-7' | 'UCS-2'
}
// Characters representable in the GSM 03.38 alphabet.
const GSM_7_CHARS =
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
const GSM_7_EXTENDED = '^{}\\[~]|€'
export function isGsm7(text: string): boolean {
for (let i = 0; i < text.length; i += 1) {
const char = text.charAt(i)
if (GSM_7_CHARS.indexOf(char) === -1 && GSM_7_EXTENDED.indexOf(char) === -1) {
return false
}
}
return true
}
/** Segment count for a message, so a user is not surprised by cost. */
export function getSegmentInfo(text: string): SegmentInfo {
const gsm = isGsm7(text)
const encoding = gsm ? 'GSM-7' : 'UCS-2'
const single = gsm ? 160 : 70
const multi = gsm ? 153 : 67
const length = text.length
if (length === 0) {
return { length: 0, segments: 0, perSegment: single, encoding }
}
if (length <= single) {
return { length, segments: 1, perSegment: single, encoding }
}
return {
length,
segments: Math.ceil(length / multi),
perSegment: multi,
encoding,
}
}
/**
* Strip formatting so "+1 (415) 555-0101" and "+14155550101" are recognised as
* the same recipient for duplicate detection.
*/
export function normalizePhone(value: string): string {
const trimmed = value.trim()
const hasPlus = trimmed.startsWith('+')
const digits = trimmed.replace(/\D/g, '')
return hasPlus ? `+${digits}` : digits
}
/**
* Permissive validity check. The gateway is the real authority on whether a
* number can be reached, so this only rejects input that cannot possibly be a
* phone number, rather than enforcing a strict E.164 shape that would block
* legitimate local formats.
*/
export function isPlausiblePhone(value: string): boolean {
const normalized = normalizePhone(value)
const digits = normalized.replace(/\D/g, '')
if (digits.length < 7 || digits.length > 15) return false
// Reject anything carrying characters that are neither digits, separators,
// nor a leading plus.
return /^\+?[\d\s()./-]+$/.test(value.trim())
}