@comergehq/studio 0.1.22 → 0.1.24
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.
- package/dist/index.d.mts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +697 -312
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +724 -336
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/bubble/Bubble.tsx +11 -5
- package/src/components/bubble/types.ts +2 -0
- package/src/components/chat/ChatComposer.tsx +4 -21
- package/src/components/chat/ChatMessageBubble.tsx +33 -2
- package/src/components/chat/ChatMessageList.tsx +12 -1
- package/src/components/chat/ChatPage.tsx +8 -14
- package/src/components/merge-requests/ReviewMergeRequestCard.tsx +1 -1
- package/src/components/primitives/MarkdownText.tsx +134 -35
- package/src/components/studio-sheet/StudioBottomSheet.tsx +26 -29
- package/src/core/services/http/index.ts +64 -1
- package/src/core/services/supabase/realtimeManager.ts +55 -1
- package/src/data/agent/types.ts +1 -0
- package/src/data/apps/bundles/remote.ts +4 -3
- package/src/data/users/types.ts +1 -1
- package/src/index.ts +1 -0
- package/src/studio/ComergeStudio.tsx +6 -2
- package/src/studio/hooks/useApp.ts +24 -6
- package/src/studio/hooks/useBundleManager.ts +12 -1
- package/src/studio/hooks/useForegroundSignal.ts +2 -4
- package/src/studio/hooks/useMergeRequests.ts +6 -1
- package/src/studio/hooks/useOptimisticChatMessages.ts +55 -3
- package/src/studio/hooks/useStudioActions.ts +60 -6
- package/src/studio/hooks/useThreadMessages.ts +26 -5
- package/src/studio/ui/ChatPanel.tsx +6 -3
- package/src/studio/ui/StudioOverlay.tsx +7 -2
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@ import { DEFAULT_EDGE_PADDING, DEFAULT_OFFSET, DEFAULT_SIZE, ENTER_ROTATION_FROM
|
|
|
26
26
|
import type { BubbleProps } from './types';
|
|
27
27
|
import { useTheme } from '../../theme';
|
|
28
28
|
import { ResettableLiquidGlassView } from '../utils/ResettableLiquidGlassView';
|
|
29
|
+
import { withAlpha } from '../utils/color';
|
|
29
30
|
|
|
30
31
|
const HIDDEN_OFFSET_X = 20;
|
|
31
32
|
|
|
@@ -59,6 +60,7 @@ export function Bubble({
|
|
|
59
60
|
disabled = false,
|
|
60
61
|
ariaLabel,
|
|
61
62
|
isLoading = false,
|
|
63
|
+
loadingBorderTone = 'default',
|
|
62
64
|
visible = true,
|
|
63
65
|
badgeCount = 0,
|
|
64
66
|
offset = DEFAULT_OFFSET,
|
|
@@ -84,6 +86,10 @@ export function Bubble({
|
|
|
84
86
|
if (isDanger) return 'rgba(239, 68, 68, 0.9)';
|
|
85
87
|
return theme.scheme === 'dark' ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)';
|
|
86
88
|
}, [backgroundColor, isDanger, theme.scheme]);
|
|
89
|
+
const warningRingColors = useMemo(
|
|
90
|
+
() => [withAlpha(theme.colors.warning, 0.35), withAlpha(theme.colors.warning, 1)] as const,
|
|
91
|
+
[theme.colors.warning]
|
|
92
|
+
);
|
|
87
93
|
|
|
88
94
|
const translateX = useSharedValue(getHiddenTranslateX(size));
|
|
89
95
|
const translateY = useSharedValue(getHiddenTranslateY(height));
|
|
@@ -118,11 +124,8 @@ export function Bubble({
|
|
|
118
124
|
// noop
|
|
119
125
|
}
|
|
120
126
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
onPressRef.current?.();
|
|
124
|
-
},
|
|
125
|
-
});
|
|
127
|
+
onPressRef.current?.();
|
|
128
|
+
animateToHidden();
|
|
126
129
|
}, [animateToHidden]);
|
|
127
130
|
|
|
128
131
|
useEffect(() => {
|
|
@@ -217,11 +220,14 @@ export function Bubble({
|
|
|
217
220
|
}));
|
|
218
221
|
|
|
219
222
|
const borderAnimatedStyle = useAnimatedStyle(() => {
|
|
223
|
+
const isWarning = loadingBorderTone === 'warning';
|
|
220
224
|
const borderColor = interpolateColor(
|
|
221
225
|
borderPulse.value,
|
|
222
226
|
[0, 1],
|
|
223
227
|
isDanger
|
|
224
228
|
? ['rgba(239,68,68,0.4)', 'rgba(239,68,68,1)']
|
|
229
|
+
: isWarning
|
|
230
|
+
? warningRingColors
|
|
225
231
|
: theme.scheme === 'dark'
|
|
226
232
|
? ['rgba(255,255,255,0.2)', 'rgba(255,255,255,0.9)']
|
|
227
233
|
: ['rgba(55,0,179,0.2)', 'rgba(55,0,179,0.9)']
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
View,
|
|
10
10
|
type ViewStyle,
|
|
11
11
|
} from 'react-native';
|
|
12
|
-
import { isLiquidGlassSupported } from '@callstack/liquid-glass';
|
|
12
|
+
import { isLiquidGlassSupported, LiquidGlassView } from '@callstack/liquid-glass';
|
|
13
13
|
import { Plus } from 'lucide-react-native';
|
|
14
14
|
|
|
15
15
|
import { useTheme } from '../../theme';
|
|
@@ -25,7 +25,6 @@ export type ChatComposerProps = {
|
|
|
25
25
|
disabled?: boolean;
|
|
26
26
|
sendDisabled?: boolean;
|
|
27
27
|
sending?: boolean;
|
|
28
|
-
autoFocus?: boolean;
|
|
29
28
|
onSend: (text: string, attachments?: string[]) => void | Promise<void>;
|
|
30
29
|
attachments?: string[];
|
|
31
30
|
onRemoveAttachment?: (index: number) => void;
|
|
@@ -95,7 +94,6 @@ export function ChatComposer({
|
|
|
95
94
|
disabled = false,
|
|
96
95
|
sendDisabled = false,
|
|
97
96
|
sending = false,
|
|
98
|
-
autoFocus = false,
|
|
99
97
|
onSend,
|
|
100
98
|
attachments = [],
|
|
101
99
|
onRemoveAttachment,
|
|
@@ -119,20 +117,6 @@ export function ChatComposer({
|
|
|
119
117
|
const maxInputHeight = React.useMemo(() => Dimensions.get('window').height * 0.5, []);
|
|
120
118
|
const shakeAnim = React.useRef(new Animated.Value(0)).current;
|
|
121
119
|
const [sendPressed, setSendPressed] = React.useState(false);
|
|
122
|
-
const inputRef = React.useRef<import('react-native').TextInput | null>(null);
|
|
123
|
-
const prevAutoFocusRef = React.useRef(false);
|
|
124
|
-
|
|
125
|
-
React.useEffect(() => {
|
|
126
|
-
const shouldFocus = autoFocus && !prevAutoFocusRef.current && !disabled && !sending;
|
|
127
|
-
prevAutoFocusRef.current = autoFocus;
|
|
128
|
-
if (!shouldFocus) return;
|
|
129
|
-
|
|
130
|
-
// Temporary workaround: Bottom sheets can take a moment to open
|
|
131
|
-
const t = setTimeout(() => {
|
|
132
|
-
inputRef.current?.focus();
|
|
133
|
-
}, 75);
|
|
134
|
-
return () => clearTimeout(t);
|
|
135
|
-
}, [autoFocus, disabled, sending]);
|
|
136
120
|
|
|
137
121
|
const triggerShake = React.useCallback(() => {
|
|
138
122
|
shakeAnim.setValue(0);
|
|
@@ -168,7 +152,7 @@ export function ChatComposer({
|
|
|
168
152
|
>
|
|
169
153
|
<View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 8 }}>
|
|
170
154
|
<Animated.View style={{ flex: 1, transform: [{ translateX: shakeAnim }] }}>
|
|
171
|
-
<
|
|
155
|
+
<LiquidGlassView
|
|
172
156
|
style={[
|
|
173
157
|
// LiquidGlassView doesn't reliably auto-size to children; ensure enough height for the
|
|
174
158
|
// thumbnail strip when attachments are present.
|
|
@@ -219,13 +203,12 @@ export function ChatComposer({
|
|
|
219
203
|
) : null}
|
|
220
204
|
|
|
221
205
|
<MultilineTextInput
|
|
222
|
-
ref={inputRef}
|
|
223
206
|
value={text}
|
|
224
207
|
onChangeText={setText}
|
|
225
208
|
placeholder={placeholder}
|
|
226
209
|
editable={!disabled && !sending}
|
|
227
210
|
useBottomSheetTextInput={useBottomSheetTextInput}
|
|
228
|
-
autoFocus={
|
|
211
|
+
autoFocus={false}
|
|
229
212
|
placeholderTextColor={placeholderTextColor}
|
|
230
213
|
scrollEnabled
|
|
231
214
|
style={{
|
|
@@ -237,7 +220,7 @@ export function ChatComposer({
|
|
|
237
220
|
lineHeight: 20,
|
|
238
221
|
}}
|
|
239
222
|
/>
|
|
240
|
-
</
|
|
223
|
+
</LiquidGlassView>
|
|
241
224
|
</Animated.View>
|
|
242
225
|
|
|
243
226
|
<ResettableLiquidGlassView
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { View, type ViewStyle } from 'react-native';
|
|
3
|
-
import { CheckCheck, GitMerge } from 'lucide-react-native';
|
|
3
|
+
import { CheckCheck, GitMerge, RotateCcw } from 'lucide-react-native';
|
|
4
4
|
|
|
5
5
|
import type { ChatMessage } from '../models/types';
|
|
6
6
|
import { useTheme } from '../../theme';
|
|
7
|
+
import { Button } from '../primitives/Button';
|
|
7
8
|
import { MarkdownText } from '../primitives/MarkdownText';
|
|
8
9
|
import { Surface } from '../primitives/Surface';
|
|
10
|
+
import { Text } from '../primitives/Text';
|
|
9
11
|
|
|
10
12
|
export type ChatMessageBubbleProps = {
|
|
11
13
|
message: ChatMessage;
|
|
@@ -13,10 +15,13 @@ export type ChatMessageBubbleProps = {
|
|
|
13
15
|
* Optional custom renderer for message content (e.g. markdown).
|
|
14
16
|
*/
|
|
15
17
|
renderContent?: (message: ChatMessage) => React.ReactNode;
|
|
18
|
+
isLast?: boolean;
|
|
19
|
+
retrying?: boolean;
|
|
20
|
+
onRetry?: () => void;
|
|
16
21
|
style?: ViewStyle;
|
|
17
22
|
};
|
|
18
23
|
|
|
19
|
-
export function ChatMessageBubble({ message, renderContent, style }: ChatMessageBubbleProps) {
|
|
24
|
+
export function ChatMessageBubble({ message, renderContent, isLast, retrying, onRetry, style }: ChatMessageBubbleProps) {
|
|
20
25
|
const theme = useTheme();
|
|
21
26
|
const metaEvent = message.meta?.event ?? null;
|
|
22
27
|
const metaStatus = message.meta?.status ?? null;
|
|
@@ -36,6 +41,8 @@ export function ChatMessageBubble({ message, renderContent, style }: ChatMessage
|
|
|
36
41
|
|
|
37
42
|
const bodyColor =
|
|
38
43
|
metaStatus === 'success' ? theme.colors.success : metaStatus === 'error' ? theme.colors.danger : undefined;
|
|
44
|
+
const showRetry = Boolean(onRetry) && isLast && metaStatus === 'error';
|
|
45
|
+
const retryLabel = retrying ? 'Retrying...' : 'Retry';
|
|
39
46
|
|
|
40
47
|
return (
|
|
41
48
|
<View style={[align, style]}>
|
|
@@ -65,6 +72,30 @@ export function ChatMessageBubble({ message, renderContent, style }: ChatMessage
|
|
|
65
72
|
</View>
|
|
66
73
|
</View>
|
|
67
74
|
</Surface>
|
|
75
|
+
{showRetry ? (
|
|
76
|
+
<View style={{ marginTop: theme.spacing.xs, alignSelf: align.alignSelf }}>
|
|
77
|
+
<Button
|
|
78
|
+
variant="ghost"
|
|
79
|
+
size="sm"
|
|
80
|
+
onPress={onRetry}
|
|
81
|
+
disabled={retrying}
|
|
82
|
+
style={{ borderColor: theme.colors.danger }}
|
|
83
|
+
accessibilityLabel="Retry send"
|
|
84
|
+
>
|
|
85
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
86
|
+
{!retrying ? <RotateCcw size={14} color={theme.colors.danger} /> : null}
|
|
87
|
+
<Text
|
|
88
|
+
variant="caption"
|
|
89
|
+
color={theme.colors.danger}
|
|
90
|
+
style={{ marginLeft: retrying ? 0 : theme.spacing.xs }}
|
|
91
|
+
numberOfLines={1}
|
|
92
|
+
>
|
|
93
|
+
{retryLabel}
|
|
94
|
+
</Text>
|
|
95
|
+
</View>
|
|
96
|
+
</Button>
|
|
97
|
+
</View>
|
|
98
|
+
) : null}
|
|
68
99
|
</View>
|
|
69
100
|
);
|
|
70
101
|
}
|
|
@@ -15,6 +15,8 @@ export type ChatMessageListProps = {
|
|
|
15
15
|
messages: ChatMessage[];
|
|
16
16
|
showTypingIndicator?: boolean;
|
|
17
17
|
renderMessageContent?: ChatMessageBubbleProps['renderContent'];
|
|
18
|
+
onRetryMessage?: (messageId: string) => void;
|
|
19
|
+
isRetryingMessage?: (messageId: string) => boolean;
|
|
18
20
|
contentStyle?: ViewStyle;
|
|
19
21
|
bottomInset?: number;
|
|
20
22
|
/**
|
|
@@ -33,6 +35,8 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
33
35
|
messages,
|
|
34
36
|
showTypingIndicator = false,
|
|
35
37
|
renderMessageContent,
|
|
38
|
+
onRetryMessage,
|
|
39
|
+
isRetryingMessage,
|
|
36
40
|
contentStyle,
|
|
37
41
|
bottomInset = 0,
|
|
38
42
|
onNearBottomChange,
|
|
@@ -49,6 +53,7 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
49
53
|
const data = React.useMemo(() => {
|
|
50
54
|
return [...messages].reverse();
|
|
51
55
|
}, [messages]);
|
|
56
|
+
const lastMessageId = messages.length > 0 ? messages[messages.length - 1]!.id : null;
|
|
52
57
|
|
|
53
58
|
const scrollToBottom = React.useCallback((options?: { animated?: boolean }) => {
|
|
54
59
|
const animated = options?.animated ?? true;
|
|
@@ -121,7 +126,13 @@ export const ChatMessageList = React.forwardRef<ChatMessageListRef, ChatMessageL
|
|
|
121
126
|
]}
|
|
122
127
|
ItemSeparatorComponent={() => <View style={{ height: theme.spacing.sm }} />}
|
|
123
128
|
renderItem={({ item }: { item: ChatMessage }) => (
|
|
124
|
-
<ChatMessageBubble
|
|
129
|
+
<ChatMessageBubble
|
|
130
|
+
message={item}
|
|
131
|
+
renderContent={renderMessageContent}
|
|
132
|
+
isLast={Boolean(lastMessageId && item.id === lastMessageId)}
|
|
133
|
+
retrying={isRetryingMessage?.(item.id) ?? false}
|
|
134
|
+
onRetry={onRetryMessage ? () => onRetryMessage(item.id) : undefined}
|
|
135
|
+
/>
|
|
125
136
|
)}
|
|
126
137
|
ListHeaderComponent={
|
|
127
138
|
<View>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Platform, View, type ViewStyle } from 'react-native';
|
|
3
3
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
4
4
|
|
|
5
5
|
import type { ChatMessage } from '../models/types';
|
|
@@ -12,6 +12,8 @@ export type ChatPageProps = {
|
|
|
12
12
|
messages: ChatMessage[];
|
|
13
13
|
showTypingIndicator?: boolean;
|
|
14
14
|
renderMessageContent?: ChatMessageListProps['renderMessageContent'];
|
|
15
|
+
onRetryMessage?: ChatMessageListProps['onRetryMessage'];
|
|
16
|
+
isRetryingMessage?: ChatMessageListProps['isRetryingMessage'];
|
|
15
17
|
topBanner?: React.ReactNode;
|
|
16
18
|
composerTop?: React.ReactNode;
|
|
17
19
|
composer: Omit<ChatComposerProps, 'attachments'> & {
|
|
@@ -32,6 +34,8 @@ export function ChatPage({
|
|
|
32
34
|
messages,
|
|
33
35
|
showTypingIndicator,
|
|
34
36
|
renderMessageContent,
|
|
37
|
+
onRetryMessage,
|
|
38
|
+
isRetryingMessage,
|
|
35
39
|
topBanner,
|
|
36
40
|
composerTop,
|
|
37
41
|
composer,
|
|
@@ -45,19 +49,7 @@ export function ChatPage({
|
|
|
45
49
|
const insets = useSafeAreaInsets();
|
|
46
50
|
const [composerHeight, setComposerHeight] = React.useState(0);
|
|
47
51
|
const [composerTopHeight, setComposerTopHeight] = React.useState(0);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
React.useEffect(() => {
|
|
51
|
-
if (Platform.OS !== 'ios') return;
|
|
52
|
-
const show = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true));
|
|
53
|
-
const hide = Keyboard.addListener('keyboardWillHide', () => setKeyboardVisible(false));
|
|
54
|
-
return () => {
|
|
55
|
-
show.remove();
|
|
56
|
-
hide.remove();
|
|
57
|
-
};
|
|
58
|
-
}, []);
|
|
59
|
-
|
|
60
|
-
const footerBottomPadding = Platform.OS === 'ios' ? (keyboardVisible ? 0 : insets.bottom) : insets.bottom + 10;
|
|
52
|
+
const footerBottomPadding = Platform.OS === 'ios' ? insets.bottom : insets.bottom + 10;
|
|
61
53
|
const totalComposerHeight = composerHeight + composerTopHeight;
|
|
62
54
|
const overlayBottom = totalComposerHeight + footerBottomPadding + theme.spacing.lg;
|
|
63
55
|
const bottomInset = totalComposerHeight + footerBottomPadding + theme.spacing.xl;
|
|
@@ -92,6 +84,8 @@ export function ChatPage({
|
|
|
92
84
|
messages={messages}
|
|
93
85
|
showTypingIndicator={showTypingIndicator}
|
|
94
86
|
renderMessageContent={renderMessageContent}
|
|
87
|
+
onRetryMessage={onRetryMessage}
|
|
88
|
+
isRetryingMessage={isRetryingMessage}
|
|
95
89
|
onNearBottomChange={onNearBottomChange}
|
|
96
90
|
bottomInset={bottomInset}
|
|
97
91
|
/>
|
|
@@ -109,7 +109,7 @@ export function ReviewMergeRequestCard({
|
|
|
109
109
|
|
|
110
110
|
<Text style={{ color: theme.colors.textMuted, fontSize: 12, lineHeight: 16, marginBottom: 12 }}>
|
|
111
111
|
{creator
|
|
112
|
-
? `${creator.
|
|
112
|
+
? `${creator.approvedOrMergedMergeRequests} approved merge${creator.approvedOrMergedMergeRequests !== 1 ? 's' : ''}`
|
|
113
113
|
: 'Loading stats...'}
|
|
114
114
|
</Text>
|
|
115
115
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { Platform, View, type ViewStyle } from 'react-native';
|
|
1
|
+
import { Platform, Pressable, Text, View, type ViewStyle } from 'react-native';
|
|
2
2
|
|
|
3
3
|
import Markdown from 'react-native-markdown-display';
|
|
4
4
|
|
|
5
5
|
import { useTheme } from '../../theme';
|
|
6
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
7
|
|
|
7
8
|
export type MarkdownTextVariant = 'chat' | 'mergeRequest';
|
|
8
9
|
|
|
@@ -16,9 +17,47 @@ export type MarkdownTextProps = {
|
|
|
16
17
|
style?: ViewStyle;
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
function copyMarkdownToClipboard(markdown: string) {
|
|
21
|
+
if (!markdown) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const navigatorClipboard = globalThis?.navigator?.clipboard;
|
|
26
|
+
if (navigatorClipboard?.writeText) {
|
|
27
|
+
void navigatorClipboard.writeText(markdown);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
33
|
+
const expoClipboard = require('expo-clipboard');
|
|
34
|
+
if (expoClipboard?.setStringAsync) {
|
|
35
|
+
void expoClipboard.setStringAsync(markdown);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// optional dependency; fall through
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
44
|
+
const rnClipboard = require('@react-native-clipboard/clipboard');
|
|
45
|
+
if (rnClipboard?.setString) {
|
|
46
|
+
rnClipboard.setString(markdown);
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// optional dependency; nothing else to try
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
19
53
|
export function MarkdownText({ markdown, variant = 'chat', bodyColor, style }: MarkdownTextProps) {
|
|
20
54
|
const theme = useTheme();
|
|
21
55
|
const isDark = theme.scheme === 'dark';
|
|
56
|
+
const [showCopied, setShowCopied] = useState(false);
|
|
57
|
+
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
|
|
58
|
+
const [tooltipWidth, setTooltipWidth] = useState(0);
|
|
59
|
+
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
60
|
+
const containerRef = useRef<View>(null);
|
|
22
61
|
|
|
23
62
|
const baseBodyColor = variant === 'mergeRequest' ? theme.colors.textMuted : theme.colors.text;
|
|
24
63
|
const linkColor =
|
|
@@ -31,41 +70,101 @@ export function MarkdownText({ markdown, variant = 'chat', bodyColor, style }: M
|
|
|
31
70
|
const paragraphBottom = variant === 'mergeRequest' ? 8 : 6;
|
|
32
71
|
const baseLineHeight = variant === 'mergeRequest' ? 22 : 20;
|
|
33
72
|
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
return () => {
|
|
75
|
+
if (hideTimerRef.current) {
|
|
76
|
+
clearTimeout(hideTimerRef.current);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const handleLongPress = (event: {
|
|
82
|
+
nativeEvent: { locationX: number; locationY: number; pageX: number; pageY: number };
|
|
83
|
+
}) => {
|
|
84
|
+
const { locationX, locationY, pageX, pageY } = event.nativeEvent;
|
|
85
|
+
|
|
86
|
+
if (containerRef.current?.measureInWindow) {
|
|
87
|
+
containerRef.current.measureInWindow((x, y) => {
|
|
88
|
+
setTooltipPosition({ x: pageX - x, y: pageY - y });
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
setTooltipPosition({ x: locationX, y: locationY });
|
|
92
|
+
}
|
|
93
|
+
copyMarkdownToClipboard(markdown);
|
|
94
|
+
setShowCopied(true);
|
|
95
|
+
|
|
96
|
+
if (hideTimerRef.current) {
|
|
97
|
+
clearTimeout(hideTimerRef.current);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
hideTimerRef.current = setTimeout(() => {
|
|
101
|
+
setShowCopied(false);
|
|
102
|
+
}, 1200);
|
|
103
|
+
};
|
|
104
|
+
|
|
34
105
|
return (
|
|
35
|
-
<
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
106
|
+
<Pressable style={style} onLongPress={handleLongPress}>
|
|
107
|
+
<View ref={containerRef} style={{ position: 'relative' }}>
|
|
108
|
+
<Markdown
|
|
109
|
+
style={{
|
|
110
|
+
body: { color: bodyColor ?? baseBodyColor, fontSize: 14, lineHeight: baseLineHeight },
|
|
111
|
+
paragraph: { marginTop: 0, marginBottom: paragraphBottom },
|
|
112
|
+
link: { color: linkColor, fontWeight: linkWeight },
|
|
113
|
+
code_inline: {
|
|
114
|
+
backgroundColor: codeBgColor,
|
|
115
|
+
color: codeTextColor,
|
|
116
|
+
paddingHorizontal: variant === 'mergeRequest' ? 6 : 4,
|
|
117
|
+
paddingVertical: variant === 'mergeRequest' ? 2 : 0,
|
|
118
|
+
borderRadius: variant === 'mergeRequest' ? 6 : 4,
|
|
119
|
+
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
|
120
|
+
fontSize: 13,
|
|
121
|
+
},
|
|
122
|
+
code_block: {
|
|
123
|
+
backgroundColor: codeBgColor,
|
|
124
|
+
color: codeTextColor,
|
|
125
|
+
padding: variant === 'mergeRequest' ? 12 : 8,
|
|
126
|
+
borderRadius: variant === 'mergeRequest' ? 8 : 6,
|
|
127
|
+
marginVertical: variant === 'mergeRequest' ? 8 : 0,
|
|
128
|
+
},
|
|
129
|
+
fence: {
|
|
130
|
+
backgroundColor: codeBgColor,
|
|
131
|
+
color: codeTextColor,
|
|
132
|
+
padding: variant === 'mergeRequest' ? 12 : 8,
|
|
133
|
+
borderRadius: variant === 'mergeRequest' ? 8 : 6,
|
|
134
|
+
marginVertical: variant === 'mergeRequest' ? 8 : 0,
|
|
135
|
+
},
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{markdown}
|
|
139
|
+
</Markdown>
|
|
140
|
+
{showCopied && tooltipPosition ? (
|
|
141
|
+
<View
|
|
142
|
+
pointerEvents="none"
|
|
143
|
+
style={{
|
|
144
|
+
position: 'absolute',
|
|
145
|
+
left: tooltipPosition.x,
|
|
146
|
+
top: tooltipPosition.y - theme.spacing.lg - 32,
|
|
147
|
+
backgroundColor: theme.colors.success,
|
|
148
|
+
borderRadius: theme.radii.pill,
|
|
149
|
+
paddingHorizontal: theme.spacing.sm,
|
|
150
|
+
paddingVertical: theme.spacing.xs,
|
|
151
|
+
transform: [{ translateX: tooltipWidth ? -tooltipWidth / 2 : 0 }],
|
|
152
|
+
}}
|
|
153
|
+
onLayout={(event) => setTooltipWidth(event.nativeEvent.layout.width)}
|
|
154
|
+
>
|
|
155
|
+
<Text
|
|
156
|
+
style={{
|
|
157
|
+
color: theme.colors.onSuccess,
|
|
158
|
+
fontSize: theme.typography.fontSize.xs,
|
|
159
|
+
fontWeight: theme.typography.fontWeight.medium,
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
Copied
|
|
163
|
+
</Text>
|
|
164
|
+
</View>
|
|
165
|
+
) : null}
|
|
166
|
+
</View>
|
|
167
|
+
</Pressable>
|
|
69
168
|
);
|
|
70
169
|
}
|
|
71
170
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { AppState, Keyboard,
|
|
3
|
-
import
|
|
2
|
+
import { AppState, Keyboard, View, type AppStateStatus } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
BottomSheetModal,
|
|
5
|
+
type BottomSheetBackgroundProps,
|
|
6
|
+
type BottomSheetModalProps,
|
|
7
|
+
} from '@gorhom/bottom-sheet';
|
|
4
8
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
5
9
|
|
|
6
10
|
import { useTheme } from '../../theme';
|
|
@@ -20,9 +24,9 @@ export type StudioBottomSheetProps = {
|
|
|
20
24
|
snapPoints?: StudioSheetSnapPoints;
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
|
-
* Optional ref forwarding to control the
|
|
27
|
+
* Optional ref forwarding to control the BottomSheetModal imperatively.
|
|
24
28
|
*/
|
|
25
|
-
sheetRef?: React.RefObject<
|
|
29
|
+
sheetRef?: React.RefObject<BottomSheetModal | null>;
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
32
|
* Provide a custom background renderer (e.g. BlurView).
|
|
@@ -35,10 +39,10 @@ export type StudioBottomSheetProps = {
|
|
|
35
39
|
children: React.ReactNode;
|
|
36
40
|
|
|
37
41
|
/**
|
|
38
|
-
* Additional
|
|
42
|
+
* Additional BottomSheetModal props
|
|
39
43
|
*/
|
|
40
44
|
bottomSheetProps?: Omit<
|
|
41
|
-
|
|
45
|
+
BottomSheetModalProps,
|
|
42
46
|
| 'ref'
|
|
43
47
|
| 'index'
|
|
44
48
|
| 'snapPoints'
|
|
@@ -48,6 +52,7 @@ export type StudioBottomSheetProps = {
|
|
|
48
52
|
| 'bottomInset'
|
|
49
53
|
| 'handleIndicatorStyle'
|
|
50
54
|
| 'onChange'
|
|
55
|
+
| 'onDismiss'
|
|
51
56
|
| 'children'
|
|
52
57
|
>;
|
|
53
58
|
};
|
|
@@ -63,31 +68,21 @@ export function StudioBottomSheet({
|
|
|
63
68
|
}: StudioBottomSheetProps) {
|
|
64
69
|
const theme = useTheme();
|
|
65
70
|
const insets = useSafeAreaInsets();
|
|
66
|
-
const internalSheetRef = React.useRef<
|
|
71
|
+
const internalSheetRef = React.useRef<BottomSheetModal | null>(null);
|
|
67
72
|
const resolvedSheetRef = sheetRef ?? internalSheetRef;
|
|
68
|
-
const
|
|
73
|
+
const resolvedSnapPoints = React.useMemo<(string | number)[]>(() => [...snapPoints], [snapPoints]);
|
|
74
|
+
const currentIndexRef = React.useRef<number>(open ? resolvedSnapPoints.length - 1 : -1);
|
|
69
75
|
const lastAppStateRef = React.useRef<AppStateStatus>(AppState.currentState);
|
|
70
76
|
|
|
71
|
-
// Workaround: @gorhom/bottom-sheet can occasionally render empty content after app resume.
|
|
77
|
+
// Workaround: @gorhom/bottom-sheet can occasionally render empty content after app resume if the keyboard is open.
|
|
72
78
|
React.useEffect(() => {
|
|
73
79
|
const sub = AppState.addEventListener('change', (state) => {
|
|
74
|
-
const prev = lastAppStateRef.current;
|
|
75
80
|
lastAppStateRef.current = state;
|
|
76
81
|
|
|
77
82
|
if (state === 'background' || state === 'inactive') {
|
|
78
83
|
Keyboard.dismiss();
|
|
79
84
|
return;
|
|
80
85
|
}
|
|
81
|
-
|
|
82
|
-
if (state !== 'active') return;
|
|
83
|
-
const sheet = resolvedSheetRef.current;
|
|
84
|
-
if (!sheet) return;
|
|
85
|
-
const idx = currentIndexRef.current;
|
|
86
|
-
if (open && idx >= 0) {
|
|
87
|
-
Keyboard.dismiss();
|
|
88
|
-
requestAnimationFrame(() => sheet.snapToIndex(idx));
|
|
89
|
-
setTimeout(() => sheet.snapToIndex(idx), 120);
|
|
90
|
-
}
|
|
91
86
|
});
|
|
92
87
|
return () => sub.remove();
|
|
93
88
|
}, [open, resolvedSheetRef]);
|
|
@@ -97,12 +92,11 @@ export function StudioBottomSheet({
|
|
|
97
92
|
if (!sheet) return;
|
|
98
93
|
|
|
99
94
|
if (open) {
|
|
100
|
-
|
|
101
|
-
sheet.snapToIndex(snapPoints.length - 1);
|
|
95
|
+
sheet.present();
|
|
102
96
|
} else {
|
|
103
|
-
sheet.
|
|
97
|
+
sheet.dismiss();
|
|
104
98
|
}
|
|
105
|
-
}, [open, resolvedSheetRef,
|
|
99
|
+
}, [open, resolvedSheetRef, resolvedSnapPoints.length]);
|
|
106
100
|
|
|
107
101
|
const handleChange = React.useCallback(
|
|
108
102
|
(index: number) => {
|
|
@@ -113,13 +107,15 @@ export function StudioBottomSheet({
|
|
|
113
107
|
);
|
|
114
108
|
|
|
115
109
|
return (
|
|
116
|
-
<
|
|
110
|
+
<BottomSheetModal
|
|
117
111
|
ref={resolvedSheetRef}
|
|
118
|
-
index={
|
|
119
|
-
snapPoints={
|
|
112
|
+
index={resolvedSnapPoints.length - 1}
|
|
113
|
+
snapPoints={resolvedSnapPoints}
|
|
120
114
|
enableDynamicSizing={false}
|
|
121
115
|
enablePanDownToClose
|
|
122
116
|
enableContentPanningGesture={false}
|
|
117
|
+
keyboardBehavior="interactive"
|
|
118
|
+
keyboardBlurBehavior="restore"
|
|
123
119
|
android_keyboardInputMode="adjustResize"
|
|
124
120
|
backgroundComponent={(props: BottomSheetBackgroundProps) => (
|
|
125
121
|
<StudioSheetBackground {...props} renderBackground={background?.renderBackground} />
|
|
@@ -127,11 +123,12 @@ export function StudioBottomSheet({
|
|
|
127
123
|
topInset={insets.top}
|
|
128
124
|
bottomInset={0}
|
|
129
125
|
handleIndicatorStyle={{ backgroundColor: theme.colors.handleIndicator }}
|
|
130
|
-
onChange={handleChange}
|
|
131
126
|
{...bottomSheetProps}
|
|
127
|
+
onChange={handleChange}
|
|
128
|
+
onDismiss={() => onOpenChange?.(false)}
|
|
132
129
|
>
|
|
133
130
|
<View style={{ flex: 1, overflow: 'hidden' }}>{children}</View>
|
|
134
|
-
</
|
|
131
|
+
</BottomSheetModal>
|
|
135
132
|
);
|
|
136
133
|
}
|
|
137
134
|
|