vojo/apps/widget-whatsapp/src/ui.tsx

205 lines
7.1 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Shared visual vocabulary: inline SVG icons (stroke-only, currentColor so
// they inherit the Dawn palette), the initials avatar, and the command-card
// building blocks reused across the login and contacts surfaces.
//
// Visual canon: «Боты» mockup at
// docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
// (BotsDesktop) — Dawn palette, fleet-violet accent, letter avatars,
// friendly cards, no terminal styling.
import type { ComponentChildren } from 'preact';
export const RefreshIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M3.5 8.5a6.5 6.5 0 0 1 11.4-3.2" stroke-linecap="round" />
<path d="M16.5 11.5a6.5 6.5 0 0 1-11.4 3.2" stroke-linecap="round" />
<path d="M14.6 3.2v3.5h-3.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5.4 16.8v-3.5h3.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Triangle warning glyph — leads the WhatsApp-only About card (which
// carries `command-card warn` for the amber outline) and re-appears inside
// the AboutModal's risk-disclosure callout. Stroke-only so it picks up the
// amber tint via `currentColor` in either context.
export const WarningIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M10 3.2 L17.5 16.5 L2.5 16.5 Z" stroke-linecap="round" stroke-linejoin="round" />
<path d="M10 8.5 L10 12" stroke-linecap="round" />
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
export const PhoneIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
</svg>
);
export const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
export const SearchIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="9" cy="9" r="5.5" />
<line x1="13.2" y1="13.2" x2="17" y2="17" stroke-linecap="round" />
</svg>
);
export const BackIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12.5 4 L6.5 10 L12.5 16" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
export const UserIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="10" cy="6.8" r="3.3" />
<path d="M3.8 17c.9-3 3.3-4.6 6.2-4.6s5.3 1.6 6.2 4.6" stroke-linecap="round" />
</svg>
);
export const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// --- Initials avatar --------------------------------------------------------
// Dawn accent rotation (same hues the design mock assigns to bot list
// entries). Hash by codepoints so a contact keeps its color across loads.
const AVATAR_COLORS = ['#9580ff', '#7ab6d9', '#d4b88a', '#7dd3a8', '#c08e7b', '#a59cff'];
const hashString = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i += 1) {
h = (h * 31 + s.charCodeAt(i)) | 0; // eslint-disable-line no-bitwise
}
return Math.abs(h);
};
export const avatarColor = (key: string): string =>
AVATAR_COLORS[hashString(key) % AVATAR_COLORS.length];
export const initialsOf = (name: string | undefined, fallback = '?'): string => {
const trimmed = (name ?? '').trim();
if (!trimmed) return fallback;
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return trimmed.slice(0, 1).toUpperCase();
};
type AvatarProps = { name?: string; colorKey: string; size?: number; src?: string | null };
// Initials always render underneath; the photo (when the MSC4039 download
// resolved) layers on top, so a slow or failed download degrades to the
// colored-letter look instead of an empty box.
export const Avatar = ({ name, colorKey, size = 38, src }: AvatarProps) => (
<span
class="avatar"
aria-hidden="true"
style={{
width: `${size}px`,
height: `${size}px`,
fontSize: `${Math.round(size * 0.42)}px`,
background: avatarColor(colorKey),
}}
>
{initialsOf(name)}
{src ? <img class="avatar-img" src={src} alt="" loading="lazy" /> : null}
</span>
);
// --- Command card -----------------------------------------------------------
type CommandCardProps = {
icon: ComponentChildren;
name: string;
desc: string;
onClick: () => void;
danger?: boolean;
/** Amber accent — the WhatsApp About card signalling the Meta-ToS risk
* disclosure behind it. */
warn?: boolean;
disabled?: boolean;
spinning?: boolean;
};
export const CommandCard = ({
icon,
name,
desc,
onClick,
danger,
warn,
disabled,
spinning,
}: CommandCardProps) => (
<button
class={`command-card${danger ? ' danger' : ''}${warn ? ' warn' : ''}${
spinning ? ' refreshing' : ''
}`}
type="button"
onClick={onClick}
disabled={disabled}
>
<span class="command-card-lead-icon" aria-hidden="true">
{icon}
</span>
<div class="command-card-body">
<div class="command-card-name">{name}</div>
<div class="command-card-desc">{desc}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
// --- Status / notice --------------------------------------------------------
type StatusPillProps = {
tone: 'connected' | 'disconnected' | 'checking';
children: ComponentChildren;
};
export const StatusPill = ({ tone, children }: StatusPillProps) => (
<span class={`section-status ${tone}`} role="status">
<span class="dot" />
{children}
</span>
);
type NoticeProps = {
tone: 'error' | 'warn' | 'info';
children: ComponentChildren;
onDismiss?: () => void;
};
// Inline notice strip — the replacement for the old transcript pane. One
// line of feedback right where the action happened, dismissable, no log.
export const Notice = ({ tone, children, onDismiss }: NoticeProps) => (
<div class={`notice ${tone}`} role={tone === 'error' ? 'alert' : 'status'}>
<span class="notice-body">{children}</span>
{onDismiss ? (
<button type="button" class="notice-dismiss" onClick={onDismiss} aria-label="×">
×
</button>
) : null}
</div>
);
export const Spinner = () => <span class="spinner" aria-hidden="true" />;