Skip to content
186 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, useMemo } from "react";
5import { ScrollView, StyleSheet, Text, View } from "react-native";
6
7import { 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 */
19export function Markdown({ text, muted }: { text: string; muted?: boolean }) {
20 const parsed = useMemo(() => blocks(text), [text]);
21 return (
22 <View style={{ gap: space.xs }}>
23 {parsed.map((block, index) => (
24 <BlockView key={index} block={block} muted={muted} />
25 ))}
26 </View>
27 );
28}
29
30function useOpen() {
31 const { server, workspace } = useSignedIn();
32 return (href: string) => {
33 const path = href.startsWith(server.site) ? href.slice(server.site.length) || "/" : href.startsWith("/") ? href : null;
34 const route = path ? routeFor(path) : null;
35 if (route) return router.push(route as never);
36 void WebBrowser.openBrowserAsync(path ? siteUrl(server, path) : href);
37 void workspace;
38 };
39}
40
41function BlockView({ block, muted }: { block: Block; muted?: boolean }) {
42 const colors = useColors();
43 const base = { color: muted ? colors.muted : colors.fg, fontSize: 15, lineHeight: 21 };
44 switch (block.t) {
45 case "p":
46 return (
47 <Text style={base} selectable>
48 {block.lines.map((line, index) => (
49 <Fragment key={index}>
50 {index > 0 ? "\n" : null}
51 <Spans spans={line} />
52 </Fragment>
53 ))}
54 </Text>
55 );
56 case "heading":
57 return (
58 <Text style={[base, { fontWeight: "700", fontSize: block.level <= 2 ? 17 : 15 }]}>
59 <Spans spans={block.c} />
60 </Text>
61 );
62 case "code":
63 return (
64 <ScrollView horizontal style={[styles.codeBlock, { backgroundColor: colors.raised, borderColor: colors.line }]}>
65 <Text selectable style={{ fontFamily: fonts.mono, fontSize: 13, lineHeight: 19, color: colors.fgSoft }}>
66 {block.v}
67 </Text>
68 </ScrollView>
69 );
70 case "list":
71 return (
72 <View style={{ gap: 2 }}>
73 {block.items.map((item, index) => (
74 <View key={index} style={{ flexDirection: "row", gap: space.sm }}>
75 <Text style={[base, { color: colors.muted, minWidth: 16 }]}>{block.ordered ? `${block.start + index}.` : "•"}</Text>
76 <View style={{ flex: 1, gap: 2 }}>
77 {item.map((inner, at) => (
78 <BlockView key={at} block={inner} muted={muted} />
79 ))}
80 </View>
81 </View>
82 ))}
83 </View>
84 );
85 case "quote":
86 return (
87 <View style={[styles.quote, { borderLeftColor: colors.lineStrong }]}>
88 {block.c.map((inner, index) => (
89 <BlockView key={index} block={inner} muted />
90 ))}
91 </View>
92 );
93 case "hr":
94 return <View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.lineStrong, marginVertical: space.xs }} />;
95 case "table":
96 return (
97 <ScrollView horizontal>
98 <View style={[styles.table, { borderColor: colors.line }]}>
99 {[block.head, ...block.rows].map((row, rowIndex) => (
100 <View key={rowIndex} style={{ flexDirection: "row", backgroundColor: rowIndex === 0 ? colors.raised : "transparent" }}>
101 {row.map((cell, cellIndex) => (
102 <Text
103 key={cellIndex}
104 style={[base, styles.cell, { borderColor: colors.line, fontWeight: rowIndex === 0 ? "600" : "400", textAlign: block.align[cellIndex] ?? "left" }]}
105 >
106 <Spans spans={cell} />
107 </Text>
108 ))}
109 </View>
110 ))}
111 </View>
112 </ScrollView>
113 );
114 }
115}
116
117function Spans({ spans }: { spans: Span[] }) {
118 const colors = useColors();
119 const open = useOpen();
120 const { workspace } = useSignedIn();
121 return (
122 <>
123 {spans.map((span, index) => {
124 switch (span.t) {
125 case "text":
126 return <Fragment key={index}>{span.v}</Fragment>;
127 case "code":
128 return (
129 <Text key={index} style={{ fontFamily: fonts.mono, fontSize: 13, backgroundColor: colors.raised, color: colors.fgSoft }}>
130 {` ${span.v} `}
131 </Text>
132 );
133 case "strong":
134 return (
135 <Text key={index} style={{ fontWeight: "700" }}>
136 <Spans spans={span.c} />
137 </Text>
138 );
139 case "em":
140 return (
141 <Text key={index} style={{ fontStyle: "italic" }}>
142 <Spans spans={span.c} />
143 </Text>
144 );
145 case "del":
146 return (
147 <Text key={index} style={{ textDecorationLine: "line-through" }}>
148 <Spans spans={span.c} />
149 </Text>
150 );
151 case "link":
152 return (
153 <Text key={index} style={{ color: colors.accent, textDecorationLine: "underline" }} onPress={() => open(span.href)}>
154 <Spans spans={span.c} />
155 </Text>
156 );
157 case "mention":
158 return (
159 <Text key={index} style={{ color: colors.accent, backgroundColor: colors.accentLow, fontWeight: "600" }}>
160 @{span.name}
161 </Text>
162 );
163 case "channel":
164 return (
165 <Text key={index} style={{ color: colors.accent, fontWeight: "600" }} onPress={() => workspace && open(`/${workspace.slug}/-/chat/${span.name}`)}>
166 #{span.name}
167 </Text>
168 );
169 case "ref":
170 return (
171 <Text key={index} style={{ color: colors.accent }} onPress={() => span.repo && open(`/${span.repo}/issues/${span.number}`)}>
172 {span.repo ? `${span.repo}#${span.number}` : `#${span.number}`}
173 </Text>
174 );
175 }
176 })}
177 </>
178 );
179}
180
181const styles = StyleSheet.create({
182 codeBlock: { borderWidth: 1, borderRadius: radius.sm, padding: space.sm },
183 quote: { borderLeftWidth: 3, paddingLeft: space.sm, gap: space.xs },
184 table: { borderWidth: 1, borderRadius: radius.sm },
185 cell: { paddingHorizontal: space.sm, paddingVertical: 4, minWidth: 80, borderRightWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth },
186});