1// Interactive TUI email client demo โ matches real Matcha layout
2const { useState, useEffect, useRef, useCallback, useMemo } = React;
3
4// ---------- Data: folders, accounts, messages ----------
5const FOLDERS_LIST = [
6 "INBOX",
7 "Archive",
8 "Deleted Messages",
9 "Drafts",
10 "Junk",
11 "Notes",
12 "Sent Messages",
13 "Work",
14 "Receipts",
15];
16
17const ACCOUNTS_DEFAULT = [
18 { id: "all", label: "ALL", isAll: true },
19 { id: "home", label: "sam@proton.me" },
20 { id: "work", label: "s.park@kestrel.works" },
21 { id: "side", label: "hello@driftnotes.xyz" },
22 { id: "news", label: "inbox@fastmail.sam" },
23];
24
25const MSGS_DEFAULT = [
26 {
27 acct: "work",
28 from: "GitHub",
29 subject:
30 "[kestrel/hedge] PR #184 ready for review โ refactor auth middleware",
31 date: "2 min ago",
32 },
33 {
34 acct: "home",
35 from: "Linear",
36 subject: 'You were assigned KST-219 ยท "Fix timezone drift in daily digest"',
37 date: "14 min ago",
38 },
39 {
40 acct: "home",
41 from: "Margo Tran",
42 subject: "re: pickup pottery on saturday?",
43 date: "1 hour ago",
44 },
45 {
46 acct: "side",
47 from: "Stripe",
48 subject: "Your payout of $642.11 is on its way",
49 date: "3 hours ago",
50 },
51 {
52 acct: "news",
53 from: "Hacker News Digest",
54 subject: 'Top 10: "Ask HN: what are you running on your homelab in 2026?"',
55 date: "5 hours ago",
56 },
57 {
58 acct: "home",
59 from: "DMV",
60 subject: "Appointment confirmed โ Tue May 12, 10:45 AM",
61 date: "8 hours ago",
62 },
63 {
64 acct: "work",
65 from: "Figma",
66 subject: 'Ines shared a file with you: "Hedge ยท onboarding v3"',
67 date: "Yesterday",
68 },
69 {
70 acct: "home",
71 from: "Alaska Airlines",
72 subject: "Check in for flight AS 1312 โ SEA โ SFO",
73 date: "Yesterday",
74 },
75 {
76 acct: "news",
77 from: "The Browser",
78 subject: "Five long reads for a slow sunday",
79 date: "Yesterday",
80 },
81 {
82 acct: "work",
83 from: "Vercel",
84 subject: "Deploy failed โ hedge-app (main) ยท missing env DATABASE_URL",
85 date: "21/04/2026 17:12",
86 },
87 {
88 acct: "side",
89 from: "Plausible Analytics",
90 subject: "driftnotes.xyz ยท 1,204 visitors this week (+18%)",
91 date: "21/04/2026 09:02",
92 },
93 {
94 acct: "home",
95 from: "Spotify",
96 subject: "Your Discover Weekly is ready",
97 date: "20/04/2026 08:00",
98 },
99 {
100 acct: "news",
101 from: "Metafilter",
102 subject: 'MeFi Digest โ "I finally fixed my kitchen sink (a story)"',
103 date: "19/04/2026 22:44",
104 },
105 {
106 acct: "work",
107 from: "Sentry",
108 subject:
109 "[hedge-prod] New issue: TimeoutError in worker.py:128 (ร42 events)",
110 date: "18/04/2026 13:27",
111 },
112 {
113 acct: "home",
114 from: "Oliver Kim",
115 subject: "we are going to regret this tattoo idea (attached)",
116 date: "17/04/2026 23:10",
117 },
118 {
119 acct: "home",
120 from: "Patagonia",
121 subject: "Your Nano Puff has shipped โ arriving Thu Apr 25",
122 date: "16/04/2026 11:03",
123 },
124 {
125 acct: "side",
126 from: "Cloudflare",
127 subject: "Reminder: driftnotes.xyz renews in 9 days",
128 date: "15/04/2026 04:18",
129 },
130 {
131 acct: "work",
132 from: "Notion",
133 subject: 'Weekly digest: 4 pages edited in "Kestrel / Engineering"',
134 date: "14/04/2026 17:55",
135 },
136 {
137 acct: "news",
138 from: "arXiv daily",
139 subject: "cs.DC โ 6 new submissions (2026-04-13)",
140 date: "13/04/2026 06:00",
141 },
142 {
143 acct: "home",
144 from: "mom",
145 subject: "did the package get there ok??",
146 date: "12/04/2026 19:44",
147 },
148];
149
150const MSGS_DEV = [
151 {
152 acct: "work",
153 from: "GitHub",
154 subject: "[kestrel/hedge] PR #184 approved by ines-w",
155 date: "09:42",
156 },
157 {
158 acct: "work",
159 from: "Vercel",
160 subject: "Deploy succeeded โ hedge-app (main) ยท commit 9f2c31a",
161 date: "09:18",
162 },
163 {
164 acct: "work",
165 from: "Linear",
166 subject: "KST-219 moved to In Review ยท assigned to you",
167 date: "08:55",
168 },
169 {
170 acct: "work",
171 from: "Sentry",
172 subject: "[hedge-prod] Regression: 3 new issues in last 24h",
173 date: "Yesterday",
174 },
175 {
176 acct: "work",
177 from: "Cloudflare",
178 subject: "Worker deployed: hedge-edge@v4.1.0",
179 date: "Yesterday",
180 },
181 {
182 acct: "side",
183 from: "npm",
184 subject: "Your package 'haze-schedule' received 1,043 downloads",
185 date: "Mon",
186 },
187 {
188 acct: "news",
189 from: "Hacker News",
190 subject: "Show HN: A tiny CRDT you can read in one afternoon",
191 date: "Mon",
192 },
193 {
194 acct: "home",
195 from: "Stripe",
196 subject: "New payout: $428.00 USD to Mercury โขโข4411",
197 date: "Mon",
198 },
199 {
200 acct: "work",
201 from: "GitHub",
202 subject: "[kestrel/hedge] Issue #412: wrap long headers in digest",
203 date: "Sun",
204 },
205 {
206 acct: "work",
207 from: "AWS Billing",
208 subject: "April forecast: $387.22 (-12% vs Mar)",
209 date: "Sun",
210 },
211 {
212 acct: "side",
213 from: "Fly.io",
214 subject: "Machine restarted in ord: driftnotes-web (oom)",
215 date: "Sat",
216 },
217 {
218 acct: "news",
219 from: "arXiv daily",
220 subject: "cs.PL โ 4 new submissions (2026-04-20)",
221 date: "Sat",
222 },
223];
224
225const MSGS_PERSONAL = [
226 { acct: "home", from: "mom", subject: "did you eat?", date: "12:14" },
227 { acct: "home", from: "Margo", subject: "pottery saturday??", date: "09:33" },
228 {
229 acct: "home",
230 from: "REI",
231 subject: "Your order has shipped",
232 date: "10:01",
233 },
234 {
235 acct: "home",
236 from: "Strava",
237 subject: "Your week: 38km ยท 3 runs",
238 date: "Yesterday",
239 },
240 {
241 acct: "home",
242 from: "Ava (landlord)",
243 subject: "building โ water shutoff Sat 9-11am",
244 date: "Yesterday",
245 },
246 {
247 acct: "home",
248 from: "Goodreads",
249 subject: "Nadia finished 'The MANIAC'",
250 date: "Mon",
251 },
252 {
253 acct: "home",
254 from: "Calendly",
255 subject: "New booking: Coffee w/ Oliver โ Fri 3pm",
256 date: "Mon",
257 },
258 {
259 acct: "home",
260 from: "your past self",
261 subject: "rotate the sourdough starter today",
262 date: "Sun",
263 },
264 {
265 acct: "home",
266 from: "Spotify",
267 subject: "Your Spring Mix is ready",
268 date: "Sun",
269 },
270 {
271 acct: "news",
272 from: "The New Yorker",
273 subject: "The daily โ a long weekend read",
274 date: "Sat",
275 },
276];
277
278const DATASETS = {
279 default: {
280 accounts: ACCOUNTS_DEFAULT,
281 messages: MSGS_DEFAULT,
282 label: "inbox",
283 },
284 dev: { accounts: ACCOUNTS_DEFAULT, messages: MSGS_DEV, label: "work" },
285 personal: {
286 accounts: ACCOUNTS_DEFAULT,
287 messages: MSGS_PERSONAL,
288 label: "personal",
289 },
290};
291
292const EMAIL_BODIES = {
293 "re: pickup pottery on saturday?": `yes! saturday works โ 11am at the studio?
294they said the bowls finally came out of the kiln.
295
296also margo owes you coffee. i'm bringing cash.
297
298โ m`,
299 "Hello world": `Hello world!
300
301this is a simple greeting from nowhere in particular.
302just confirming the new inbox is working as expected.
303
304cheers,
305m`,
306};
307
308function bodyFor(msg) {
309 if (EMAIL_BODIES[msg.subject]) return EMAIL_BODIES[msg.subject];
310 return `[no plain-text part]
311
312This message has no rendered body in the demo.
313Press esc to return to the inbox.`;
314}
315
316// ---------- Watermark (original โ desert + running figure, NOT copyrighted) ----------
317function Watermark() {
318 return (
319 <svg
320 className="tui-watermark"
321 viewBox="0 0 1000 520"
322 aria-hidden="true"
323 preserveAspectRatio="xMidYMax meet"
324 >
325 {/* dust/star dots */}
326 {Array.from({ length: 60 }).map((_, i) => {
327 const x = (i * 173) % 1000;
328 const y = (i * 97 + 50) % 380;
329 const r = 0.8 + ((i * 13) % 5) * 0.3;
330 return (
331 <circle
332 key={i}
333 cx={x}
334 cy={y}
335 r={r}
336 fill="currentColor"
337 opacity={0.25 + ((i * 7) % 5) * 0.06}
338 />
339 );
340 })}
341 {/* ground */}
342 <path
343 d="M 0 470 L 260 470 Q 310 460 360 470 L 540 470 Q 580 482 620 470 L 1000 470"
344 stroke="currentColor"
345 strokeWidth="1.2"
346 fill="none"
347 opacity="0.35"
348 />
349 {/* original: saguaro cactus */}
350 <g transform="translate(660 300)" opacity="0.35" fill="currentColor">
351 <rect x="54" y="10" width="24" height="160" rx="4" />
352 <rect x="30" y="60" width="24" height="70" rx="4" />
353 <rect x="30" y="60" width="16" height="14" rx="3" />
354 <rect x="78" y="40" width="24" height="50" rx="4" />
355 <rect x="78" y="40" width="24" height="14" rx="3" />
356 <rect x="60" y="170" width="12" height="4" />
357 </g>
358 {/* original: little running figure (stick person) โ not a branded character */}
359 <g transform="translate(190 360)" fill="currentColor" opacity="0.32">
360 <circle cx="22" cy="14" r="11" />
361 <rect x="14" y="26" width="22" height="42" rx="4" />
362 <rect
363 x="4"
364 y="34"
365 width="14"
366 height="5"
367 rx="2"
368 transform="rotate(-18 11 36)"
369 />
370 <rect
371 x="30"
372 y="30"
373 width="14"
374 height="5"
375 rx="2"
376 transform="rotate(20 37 32)"
377 />
378 <rect
379 x="10"
380 y="68"
381 width="8"
382 height="24"
383 rx="3"
384 transform="rotate(-12 14 80)"
385 />
386 <rect
387 x="26"
388 y="68"
389 width="8"
390 height="24"
391 rx="3"
392 transform="rotate(14 30 80)"
393 />
394 </g>
395 </svg>
396 );
397}
398
399// ---------- Selection indicator char ----------
400function cursor(i, cur, visual, vStart) {
401 if (visual && vStart != null) {
402 const lo = Math.min(cur, vStart),
403 hi = Math.max(cur, vStart);
404 const inRange = i >= lo && i <= hi;
405 if (inRange && i === cur) return ">*";
406 if (inRange) return " *";
407 if (i === cur) return "> ";
408 return " ";
409 }
410 return i === cur ? "> " : " ";
411}
412
413// ---------- TUI component ----------
414function TUI({ datasetKey = "default", onKeyPressed }) {
415 const dataset = DATASETS[datasetKey] || DATASETS.default;
416 const [folderIdx, setFolderIdx] = useState(0);
417 const [acctIdx, setAcctIdx] = useState(0);
418 const [cur, setCur] = useState(0);
419 const [mode, setMode] = useState("list"); // "list" | "email" | "filter" | "visual"
420 const [filter, setFilter] = useState("");
421 const [visualStart, setVisualStart] = useState(null);
422 const [flash, setFlash] = useState("");
423 const [deleted, setDeleted] = useState(new Set()); // indices into dataset.messages
424 const containerRef = useRef(null);
425 const [focused, setFocused] = useState(false);
426
427 const messages = dataset.messages;
428 const accounts = dataset.accounts;
429 const activeAcct = accounts[acctIdx];
430
431 // Reset on dataset change
432 useEffect(() => {
433 setFolderIdx(0);
434 setAcctIdx(0);
435 setCur(0);
436 setMode("list");
437 setFilter("");
438 setVisualStart(null);
439 setDeleted(new Set());
440 setFlash(`loaded: ${dataset.label}`);
441 const t = setTimeout(() => setFlash(""), 1400);
442 return () => clearTimeout(t);
443 }, [datasetKey]);
444
445 // Visible messages
446 const visible = useMemo(() => {
447 let list = messages
448 .map((m, origIdx) => ({ ...m, origIdx }))
449 .filter((m) => !deleted.has(m.origIdx));
450 if (folderIdx === 0) {
451 if (!activeAcct.isAll)
452 list = list.filter((m) => m.acct === activeAcct.id);
453 } else {
454 // Other folders are empty in the demo
455 list = [];
456 }
457 if (filter.trim()) {
458 const q = filter.toLowerCase();
459 list = list.filter(
460 (m) =>
461 m.subject.toLowerCase().includes(q) ||
462 m.from.toLowerCase().includes(q),
463 );
464 }
465 return list;
466 }, [messages, deleted, folderIdx, activeAcct, filter]);
467
468 const selected = visible[cur] || null;
469
470 const focusMe = useCallback(() => {
471 if (containerRef.current) containerRef.current.focus();
472 }, []);
473
474 const flashFor = (msg, ms = 1200) => {
475 setFlash(msg);
476 setTimeout(() => setFlash((f) => (f === msg ? "" : f)), ms);
477 };
478
479 const doDelete = (indices) => {
480 setDeleted((prev) => {
481 const next = new Set(prev);
482 indices.forEach((i) => next.add(i));
483 return next;
484 });
485 flashFor(
486 `โ deleted ${indices.length} message${indices.length === 1 ? "" : "s"}`,
487 );
488 };
489
490 // Key handling
491 const handleKey = (e) => {
492 if (onKeyPressed) onKeyPressed();
493 const k = e.key;
494
495 if (mode === "filter") {
496 if (k === "Enter" || k === "Escape") {
497 e.preventDefault();
498 setMode("list");
499 flashFor(filter ? `filter: "${filter}"` : "filter cleared");
500 return;
501 }
502 if (k === "Backspace") {
503 e.preventDefault();
504 setFilter((f) => f.slice(0, -1));
505 return;
506 }
507 if (k.length === 1) {
508 e.preventDefault();
509 setFilter((f) => f + k);
510 return;
511 }
512 return;
513 }
514
515 if (mode === "email") {
516 if (k === "Escape") {
517 e.preventDefault();
518 setMode("list");
519 return;
520 }
521 if (k === "r") {
522 e.preventDefault();
523 flashFor("โข reply (composer)");
524 return;
525 }
526 if (k === "f") {
527 e.preventDefault();
528 flashFor("โข forward");
529 return;
530 }
531 if (k === "d") {
532 e.preventDefault();
533 if (selected) {
534 doDelete([selected.origIdx]);
535 setMode("list");
536 setCur((c) => Math.max(0, Math.min(c, visible.length - 2)));
537 }
538 return;
539 }
540 if (k === "a") {
541 e.preventDefault();
542 flashFor("โค archived");
543 setMode("list");
544 return;
545 }
546 if (k === "i") {
547 e.preventDefault();
548 flashFor("โง images toggled");
549 return;
550 }
551 return;
552 }
553
554 if (mode === "visual") {
555 if (k === "v" || k === "Escape") {
556 e.preventDefault();
557 setMode("list");
558 setVisualStart(null);
559 return;
560 }
561 if (k === "j" || k === "ArrowDown") {
562 e.preventDefault();
563 setCur((c) => Math.min(c + 1, visible.length - 1));
564 return;
565 }
566 if (k === "k" || k === "ArrowUp") {
567 e.preventDefault();
568 setCur((c) => Math.max(c - 1, 0));
569 return;
570 }
571 if (k === "d") {
572 e.preventDefault();
573 const lo = Math.min(cur, visualStart),
574 hi = Math.max(cur, visualStart);
575 const indices = visible.slice(lo, hi + 1).map((m) => m.origIdx);
576 doDelete(indices);
577 setMode("list");
578 setVisualStart(null);
579 setCur((c) =>
580 Math.max(0, Math.min(lo, visible.length - indices.length - 1)),
581 );
582 return;
583 }
584 if (k === "a") {
585 e.preventDefault();
586 flashFor("โค archived batch");
587 setMode("list");
588 setVisualStart(null);
589 return;
590 }
591 if (k === "m") {
592 e.preventDefault();
593 flashFor("โข move to folderโฆ");
594 setMode("list");
595 setVisualStart(null);
596 return;
597 }
598 return;
599 }
600
601 // list mode
602 if (k === "Escape") {
603 e.preventDefault();
604 flashFor("โ main menu");
605 return;
606 }
607 if (k === "/") {
608 e.preventDefault();
609 setMode("filter");
610 setFilter("");
611 return;
612 }
613 if (k === "j" || k === "ArrowDown") {
614 e.preventDefault();
615 setCur((c) => Math.min(c + 1, visible.length - 1));
616 return;
617 }
618 if (k === "k" || k === "ArrowUp") {
619 e.preventDefault();
620 setCur((c) => Math.max(c - 1, 0));
621 return;
622 }
623 if (k === "h" || k === "ArrowLeft") {
624 e.preventDefault();
625 setAcctIdx((i) => (i - 1 + accounts.length) % accounts.length);
626 setCur(0);
627 return;
628 }
629 if (k === "l" || k === "ArrowRight") {
630 e.preventDefault();
631 setAcctIdx((i) => (i + 1) % accounts.length);
632 setCur(0);
633 return;
634 }
635 if (k === "Tab" && !e.shiftKey) {
636 e.preventDefault();
637 setFolderIdx((i) => (i + 1) % FOLDERS_LIST.length);
638 setCur(0);
639 return;
640 }
641 if (k === "Tab" && e.shiftKey) {
642 e.preventDefault();
643 setFolderIdx((i) => (i - 1 + FOLDERS_LIST.length) % FOLDERS_LIST.length);
644 setCur(0);
645 return;
646 }
647 if (k === "Enter") {
648 e.preventDefault();
649 if (selected) setMode("email");
650 return;
651 }
652 if (k === "v") {
653 e.preventDefault();
654 if (visible.length) {
655 setMode("visual");
656 setVisualStart(cur);
657 }
658 return;
659 }
660 if (k === "d") {
661 e.preventDefault();
662 if (selected) {
663 doDelete([selected.origIdx]);
664 setCur((c) => Math.max(0, Math.min(c, visible.length - 2)));
665 }
666 return;
667 }
668 if (k === "a") {
669 e.preventDefault();
670 flashFor("โค archived");
671 return;
672 }
673 if (k === "r") {
674 e.preventDefault();
675 flashFor("โป inbox refreshed");
676 return;
677 }
678 if (k === "q") {
679 e.preventDefault();
680 flashFor("quit");
681 return;
682 }
683 };
684
685 const titleSuffix =
686 mode === "visual"
687 ? ` - VISUAL (${Math.abs(cur - (visualStart ?? cur)) + 1} selected)`
688 : "";
689 const folderLabel = FOLDERS_LIST[folderIdx];
690 const isInbox = folderIdx === 0;
691 const acctLabel = activeAcct.isAll ? "All Accounts" : activeAcct.label;
692
693 return (
694 <div
695 ref={containerRef}
696 className={"tui-root " + (focused ? "is-focused" : "")}
697 tabIndex={0}
698 onKeyDown={handleKey}
699 onFocus={() => setFocused(true)}
700 onBlur={() => setFocused(false)}
701 onClick={focusMe}
702 >
703 {/* Title bar */}
704 <div className="tui-titlebar">
705 <div className="tui-titlebar-left">
706 <span className="tui-traffic">
707 <span className="tl tl-r" />
708 <span className="tl tl-y" />
709 <span className="tl tl-g" />
710 </span>
711 <span className="tui-title-text">matcha โ kai@floatpane</span>
712 </div>
713 <div className="tui-titlebar-right">
714 <span className="sep">ยท</span>
715 <span>80ร24</span>
716 </div>
717 </div>
718
719 {/* Body: sidebar + main pane */}
720 <div className="tui-shell">
721 {/* Sidebar */}
722 <div className="tui-sidebar">
723 <div className="tui-sidebar-head">Drew Smirnoff</div>
724 {FOLDERS_LIST.map((f, i) => (
725 <div
726 key={f}
727 className={"tui-folder " + (i === folderIdx ? "active" : "")}
728 onClick={() => {
729 setFolderIdx(i);
730 setCur(0);
731 focusMe();
732 }}
733 >
734 {f}
735 </div>
736 ))}
737 </div>
738
739 {/* Main */}
740 <div className="tui-main">
741 {mode !== "email" ? (
742 <>
743 {/* Account tabs */}
744 <div className="tui-acct-row">
745 {accounts.map((a, i) => (
746 <div
747 key={a.id}
748 className={"tui-acct " + (i === acctIdx ? "active" : "")}
749 onClick={() => {
750 setAcctIdx(i);
751 setCur(0);
752 focusMe();
753 }}
754 >
755 {a.label}
756 </div>
757 ))}
758 </div>
759
760 {/* Header line */}
761 <div className="tui-heading">
762 {isInbox ? (
763 <>
764 INBOX โ {acctLabel}
765 {titleSuffix}
766 </>
767 ) : (
768 <>
769 {folderLabel}
770 {titleSuffix}
771 </>
772 )}
773 </div>
774 <div className="tui-subhead">
775 {isInbox
776 ? `${visible.length} ${visible.length === 1 ? "email" : "emails"}`
777 : "folder empty"}
778 {filter && <span className="tui-filter-chip"> /{filter}</span>}
779 </div>
780
781 {/* Message rows */}
782 <div className="tui-list">
783 {visible.length === 0 && (
784 <div className="tui-row-empty">โ no messages โ</div>
785 )}
786 {visible.map((m, i) => {
787 const acct = accounts.find((a) => a.id === m.acct);
788 return (
789 <div
790 key={m.origIdx}
791 className={
792 "tui-row " +
793 (i === cur ? "cur " : "") +
794 (mode === "visual" ? "visual " : "")
795 }
796 onClick={() => {
797 setCur(i);
798 focusMe();
799 }}
800 onDoubleClick={() => {
801 setCur(i);
802 setMode("email");
803 focusMe();
804 }}
805 >
806 <span className="tui-row-cursor">
807 {cursor(i, cur, mode === "visual", visualStart)}
808 </span>
809 <span className="tui-row-num">
810 {String(i + 1).padStart(2, " ")}.
811 </span>
812 <span className="tui-row-acct">
813 [{acct ? acct.label : m.acct}]
814 </span>
815 <span className="tui-row-sep">โ</span>
816 <span className="tui-row-from">{m.from}</span>
817 <span className="tui-row-sep">โข</span>
818 <span className="tui-row-subj">{m.subject}</span>
819 <span className="tui-row-date">{m.date}</span>
820 </div>
821 );
822 })}
823 <div className="tui-row-dots">โข โข โข โข</div>
824 </div>
825
826 <Watermark />
827 </>
828 ) : (
829 <EmailView
830 msg={selected}
831 acct={accounts.find((a) => a.id === selected?.acct)}
832 />
833 )}
834 </div>
835 </div>
836
837 {/* Filter input */}
838 {mode === "filter" && (
839 <div className="tui-cmdline">
840 <span className="tui-cmdline-prompt">/</span>
841 <span className="tui-cmdline-buf">{filter}</span>
842 <span className="tui-caret">โ</span>
843 </div>
844 )}
845
846 {/* Flash */}
847 {flash && <div className="tui-flash">{flash}</div>}
848
849 {/* Status line */}
850 <div className="tui-status">
851 {mode === "email" ? (
852 <>
853 <span className="seg">โโ r: reply</span>
854 <span className="seg">โต f: forward</span>
855 <span className="seg">โ d: delete</span>
856 <span className="seg">โก a: archive</span>
857 <span className="seg">โฅ tab: focus attachments</span>
858 <span className="seg">โ esc: back to inbox</span>
859 <span className="seg">โซ i: toggle images</span>
860 </>
861 ) : mode === "visual" ? (
862 <>
863 <span className="seg hi">-- VISUAL --</span>
864 <span className="seg">j/k expand</span>
865 <span className="seg">d delete</span>
866 <span className="seg">a archive</span>
867 <span className="seg">m move</span>
868 <span className="seg">v/esc exit</span>
869 </>
870 ) : mode === "filter" ? (
871 <>
872 <span className="seg hi">-- FILTER --</span>
873 <span className="seg">enter apply</span>
874 <span className="seg">esc cancel</span>
875 </>
876 ) : (
877 <>
878 <span className="seg">โ/k up</span>
879 <span className="seg">โ/j down</span>
880 <span className="seg">/ filter</span>
881 <span className="seg">v visual mode</span>
882 <span className="seg">โ d delete</span>
883 <span className="seg">โก a archive</span>
884 <span className="seg">โ r refresh</span>
885 <span className="seg">โ/h prev tab</span>
886 <span className="seg">โ/l next tab</span>
887 <span className="seg">tab next folder</span>
888 <span className="seg">shift+tab prev folder</span>
889 <span className="seg">m move</span>
890 <span className="seg">q quit</span>
891 </>
892 )}
893 </div>
894
895 {/* Focus hint */}
896 {!focused && (
897 <div className="tui-focus-hint">
898 click ยท try <kbd>j</kbd>
899 <kbd>k</kbd> <kbd>tab</kbd> <kbd>/</kbd> <kbd>โต</kbd>
900 </div>
901 )}
902 </div>
903 );
904}
905
906function EmailView({ msg, acct }) {
907 if (!msg) return null;
908 const body = bodyFor(msg);
909 const from = acct ? `<${acct.label}>` : "";
910 return (
911 <div className="tui-email">
912 <div className="tui-email-head">
913 To: {acct ? acct.label : "you"} | From: {msg.from} {from} | Subject:{" "}
914 {msg.subject}
915 </div>
916 <div className="tui-email-body">
917 {body.split("\n").map((line, i) => {
918 // highlight 'this guy' style blue token if present
919 const parts = line.split(/(this guy)/);
920 return (
921 <div key={i} className="tui-email-line">
922 {parts.map((p, j) =>
923 p === "this guy" ? (
924 <span key={j} className="tui-link">
925 {p}
926 </span>
927 ) : (
928 <span key={j}>{p || "\u00a0"}</span>
929 ),
930 )}
931 </div>
932 );
933 })}
934 </div>
935 <Watermark />
936 </div>
937 );
938}
939
940window.MatchaTUI = TUI;