@10play/expo-air 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import React, { useState } from "react";
1
+ import React, { useState, useEffect, useRef } from "react";
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -6,25 +6,71 @@ import {
6
6
  TouchableOpacity,
7
7
  ScrollView,
8
8
  TextInput,
9
+ Animated,
10
+ Easing,
9
11
  } from "react-native";
10
12
  import type { BranchInfo } from "../services/websocket";
11
13
  import { SPACING, LAYOUT, COLORS, TYPOGRAPHY } from "../constants/design";
12
14
 
13
15
  // Header height: paddingVertical (14) * 2 + content (~20) + border (1) ≈ 49
14
16
  const HEADER_HEIGHT = 49;
17
+ const DEFAULT_BRANCHES = ["main", "master"];
18
+ const RECENT_COUNT = 5;
15
19
 
16
20
  interface BranchSwitcherProps {
17
21
  branches: BranchInfo[];
18
22
  currentBranch: string;
23
+ loading?: boolean;
19
24
  onSelect: (branchName: string) => void;
20
25
  onCreate: (branchName: string) => void;
21
26
  onClose: () => void;
22
27
  error?: string | null;
23
28
  }
24
29
 
30
+ interface BranchSection {
31
+ title: string;
32
+ branches: BranchInfo[];
33
+ }
34
+
35
+ function groupBranches(
36
+ branches: BranchInfo[],
37
+ searchQuery: string
38
+ ): BranchSection[] {
39
+ const query = searchQuery.toLowerCase().trim();
40
+ const filtered = query
41
+ ? branches.filter((b) => b.name.toLowerCase().includes(query))
42
+ : branches;
43
+
44
+ const defaultBranch = filtered.filter((b) =>
45
+ DEFAULT_BRANCHES.includes(b.name)
46
+ );
47
+ const remaining = filtered.filter(
48
+ (b) => !DEFAULT_BRANCHES.includes(b.name)
49
+ );
50
+
51
+ // Recent: local non-default branches, top N by commit date (already sorted)
52
+ const recentLocal = remaining.filter((b) => !b.isRemote);
53
+ const recent = recentLocal.slice(0, RECENT_COUNT);
54
+ const recentNames = new Set(recent.map((b) => b.name));
55
+
56
+ // Other: everything else
57
+ const other = remaining.filter((b) => !recentNames.has(b.name));
58
+
59
+ const sections: BranchSection[] = [];
60
+ if (defaultBranch.length > 0)
61
+ sections.push({ title: "Default", branches: defaultBranch });
62
+ if (recent.length > 0)
63
+ sections.push({ title: "Recent", branches: recent });
64
+ if (other.length > 0)
65
+ sections.push({ title: "Other", branches: other });
66
+
67
+ return sections;
68
+ }
69
+
25
70
  export function BranchSwitcher({
26
71
  branches,
27
72
  currentBranch,
73
+ loading,
28
74
  onSelect,
29
75
  onCreate,
30
76
  onClose,
@@ -33,6 +79,8 @@ export function BranchSwitcher({
33
79
  const [showCreateInput, setShowCreateInput] = useState(false);
34
80
  const [newBranchName, setNewBranchName] = useState("");
35
81
 
82
+ const sections = groupBranches(branches, "");
83
+
36
84
  const handleCreate = () => {
37
85
  const trimmed = newBranchName.trim();
38
86
  if (trimmed) {
@@ -42,53 +90,84 @@ export function BranchSwitcher({
42
90
  }
43
91
  };
44
92
 
93
+ const renderBranchItem = (branch: BranchInfo, isFirst: boolean) => {
94
+ const isCurrent = branch.name === currentBranch;
95
+ return (
96
+ <TouchableOpacity
97
+ key={branch.name}
98
+ style={[
99
+ styles.branchItem,
100
+ isCurrent && styles.branchItemCurrent,
101
+ isFirst && styles.branchItemFirst,
102
+ ]}
103
+ onPress={() => {
104
+ if (!isCurrent) {
105
+ onSelect(branch.name);
106
+ }
107
+ }}
108
+ activeOpacity={isCurrent ? 1 : 0.6}
109
+ >
110
+ <View style={styles.branchInfo}>
111
+ <Text
112
+ style={[
113
+ styles.branchName,
114
+ isCurrent && styles.branchNameCurrent,
115
+ ]}
116
+ numberOfLines={1}
117
+ >
118
+ {branch.name}
119
+ </Text>
120
+ {branch.prNumber && (
121
+ <View style={styles.prBadge}>
122
+ <Text style={styles.prBadgeText}>#{branch.prNumber}</Text>
123
+ </View>
124
+ )}
125
+ {branch.isRemote && (
126
+ <View style={styles.remoteBadge}>
127
+ <Text style={styles.remoteBadgeText}>remote</Text>
128
+ </View>
129
+ )}
130
+ </View>
131
+ {isCurrent && <Text style={styles.currentIndicator}>✓</Text>}
132
+ </TouchableOpacity>
133
+ );
134
+ };
135
+
45
136
  return (
46
137
  <View style={styles.overlay}>
47
- <TouchableOpacity style={styles.backdrop} onPress={onClose} activeOpacity={1} />
138
+ <TouchableOpacity
139
+ style={styles.backdrop}
140
+ onPress={onClose}
141
+ activeOpacity={1}
142
+ />
48
143
  <View style={styles.dropdown}>
49
144
  {error && (
50
145
  <View style={styles.errorBanner}>
51
- <Text style={styles.errorText} numberOfLines={2}>{error}</Text>
146
+ <Text style={styles.errorText} numberOfLines={2}>
147
+ {error}
148
+ </Text>
52
149
  </View>
53
150
  )}
54
- <ScrollView style={styles.branchList} bounces={false}>
55
- {branches.map((branch, index) => (
56
- <TouchableOpacity
57
- key={branch.name}
58
- style={[
59
- styles.branchItem,
60
- branch.isCurrent && styles.branchItemCurrent,
61
- index === 0 && styles.branchItemFirst,
62
- ]}
63
- onPress={() => {
64
- if (!branch.isCurrent) {
65
- onSelect(branch.name);
66
- }
67
- }}
68
- activeOpacity={branch.isCurrent ? 1 : 0.6}
69
- >
70
- <View style={styles.branchInfo}>
71
- <Text
72
- style={[
73
- styles.branchName,
74
- branch.isCurrent && styles.branchNameCurrent,
75
- ]}
76
- numberOfLines={1}
77
- >
78
- {branch.name}
79
- </Text>
80
- {branch.prNumber && (
81
- <View style={styles.prBadge}>
82
- <Text style={styles.prBadgeText}>#{branch.prNumber}</Text>
83
- </View>
151
+ {loading && branches.length === 0 ? (
152
+ <View style={styles.loadingContainer}>
153
+ <LoadingDots />
154
+ </View>
155
+ ) : (
156
+ <ScrollView style={styles.branchList} bounces={false}>
157
+ {sections.map((section) => (
158
+ <View key={section.title}>
159
+ <View style={styles.sectionHeader}>
160
+ <Text style={styles.sectionHeaderText}>
161
+ {section.title}
162
+ </Text>
163
+ </View>
164
+ {section.branches.map((branch, index) =>
165
+ renderBranchItem(branch, index === 0)
84
166
  )}
85
167
  </View>
86
- {branch.isCurrent && (
87
- <Text style={styles.currentIndicator}>✓</Text>
88
- )}
89
- </TouchableOpacity>
90
- ))}
91
- </ScrollView>
168
+ ))}
169
+ </ScrollView>
170
+ )}
92
171
 
93
172
  <View style={styles.createSection}>
94
173
  {showCreateInput ? (
@@ -121,7 +200,9 @@ export function BranchSwitcher({
121
200
  style={styles.createButton}
122
201
  onPress={() => setShowCreateInput(true)}
123
202
  >
124
- <Text style={styles.createButtonText}>+ New branch from main</Text>
203
+ <Text style={styles.createButtonText}>
204
+ + New branch from main
205
+ </Text>
125
206
  </TouchableOpacity>
126
207
  )}
127
208
  </View>
@@ -130,6 +211,52 @@ export function BranchSwitcher({
130
211
  );
131
212
  }
132
213
 
214
+ function LoadingDots() {
215
+ const dot1 = useRef(new Animated.Value(0.3)).current;
216
+ const dot2 = useRef(new Animated.Value(0.3)).current;
217
+ const dot3 = useRef(new Animated.Value(0.3)).current;
218
+
219
+ useEffect(() => {
220
+ const animate = (dot: Animated.Value, delay: number) =>
221
+ Animated.loop(
222
+ Animated.sequence([
223
+ Animated.delay(delay),
224
+ Animated.timing(dot, {
225
+ toValue: 1,
226
+ duration: 300,
227
+ easing: Easing.inOut(Easing.ease),
228
+ useNativeDriver: true,
229
+ }),
230
+ Animated.timing(dot, {
231
+ toValue: 0.3,
232
+ duration: 300,
233
+ easing: Easing.inOut(Easing.ease),
234
+ useNativeDriver: true,
235
+ }),
236
+ ]),
237
+ );
238
+ const a1 = animate(dot1, 0);
239
+ const a2 = animate(dot2, 150);
240
+ const a3 = animate(dot3, 300);
241
+ a1.start();
242
+ a2.start();
243
+ a3.start();
244
+ return () => {
245
+ a1.stop();
246
+ a2.stop();
247
+ a3.stop();
248
+ };
249
+ }, [dot1, dot2, dot3]);
250
+
251
+ return (
252
+ <View style={styles.loadingDots}>
253
+ {[dot1, dot2, dot3].map((dot, i) => (
254
+ <Animated.View key={i} style={[styles.loadingDot, { opacity: dot }]} />
255
+ ))}
256
+ </View>
257
+ );
258
+ }
259
+
133
260
  const styles = StyleSheet.create({
134
261
  overlay: {
135
262
  ...StyleSheet.absoluteFillObject,
@@ -151,9 +278,36 @@ const styles = StyleSheet.create({
151
278
  borderColor: "rgba(255,255,255,0.12)",
152
279
  overflow: "hidden",
153
280
  },
281
+ loadingContainer: {
282
+ paddingVertical: SPACING.XL,
283
+ alignItems: "center",
284
+ justifyContent: "center",
285
+ },
286
+ loadingDots: {
287
+ flexDirection: "row",
288
+ gap: 6,
289
+ },
290
+ loadingDot: {
291
+ width: 6,
292
+ height: 6,
293
+ borderRadius: 3,
294
+ backgroundColor: "rgba(255,255,255,0.5)",
295
+ },
154
296
  branchList: {
155
297
  maxHeight: 300,
156
298
  },
299
+ sectionHeader: {
300
+ paddingHorizontal: SPACING.LG,
301
+ paddingTop: SPACING.MD,
302
+ paddingBottom: SPACING.XS,
303
+ },
304
+ sectionHeaderText: {
305
+ color: COLORS.TEXT_MUTED,
306
+ fontSize: TYPOGRAPHY.SIZE_XS,
307
+ fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
308
+ textTransform: "uppercase",
309
+ letterSpacing: 0.5,
310
+ },
157
311
  branchItem: {
158
312
  flexDirection: "row",
159
313
  alignItems: "center",
@@ -196,6 +350,17 @@ const styles = StyleSheet.create({
196
350
  fontSize: TYPOGRAPHY.SIZE_XS,
197
351
  fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
198
352
  },
353
+ remoteBadge: {
354
+ backgroundColor: "rgba(255,255,255,0.06)",
355
+ paddingHorizontal: SPACING.SM,
356
+ paddingVertical: 2,
357
+ borderRadius: 8,
358
+ },
359
+ remoteBadgeText: {
360
+ color: COLORS.TEXT_MUTED,
361
+ fontSize: TYPOGRAPHY.SIZE_XS,
362
+ fontWeight: TYPOGRAPHY.WEIGHT_NORMAL,
363
+ },
199
364
  currentIndicator: {
200
365
  color: COLORS.STATUS_SUCCESS,
201
366
  fontSize: TYPOGRAPHY.SIZE_SM,
@@ -0,0 +1,320 @@
1
+ import React from "react";
2
+ import { View, Text, StyleSheet, Platform, Linking } from "react-native";
3
+ import { SPACING, COLORS, TYPOGRAPHY } from "../constants/design";
4
+
5
+ // Renders formatted text with markdown-style lists, code, and emphasis
6
+ export function FormattedText({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
7
+ const lines = content.split('\n');
8
+ const elements: React.ReactNode[] = [];
9
+ let i = 0;
10
+
11
+ while (i < lines.length) {
12
+ const line = lines[i];
13
+ const trimmed = line.trim();
14
+
15
+ // Code block start (check first to avoid matching content inside code blocks)
16
+ if (trimmed.startsWith('```')) {
17
+ const codeLines: string[] = [];
18
+ const lang = trimmed.slice(3).trim();
19
+ i++;
20
+ while (i < lines.length && !lines[i].trim().startsWith('```')) {
21
+ codeLines.push(lines[i]);
22
+ i++;
23
+ }
24
+ elements.push(
25
+ <View key={`code-${i}`} style={styles.codeBlock}>
26
+ {lang && <Text style={styles.codeLang}>{lang}</Text>}
27
+ <Text style={styles.codeText} selectable>{codeLines.join('\n')}</Text>
28
+ </View>
29
+ );
30
+ i++; // skip closing ```
31
+ continue;
32
+ }
33
+
34
+ // Heading (# ## ### etc.)
35
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
36
+ if (headingMatch) {
37
+ const level = headingMatch[1].length;
38
+ const headingStyle = level <= 2 ? styles.heading1 : level <= 4 ? styles.heading2 : styles.heading3;
39
+ elements.push(
40
+ <Text key={i} style={headingStyle} selectable>{formatInlineText(headingMatch[2])}</Text>
41
+ );
42
+ i++;
43
+ continue;
44
+ }
45
+
46
+ // Blockquote (> text) - collect consecutive > lines
47
+ if (trimmed.startsWith('> ') || trimmed === '>') {
48
+ const quoteLines: string[] = [];
49
+ while (i < lines.length && (lines[i].trim().startsWith('> ') || lines[i].trim() === '>')) {
50
+ const qContent = lines[i].trim().startsWith('> ') ? lines[i].trim().slice(2) : '';
51
+ quoteLines.push(qContent);
52
+ i++;
53
+ }
54
+ elements.push(
55
+ <View key={`quote-${i}`} style={styles.blockquote}>
56
+ <Text style={styles.blockquoteText} selectable>{formatInlineText(quoteLines.join('\n'))}</Text>
57
+ </View>
58
+ );
59
+ continue;
60
+ }
61
+
62
+ // Task list item (- [ ] or - [x])
63
+ const taskMatch = trimmed.match(/^[-*]\s+\[([ xX])\]\s+(.+)$/);
64
+ if (taskMatch) {
65
+ const checked = taskMatch[1] !== ' ';
66
+ elements.push(
67
+ <View key={i} style={styles.listItem}>
68
+ <Text style={styles.listBullet}>{checked ? '☑' : '☐'}</Text>
69
+ <Text style={styles.listText} selectable>{formatInlineText(taskMatch[2])}</Text>
70
+ </View>
71
+ );
72
+ i++;
73
+ continue;
74
+ }
75
+
76
+ // Numbered list item (1. 2. 3.) - with nesting via indentation
77
+ const numberedMatch = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
78
+ if (numberedMatch) {
79
+ const indent = Math.floor(numberedMatch[1].length / 2);
80
+ const [, , num, text] = numberedMatch;
81
+ elements.push(
82
+ <View key={i} style={[styles.listItem, indent > 0 && { paddingLeft: SPACING.SM + indent * SPACING.LG }]}>
83
+ <Text style={styles.listNumber}>{num}.</Text>
84
+ <Text style={styles.listText} selectable>{formatInlineText(text)}</Text>
85
+ </View>
86
+ );
87
+ i++;
88
+ continue;
89
+ }
90
+
91
+ // Bullet list item (- or *) - with nesting via indentation
92
+ const bulletMatch = line.match(/^(\s*)[-*]\s+(.+)$/);
93
+ if (bulletMatch) {
94
+ const indent = Math.floor(bulletMatch[1].length / 2);
95
+ const bulletChar = indent === 0 ? '•' : indent === 1 ? '◦' : '▪';
96
+ elements.push(
97
+ <View key={i} style={[styles.listItem, indent > 0 && { paddingLeft: SPACING.SM + indent * SPACING.LG }]}>
98
+ <Text style={styles.listBullet}>{bulletChar}</Text>
99
+ <Text style={styles.listText} selectable>{formatInlineText(bulletMatch[2])}</Text>
100
+ </View>
101
+ );
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ // Horizontal rule (---, ***, ___)
107
+ if (/^[-*_]{3,}$/.test(trimmed)) {
108
+ elements.push(<View key={i} style={styles.horizontalRule} />);
109
+ i++;
110
+ continue;
111
+ }
112
+
113
+ // Empty line = paragraph break
114
+ if (!trimmed) {
115
+ elements.push(<View key={i} style={styles.paragraphBreak} />);
116
+ i++;
117
+ continue;
118
+ }
119
+
120
+ // Regular text
121
+ elements.push(
122
+ <Text key={i} style={styles.responseText} selectable>{formatInlineText(line)}</Text>
123
+ );
124
+ i++;
125
+ }
126
+
127
+ return (
128
+ <View style={styles.formattedContainer}>
129
+ {elements}
130
+ {isStreaming && <View style={styles.cursor} />}
131
+ </View>
132
+ );
133
+ }
134
+
135
+ // Format inline elements: code spans first, then links, then emphasis
136
+ function formatInlineText(text: string): React.ReactNode {
137
+ // Split on inline code first to avoid processing markdown inside code spans
138
+ const codeParts = text.split(/(`[^`]+`)/g);
139
+ if (codeParts.length === 1) {
140
+ return formatLinks(text);
141
+ }
142
+
143
+ return codeParts.map((part, i) => {
144
+ if (part.startsWith('`') && part.endsWith('`')) {
145
+ return <Text key={i} style={styles.inlineCode}>{part.slice(1, -1)}</Text>;
146
+ }
147
+ return <React.Fragment key={i}>{formatLinks(part)}</React.Fragment>;
148
+ });
149
+ }
150
+
151
+ // Format markdown links [text](url)
152
+ function formatLinks(text: string): React.ReactNode {
153
+ const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
154
+ if (parts.length === 1) return formatEmphasis(text);
155
+
156
+ return parts.map((part, i) => {
157
+ const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
158
+ if (linkMatch) {
159
+ return (
160
+ <Text
161
+ key={i}
162
+ style={styles.linkText}
163
+ onPress={() => Linking.openURL(linkMatch[2])}
164
+ >
165
+ {linkMatch[1]}
166
+ </Text>
167
+ );
168
+ }
169
+ return <React.Fragment key={i}>{formatEmphasis(part)}</React.Fragment>;
170
+ });
171
+ }
172
+
173
+ // Format bold, italic, and strikethrough emphasis
174
+ function formatEmphasis(text: string): React.ReactNode {
175
+ // Match **bold**, ~~strikethrough~~, *italic*, and plain text segments
176
+ const parts = text.split(/(\*\*[^*]+\*\*|~~[^~]+~~|\*[^*]+\*)/g);
177
+ if (parts.length === 1) return text;
178
+
179
+ return parts.map((part, i) => {
180
+ if (part.startsWith('**') && part.endsWith('**')) {
181
+ return <Text key={i} style={styles.boldText}>{part.slice(2, -2)}</Text>;
182
+ }
183
+ if (part.startsWith('~~') && part.endsWith('~~')) {
184
+ return <Text key={i} style={styles.strikethroughText}>{part.slice(2, -2)}</Text>;
185
+ }
186
+ if (part.startsWith('*') && part.endsWith('*')) {
187
+ return <Text key={i} style={styles.italicText}>{part.slice(1, -1)}</Text>;
188
+ }
189
+ return part;
190
+ });
191
+ }
192
+
193
+ const styles = StyleSheet.create({
194
+ formattedContainer: {
195
+ flexDirection: "column",
196
+ },
197
+ responseText: {
198
+ color: "rgba(255,255,255,0.95)",
199
+ fontSize: TYPOGRAPHY.SIZE_LG,
200
+ lineHeight: 22,
201
+ },
202
+ cursor: {
203
+ width: 2,
204
+ height: 18,
205
+ backgroundColor: COLORS.TEXT_PRIMARY,
206
+ marginLeft: 2,
207
+ opacity: 0.7,
208
+ },
209
+ listItem: {
210
+ flexDirection: "row",
211
+ marginVertical: SPACING.XS / 2,
212
+ paddingLeft: SPACING.SM,
213
+ },
214
+ listNumber: {
215
+ color: COLORS.TEXT_MUTED,
216
+ fontSize: TYPOGRAPHY.SIZE_LG,
217
+ lineHeight: 22,
218
+ width: 24,
219
+ fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
220
+ },
221
+ listBullet: {
222
+ color: COLORS.TEXT_MUTED,
223
+ fontSize: TYPOGRAPHY.SIZE_LG,
224
+ lineHeight: 22,
225
+ width: 18,
226
+ },
227
+ listText: {
228
+ flex: 1,
229
+ color: "rgba(255,255,255,0.95)",
230
+ fontSize: TYPOGRAPHY.SIZE_LG,
231
+ lineHeight: 22,
232
+ },
233
+ codeBlock: {
234
+ backgroundColor: "rgba(255,255,255,0.06)",
235
+ borderRadius: SPACING.SM,
236
+ padding: SPACING.MD,
237
+ marginVertical: SPACING.SM,
238
+ },
239
+ codeLang: {
240
+ color: COLORS.TEXT_MUTED,
241
+ fontSize: TYPOGRAPHY.SIZE_XS,
242
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
243
+ marginBottom: SPACING.XS,
244
+ textTransform: "uppercase",
245
+ },
246
+ codeText: {
247
+ color: "rgba(255,255,255,0.85)",
248
+ fontSize: TYPOGRAPHY.SIZE_SM,
249
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
250
+ lineHeight: 18,
251
+ },
252
+ inlineCode: {
253
+ backgroundColor: "rgba(255,255,255,0.1)",
254
+ color: "rgba(255,255,255,0.9)",
255
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
256
+ fontSize: TYPOGRAPHY.SIZE_MD,
257
+ paddingHorizontal: 4,
258
+ borderRadius: 3,
259
+ },
260
+ heading1: {
261
+ color: COLORS.TEXT_PRIMARY,
262
+ fontSize: TYPOGRAPHY.SIZE_XL,
263
+ fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
264
+ lineHeight: 24,
265
+ marginTop: SPACING.MD,
266
+ marginBottom: SPACING.XS,
267
+ },
268
+ heading2: {
269
+ color: "rgba(255,255,255,0.9)",
270
+ fontSize: TYPOGRAPHY.SIZE_LG,
271
+ fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
272
+ lineHeight: 22,
273
+ marginTop: SPACING.SM,
274
+ marginBottom: SPACING.XS,
275
+ },
276
+ heading3: {
277
+ color: "rgba(255,255,255,0.85)",
278
+ fontSize: TYPOGRAPHY.SIZE_LG,
279
+ fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
280
+ lineHeight: 22,
281
+ marginTop: SPACING.SM,
282
+ marginBottom: SPACING.XS,
283
+ },
284
+ boldText: {
285
+ fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
286
+ color: COLORS.TEXT_PRIMARY,
287
+ },
288
+ italicText: {
289
+ fontStyle: "italic",
290
+ color: "rgba(255,255,255,0.85)",
291
+ },
292
+ strikethroughText: {
293
+ textDecorationLine: "line-through",
294
+ color: COLORS.TEXT_MUTED,
295
+ },
296
+ linkText: {
297
+ color: COLORS.STATUS_INFO,
298
+ textDecorationLine: "underline",
299
+ },
300
+ blockquote: {
301
+ borderLeftWidth: 3,
302
+ borderLeftColor: "rgba(255,255,255,0.15)",
303
+ paddingLeft: SPACING.MD,
304
+ marginVertical: SPACING.XS,
305
+ },
306
+ blockquoteText: {
307
+ color: COLORS.TEXT_SECONDARY,
308
+ fontSize: TYPOGRAPHY.SIZE_LG,
309
+ lineHeight: 22,
310
+ fontStyle: "italic",
311
+ },
312
+ horizontalRule: {
313
+ height: 1,
314
+ backgroundColor: COLORS.BORDER,
315
+ marginVertical: SPACING.MD,
316
+ },
317
+ paragraphBreak: {
318
+ height: SPACING.SM,
319
+ },
320
+ });