Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

210 linesCodeBlameRaw
1import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown";
2import { router } from "expo-router";
3import * as WebBrowser from "expo-web-browser";
4import { Fragment, createContext, useContext, useMemo } from "react";
5import { ScrollView, StyleSheet, Text, View } from "react-native";
6
7import { type RefContext, refContext, refRoute, routeFor } from "@/lib/links";
8import { siteUrl } from "@/lib/server";
9import { useSignedIn } from "@/lib/session";
10import { fonts, radius, space, useColors } from "@/theme";
11
12/**
13 * A message's Markdown, as the site draws it (components/chat/text.tsx),
14 * from the same parser (@g1t/contracts/chat-markdown): nothing in a
15 * message is HTML, links go only to the web, mail or a page on g1t, and
16 * `@mentions`, `#channels` and `#123` references stand out. A link to a
17 * page the app has opens there; any other opens in the browser.
18 *
19 * `#123` is read in `refs`, where the text was written: a repository for a
20 * pull request's, the workspace for Chat's and a work item's. g1t says
21 * what it names when it is tapped (app/ref.tsx).
22 */
23export function Markdown({ text, muted, refs }: { text: string; muted?: boolean; refs?: RefContext }) {
24 const parsed = useMemo(() => blocks(text), [text]);
25 const body = (
26 <View style={{ gap: space.xs }}>
27 {parsed.map((block, index) => (
28 <BlockView key={index} block={block} muted={muted} />
29 ))}
30 </View>
31 );
32 return refs ? <RefsHere.Provider value={refs}>{body}</RefsHere.Provider> : body;
33}
34
35/** Where `#123` in the text being drawn is read; the workspace you are in when nothing says. */
36const RefsHere = createContext<RefContext | null>(null);
37
38function useOpen() {
39 const { server, workspace } = useSignedIn();
40 return (href: string) => {
41 const path = href.startsWith(server.site) ? href.slice(server.site.length) || "/" : href.startsWith("/") ? href : null;
42 const route = path ? routeFor(path) : null;
43 if (route) return router.push(route as never);
44 void WebBrowser.openBrowserAsync(path ? siteUrl(server, path) : href);
45 void workspace;
46 };
47}
48
49function BlockView({ block, muted }: { block: Block; muted?: boolean }) {
50 const colors = useColors();
51 const base = { color: muted ? colors.muted : colors.fg, fontSize: 15, lineHeight: 21 };
52 switch (block.t) {
53 case "p":
54 return (
55 <Text style={base} selectable>
56 {block.lines.map((line, index) => (
57 <Fragment key={index}>
58 {index > 0 ? "\n" : null}
59 <Spans spans={line} />
60 </Fragment>
61 ))}
62 </Text>
63 );
64 case "heading":
65 return (
66 <Text style={[base, { fontWeight: "700", fontSize: block.level <= 2 ? 17 : 15 }]}>
67 <Spans spans={block.c} />
68 </Text>
69 );
70 case "code":
71 return (
72 <ScrollView horizontal style={[styles.codeBlock, { backgroundColor: colors.raised, borderColor: colors.line }]}>
73 <Text selectable style={{ fontFamily: fonts.mono, fontSize: 13, lineHeight: 19, color: colors.fgSoft }}>
74 {block.v}
75 </Text>
76 </ScrollView>
77 );
78 case "list":
79 return (
80 <View style={{ gap: 2 }}>
81 {block.items.map((item, index) => (
82 <View key={index} style={{ flexDirection: "row", gap: space.sm }}>
83 <Text style={[base, { color: colors.muted, minWidth: 16 }]}>{block.ordered ? `${block.start + index}.` : "•"}</Text>
84 <View style={{ flex: 1, gap: 2 }}>
85 {item.map((inner, at) => (
86 <BlockView key={at} block={inner} muted={muted} />
87 ))}
88 </View>
89 </View>
90 ))}
91 </View>
92 );
93 case "quote":
94 return (
95 <View style={[styles.quote, { borderLeftColor: colors.lineStrong }]}>
96 {block.c.map((inner, index) => (
97 <BlockView key={index} block={inner} muted />
98 ))}
99 </View>
100 );
101 case "hr":
102 return <View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.lineStrong, marginVertical: space.xs }} />;
103 case "table":
104 return (
105 <ScrollView horizontal>
106 <View style={[styles.table, { borderColor: colors.line }]}>
107 {[block.head, ...block.rows].map((row, rowIndex) => (
108 <View key={rowIndex} style={{ flexDirection: "row", backgroundColor: rowIndex === 0 ? colors.raised : "transparent" }}>
109 {row.map((cell, cellIndex) => (
110 <Text
111 key={cellIndex}
112 style={[
113 base,
114 styles.cell,
115 { borderColor: colors.line, fontWeight: rowIndex === 0 ? "600" : "400", textAlign: block.align[cellIndex] ?? "left" },
116 ]}
117 >
118 <Spans spans={cell} />
119 </Text>
120 ))}
121 </View>
122 ))}
123 </View>
124 </ScrollView>
125 );
126 }
127}
128
129function Spans({ spans }: { spans: Span[] }) {
130 const colors = useColors();
131 const open = useOpen();
132 const { workspace } = useSignedIn();
133 const given = useContext(RefsHere);
134 const here = given ?? (workspace ? { workspace: workspace.slug } : null);
135 return (
136 <>
137 {spans.map((span, index) => {
138 switch (span.t) {
139 case "text":
140 return <Fragment key={index}>{span.v}</Fragment>;
141 case "code":
142 return (
143 <Text key={index} style={{ fontFamily: fonts.mono, fontSize: 13, backgroundColor: colors.raised, color: colors.fgSoft }}>
144 {` ${span.v} `}
145 </Text>
146 );
147 case "strong":
148 return (
149 <Text key={index} style={{ fontWeight: "700" }}>
150 <Spans spans={span.c} />
151 </Text>
152 );
153 case "em":
154 return (
155 <Text key={index} style={{ fontStyle: "italic" }}>
156 <Spans spans={span.c} />
157 </Text>
158 );
159 case "del":
160 return (
161 <Text key={index} style={{ textDecorationLine: "line-through" }}>
162 <Spans spans={span.c} />
163 </Text>
164 );
165 case "link":
166 return (
167 <Text key={index} style={{ color: colors.accent, textDecorationLine: "underline" }} onPress={() => open(span.href)}>
168 <Spans spans={span.c} />
169 </Text>
170 );
171 case "mention":
172 return (
173 <Text key={index} style={{ color: colors.accent, backgroundColor: colors.accentLow, fontWeight: "600" }}>
174 @{span.name}
175 </Text>
176 );
177 case "channel":
178 return (
179 <Text key={index} style={{ color: colors.accent, fontWeight: "600" }} onPress={() => workspace && open(`/${workspace.slug}/-/chat/${span.name}`)}>
180 #{span.name}
181 </Text>
182 );
183 case "ref":
184 return (
185 <Text
186 key={index}
187 style={{ color: colors.accent }}
188 onPress={() => here && router.push(refRoute(refContext(span.repo, here), span.number) as never)}
189 >
190 {span.repo ? `${span.repo}#${span.number}` : `#${span.number}`}
191 </Text>
192 );
193 }
194 })}
195 </>
196 );
197}
198
199const styles = StyleSheet.create({
200 codeBlock: { borderWidth: 1, borderRadius: radius.sm, padding: space.sm },
201 quote: { borderLeftWidth: 3, paddingLeft: space.sm, gap: space.xs },
202 table: { borderWidth: 1, borderRadius: radius.sm },
203 cell: {
204 paddingHorizontal: space.sm,
205 paddingVertical: 4,
206 minWidth: 80,
207 borderRightWidth: StyleSheet.hairlineWidth,
208 borderBottomWidth: StyleSheet.hairlineWidth,
209 },
210});