mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
/** Pure state/validation helpers for the lazy Help & Feedback dialog (#43). */
|
|
|
|
export const SUPPORT_MESSAGE_LIMIT = 10_000;
|
|
export const SUPPORT_CONTACT_LIMIT = 320;
|
|
export const SUPPORT_API_VERSION = 1;
|
|
|
|
/** Exact protocol capability gate; product release versions are diagnostic only. */
|
|
export function supportApiCompatible(value: unknown): boolean {
|
|
return value === SUPPORT_API_VERSION;
|
|
}
|
|
|
|
export interface SupportPreview {
|
|
token: string;
|
|
expiresAt: number;
|
|
size: number;
|
|
sha256: string;
|
|
spaces: number;
|
|
format: string;
|
|
version: number;
|
|
text: string;
|
|
preparedAt: number;
|
|
}
|
|
|
|
export type SupportDialogStatus =
|
|
| 'idle'
|
|
| 'building'
|
|
| 'ready'
|
|
| 'sending'
|
|
| 'success'
|
|
| 'error';
|
|
|
|
export interface SupportDialogState {
|
|
draftId: string;
|
|
idempotencyKey: string;
|
|
submissionFingerprint: string;
|
|
contact: string;
|
|
message: string;
|
|
attach: boolean;
|
|
status: SupportDialogStatus;
|
|
preview: SupportPreview | null;
|
|
rawOpen: boolean;
|
|
errorCode: string;
|
|
reportId: string;
|
|
}
|
|
|
|
export interface SupportRuntimeFacts {
|
|
browser_family: 'chromium' | 'firefox' | 'webkit' | 'unknown';
|
|
browser_major: number;
|
|
language: 'en' | 'ru' | 'de' | 'fr';
|
|
coarse_pointer: boolean;
|
|
hover_capable: boolean;
|
|
registry_access: 'full' | 'partial' | 'unavailable';
|
|
registry_age_bucket: 'fresh' | 'stale' | 'unknown';
|
|
}
|
|
|
|
function randomId(prefix: string): string {
|
|
const id = globalThis.crypto?.randomUUID?.()
|
|
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
return `${prefix}-${id}`;
|
|
}
|
|
|
|
export function supportSubmissionFingerprint(state: SupportDialogState): string {
|
|
return JSON.stringify({
|
|
message: state.message.trim(),
|
|
contact: state.contact.trim(),
|
|
previewToken: state.attach && state.preview ? state.preview.token : '',
|
|
});
|
|
}
|
|
|
|
export function supportSubmissionIdentity(
|
|
state: SupportDialogState,
|
|
): { idempotencyKey: string; fingerprint: string } {
|
|
const fingerprint = supportSubmissionFingerprint(state);
|
|
return {
|
|
idempotencyKey: state.submissionFingerprint
|
|
&& state.submissionFingerprint !== fingerprint
|
|
? randomId('report')
|
|
: state.idempotencyKey,
|
|
fingerprint,
|
|
};
|
|
}
|
|
|
|
export function newSupportDialogState(): SupportDialogState {
|
|
return {
|
|
draftId: randomId('draft'),
|
|
idempotencyKey: randomId('report'),
|
|
submissionFingerprint: '',
|
|
contact: '',
|
|
message: '',
|
|
attach: false,
|
|
status: 'idle',
|
|
preview: null,
|
|
rawOpen: false,
|
|
errorCode: '',
|
|
reportId: '',
|
|
};
|
|
}
|
|
|
|
export function codePointLength(value: string): number {
|
|
return [...value].length;
|
|
}
|
|
|
|
export type SupportDraftError =
|
|
| 'message_required'
|
|
| 'message_too_long'
|
|
| 'contact_too_long'
|
|
| 'preview_missing'
|
|
| 'preview_expired'
|
|
| null;
|
|
|
|
export function supportDraftError(state: SupportDialogState, now = Date.now()): SupportDraftError {
|
|
const message = state.message.trim();
|
|
if (!message) return 'message_required';
|
|
if (codePointLength(message) > SUPPORT_MESSAGE_LIMIT) return 'message_too_long';
|
|
if (codePointLength(state.contact.trim()) > SUPPORT_CONTACT_LIMIT) return 'contact_too_long';
|
|
if (state.attach && !state.preview) return 'preview_missing';
|
|
if (state.attach && state.preview!.expiresAt <= now) return 'preview_expired';
|
|
return null;
|
|
}
|
|
|
|
export function supportCanSubmit(state: SupportDialogState, now = Date.now()): boolean {
|
|
return state.status !== 'building'
|
|
&& state.status !== 'sending'
|
|
&& state.status !== 'success'
|
|
&& supportDraftError(state, now) === null;
|
|
}
|
|
|
|
function browserOf(userAgent: string): { family: SupportRuntimeFacts['browser_family']; major: number } {
|
|
const firefox = /Firefox\/(\d+)/i.exec(userAgent);
|
|
if (firefox) return { family: 'firefox', major: Number(firefox[1]) || 0 };
|
|
const chromium = /(?:Chrome|Chromium|Edg)\/(\d+)/i.exec(userAgent);
|
|
if (chromium) return { family: 'chromium', major: Number(chromium[1]) || 0 };
|
|
const webkit = /Version\/(\d+).+Safari\//i.exec(userAgent);
|
|
if (webkit) return { family: 'webkit', major: Number(webkit[1]) || 0 };
|
|
return { family: 'unknown', major: 0 };
|
|
}
|
|
|
|
export function supportRuntimeFacts(
|
|
options: {
|
|
userAgent?: string;
|
|
language: string;
|
|
coarsePointer?: boolean;
|
|
hoverCapable?: boolean;
|
|
registryAccess: 'pending' | 'full' | 'limited';
|
|
registryLastSuccess?: number;
|
|
now?: number;
|
|
},
|
|
): SupportRuntimeFacts {
|
|
const browser = browserOf(options.userAgent ?? globalThis.navigator?.userAgent ?? '');
|
|
const now = options.now ?? Date.now();
|
|
const last = Number(options.registryLastSuccess || 0);
|
|
const age = last <= 0 ? 'unknown' : now - last <= 10 * 60_000 ? 'fresh' : 'stale';
|
|
const languages: readonly string[] = ['en', 'ru', 'de', 'fr'];
|
|
const language = languages.includes(options.language)
|
|
? options.language as SupportRuntimeFacts['language'] : 'en';
|
|
const media = globalThis.matchMedia?.bind(globalThis);
|
|
return {
|
|
browser_family: browser.family,
|
|
browser_major: Math.max(0, Math.min(999, browser.major)),
|
|
language,
|
|
coarse_pointer: options.coarsePointer ?? !!media?.('(pointer: coarse)').matches,
|
|
hover_capable: options.hoverCapable ?? !!media?.('(hover: hover)').matches,
|
|
registry_access: options.registryAccess === 'full'
|
|
? 'full' : options.registryAccess === 'limited' ? 'partial' : 'unavailable',
|
|
registry_age_bucket: age,
|
|
};
|
|
}
|
|
|
|
export function supportErrorCode(value: unknown): string {
|
|
const code = value && typeof value === 'object' && 'code' in value
|
|
? String((value as { code?: unknown }).code || '') : '';
|
|
return new Set([
|
|
'support_invalid_message', 'support_preview_expired', 'support_package_too_large',
|
|
'support_rate_limited', 'support_unavailable', 'support_rejected', 'unauthorized',
|
|
]).has(code) ? code : 'support_unavailable';
|
|
}
|
|
|
|
export function supportSizeKiB(size: number): string {
|
|
return (Math.max(0, size) / 1024).toFixed(size < 10 * 1024 ? 1 : 0);
|
|
}
|