Files
houseplan-card/src/support-feedback.ts
T
2026-09-02 21:25:30 +03:00

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);
}