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.

196 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={[base, styles.cell, { borderColor: colors.line, fontWeight: rowIndex === 0 ? "600" : "400", textAlign: block.align[cellIndex] ?? "left" }]}
113 >
114 <Spans spans={cell} />
115 </Text>
116 ))}
117 </View>
118 ))}
119 </View>
120 </ScrollView>
121 );
122 }
123}
124
125function Spans({ spans }: { spans: Span[] }) {
126 const colors = useColors();
127 const open = useOpen();
128 const { workspace } = useSignedIn();
129 const given = useContext(RefsHere);
130 const here = given ?? (workspace ? { workspace: workspace.slug } : null);
131 return (
132 <>
133 {spans.map((span, index) => {
134 switch (span.t) {
135 case "text":
136 return <Fragment key={index}>{span.v}</Fragment>;
137 case "code":
138 return (
139 <Text key={index} style={{ fontFamily: fonts.mono, fontSize: 13, backgroundColor: colors.raised, color: colors.fgSoft }}>
140 {` ${span.v} `}
141 </Text>
142 );
143 case "strong":
144 return (
145 <Text key={index} style={{ fontWeight: "700" }}>
146 <Spans spans={span.c} />
147 </Text>
148 );
149 case "em":
150 return (
151 <Text key={index} style={{ fontStyle: "italic" }}>
152 <Spans spans={span.c} />
153 </Text>
154 );
155 case "del":
156 return (
157 <Text key={index} style={{ textDecorationLine: "line-through" }}>
158 <Spans spans={span.c} />
159 </Text>
160 );
161 case "link":
162 return (
163 <Text key={index} style={{ color: colors.accent, textDecorationLine: "underline" }} onPress={() => open(span.href)}>
164 <Spans spans={span.c} />
165 </Text>
166 );
167 case "mention":
168 return (
169 <Text key={index} style={{ color: colors.accent, backgroundColor: colors.accentLow, fontWeight: "600" }}>
170 @{span.name}
171 </Text>
172 );
173 case "channel":
174 return (
175 <Text key={index} style={{ color: colors.accent, fontWeight: "600" }} onPress={() => workspace && open(`/${workspace.slug}/-/chat/${span.name}`)}>
176 #{span.name}
177 </Text>
178 );
179 case "ref":
180 return (
181 <Text key={index} style={{ color: colors.accent }} onPress={() => here && router.push(refRoute(refContext(span.repo, here), span.number) as never)}>
182 {span.repo ? `${span.repo}#${span.number}` : `#${span.number}`}
183 </Text>
184 );
185 }
186 })}
187 </>
188 );
189}
190
191const styles = StyleSheet.create({
192 codeBlock: { borderWidth: 1, borderRadius: radius.sm, padding: space.sm },
193 quote: { borderLeftWidth: 3, paddingLeft: space.sm, gap: space.xs },
194 table: { borderWidth: 1, borderRadius: radius.sm },
195 cell: { paddingHorizontal: space.sm, paddingVertical: 4, minWidth: 80, borderRightWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth },
196});