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