返回 CodeWhale
ticker.tsx
根目录 / web / components / ticker.tsx
1 import type { FeedItem } from "@/lib/types";
2 import { relativeAge, relativeTime } from "@/lib/github";
3 import { splitToken } from "@/lib/i18n/dictionaries";
4 import { truncateChars } from "@/lib/truncate";
5
6 /**
7 * The wire strip under the masthead: what actually happened in the
8 * repository, in the reader's language, with the person who did it named.
9 *
10 * Everything here is GitHub's own record — merge state, handles, and
11 * GitHub's `author_association` first-timer verdict, all from the list
12 * payloads `fetchFeed` already pulls. Nothing is summarized, ranked, or
13 * generated; an empty feed renders nothing at all rather than a skeleton.
14 *
15 * Titles and handles are content and stay verbatim. The chrome around them —
16 * the event verb, the by-line, the first-contribution mark — comes from the
17 * caller's dictionary, and the age comes from CLDR via the locale's own
18 * `dateLocale`.
19 */
20 export interface TickerLabels {
21 /** Han-seal live label and its mono tag. */
22 liveLabel: string;
23 liveTag: string;
24 /** aria-label for the strip's landmark. */
25 ariaLabel: string;
26 /** Event verbs. Drafts never reach the strip — see `EVENT_STATES`. */
27 merged: string;
28 opened: string;
29 closed: string;
30 released: string;
31 /** Mark on a newcomer's contribution, e.g. "first contribution". */
32 firstContribution: string;
33 /** By-line template carrying a `{handle}` token, e.g. "by {handle}". */
34 by: string;
35 /** BCP 47 tag driving `Intl.RelativeTimeFormat` (chrome.dateLocale). */
36 dateLocale: string;
37 }
38
39 /**
40 * States the strip reports. A draft pull request is the one thing here that
41 * is not an event — it is work its own author has marked not-ready — and on a
42 * repository where agents open them in batches it crowds out the merges and
43 * the people behind them. It reappears the moment it is opened or merged.
44 */
45 const EVENT_STATES: readonly FeedItem["state"][] = ["merged", "open", "closed", "published"];
46
47 /** The state a feed item is in → the verb the strip prints for it. */
48 function verbFor(state: FeedItem["state"], labels: TickerLabels): string {
49 switch (state) {
50 case "merged":
51 return labels.merged;
52 case "closed":
53 return labels.closed;
54 case "published":
55 return labels.released;
56 default:
57 return labels.opened;
58 }
59 }
60
61 /**
62 * Locale age formatter. `Intl.RelativeTimeFormat` is unavailable or missing
63 * locale data on some runtimes; falling back to the compact English form is
64 * an honest degradation, an empty timestamp is not.
65 */
66 function ageFormatter(dateLocale: string): (iso: string) => string {
67 let rtf: Intl.RelativeTimeFormat | undefined;
68 try {
69 rtf = new Intl.RelativeTimeFormat(dateLocale, { numeric: "auto", style: "narrow" });
70 } catch {
71 rtf = undefined;
72 }
73 return (iso: string) => {
74 if (!rtf) return relativeTime(iso);
75 const { value, unit } = relativeAge(iso);
76 return rtf.format(value, unit);
77 };
78 }
79
80 function TickerEntry({
81 item,
82 labels,
83 age,
84 hidden,
85 }: {
86 item: FeedItem;
87 labels: TickerLabels;
88 age: string;
89 hidden: boolean;
90 }) {
91 const isRelease = item.kind === "release";
92 // A release named after its own tag would print the tag twice.
93 const title = isRelease && item.title === item.tag ? "" : item.title;
94 const [beforeHandle, afterHandle] = splitToken(labels.by, "handle");
95
96 return (
97 <span className="ticker-item" aria-hidden={hidden || undefined}>
98 <span className="ticker-verb" data-event={item.state}>
99 {verbFor(item.state, labels)}
100 </span>
101 {isRelease ? (
102 <span className="ticker-tag tabular">{item.tag}</span>
103 ) : (
104 <span className="ticker-num tabular">#{item.number}</span>
105 )}
106 {title ? (
107 <span className="ticker-title">{truncateChars(title, 70)}</span>
108 ) : null}
109 {item.author ? (
110 <span className="ticker-by">
111 {beforeHandle}
112 <span className="ticker-handle">@{item.author}</span>
113 {afterHandle}
114 </span>
115 ) : null}
116 {item.firstTimeContributor ? (
117 <span className="ticker-first">{labels.firstContribution}</span>
118 ) : null}
119 <span className="ticker-age tabular">{age}</span>
120 <span className="ticker-sep" aria-hidden>
121
122 </span>
123 </span>
124 );
125 }
126
127 export function Ticker({ items, labels }: { items: FeedItem[]; labels: TickerLabels }) {
128 // Newest event first — the verb and the age describe the same moment, so
129 // the strip reads as a wire and never dates a merge by a later comment.
130 const ordered = items
131 .filter((item) => EVENT_STATES.includes(item.state))
132 .sort((a, b) => +new Date(b.eventAt ?? b.updatedAt) - +new Date(a.eventAt ?? a.updatedAt));
133
134 // Nothing to report is reported as nothing.
135 if (!ordered.length) return null;
136
137 const formatAge = ageFormatter(labels.dateLocale);
138 // Seamless loop: the track translates -50%, so both halves must be the same
139 // flat run of children. The second half is hidden from assistive tech.
140 const doubled = [...ordered, ...ordered];
141
142 // Loop duration: never faster than the original 80s baseline; longer feeds
143 // (CJK titles run 2-4x wider than English) get proportionally more time so
144 // every locale scrolls at a readable pace.
145 const glyphCount = ordered.reduce(
146 (sum, item) => sum + (item.title?.length ?? 0) + 24,
147 0,
148 );
149 const durationSeconds = Math.max(80, Math.round(glyphCount / 22));
150
151 return (
152 <div className="hairline-t hairline-b bg-paper-deep overflow-hidden">
153 <div className="site-container flex items-stretch">
154 <div className="border-r border-paper-edge text-ink px-4 py-2 flex items-center shrink-0 gap-2">
155 <span className="w-1.5 h-1.5 bg-indigo rounded-full inline-block animate-pulse" />
156 <span className="font-cjk text-sm font-semibold tracking-wider">{labels.liveLabel}</span>
157 <span className="font-mono text-[0.55rem] uppercase tracking-widest text-ink-mute ml-1 self-end mb-0.5">
158 {labels.liveTag}
159 </span>
160 </div>
161 <div className="ticker-viewport" role="group" aria-label={labels.ariaLabel}>
162 <div
163 className="ticker-track py-2 font-mono text-[0.78rem]"
164 style={{ animationDuration: `${durationSeconds}s` }}
165 >
166 {doubled.map((item, i) => (
167 <TickerEntry
168 key={`${item.url}-${i}`}
169 item={item}
170 labels={labels}
171 age={formatAge(item.eventAt ?? item.updatedAt)}
172 hidden={i >= ordered.length}
173 />
174 ))}
175 </div>
176 </div>
177 </div>
178 </div>
179 );
180 }
181
181 lines Plain Text