@comergehq/studio 0.1.1 → 0.1.3

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.
Files changed (172) hide show
  1. package/dist/index.js +255 -245
  2. package/dist/index.js.map +1 -1
  3. package/dist/index.mjs +213 -203
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +9 -6
  6. package/src/components/chat/ChatComposer.tsx +277 -0
  7. package/src/components/chat/ChatHeader.tsx +31 -0
  8. package/src/components/chat/ChatMessageBubble.tsx +69 -0
  9. package/src/components/chat/ChatMessageList.tsx +137 -0
  10. package/src/components/chat/ChatPage.tsx +69 -0
  11. package/src/components/chat/ForkNoticeBanner.tsx +66 -0
  12. package/src/components/chat/MultilineTextInput.tsx +46 -0
  13. package/src/components/chat/ScrollToBottomButton.tsx +78 -0
  14. package/src/components/chat/TypingIndicator.tsx +54 -0
  15. package/src/components/chat/index.ts +28 -0
  16. package/src/components/comments/AppCommentsSheet.tsx +213 -0
  17. package/src/components/comments/CommentRow.tsx +63 -0
  18. package/src/components/comments/formatTimeAgo.ts +3 -0
  19. package/src/components/comments/index.ts +3 -0
  20. package/src/components/comments/useAppComments.ts +74 -0
  21. package/src/components/comments/useAppDetails.ts +35 -0
  22. package/src/components/comments/useIosKeyboardSnapFix.ts +24 -0
  23. package/src/components/dialogs/ConfirmMergeRequestDialog.tsx +156 -0
  24. package/src/components/dialogs/index.ts +4 -0
  25. package/src/components/draw/DrawColorPicker.tsx +77 -0
  26. package/src/components/draw/DrawModeOverlay.tsx +144 -0
  27. package/src/components/draw/DrawSurface.tsx +127 -0
  28. package/src/components/draw/DrawToolbar.tsx +253 -0
  29. package/src/components/draw/index.ts +15 -0
  30. package/src/components/draw/optionalHaptics.ts +15 -0
  31. package/src/components/draw/strokes.ts +21 -0
  32. package/src/components/draw/types.ts +9 -0
  33. package/src/components/floating-draggable-button/FloatingDraggableButton.tsx +323 -0
  34. package/src/components/floating-draggable-button/constants.ts +17 -0
  35. package/src/components/floating-draggable-button/index.ts +4 -0
  36. package/src/components/floating-draggable-button/types.ts +63 -0
  37. package/src/components/icons/MergeIcon.tsx +14 -0
  38. package/src/components/icons/StudioIcons.tsx +66 -0
  39. package/src/components/index.ts +17 -0
  40. package/src/components/merge-requests/MergeRequestStatusCard.tsx +179 -0
  41. package/src/components/merge-requests/ReviewMergeRequestActionButton.tsx +62 -0
  42. package/src/components/merge-requests/ReviewMergeRequestCard.tsx +192 -0
  43. package/src/components/merge-requests/ReviewMergeRequestCarousel.tsx +132 -0
  44. package/src/components/merge-requests/index.ts +7 -0
  45. package/src/components/merge-requests/mergeRequestStatusDisplay.ts +23 -0
  46. package/src/components/merge-requests/toIsoString.ts +9 -0
  47. package/src/components/merge-requests/useControlledExpansion.ts +16 -0
  48. package/src/components/models/index.ts +9 -0
  49. package/src/components/models/types.ts +43 -0
  50. package/src/components/overlays/EdgeGlowFrame.tsx +105 -0
  51. package/src/components/overlays/index.ts +4 -0
  52. package/src/components/preview/PreviewHeroCard.tsx +58 -0
  53. package/src/components/preview/PreviewImage.tsx +22 -0
  54. package/src/components/preview/PreviewMetaRow.tsx +70 -0
  55. package/src/components/preview/PreviewPage.tsx +36 -0
  56. package/src/components/preview/PreviewPlaceholder.tsx +72 -0
  57. package/src/components/preview/PreviewStatusBadge.tsx +63 -0
  58. package/src/components/preview/StatsBar.tsx +109 -0
  59. package/src/components/preview/index.ts +22 -0
  60. package/src/components/primitives/Avatar.tsx +68 -0
  61. package/src/components/primitives/Button.tsx +102 -0
  62. package/src/components/primitives/Card.tsx +30 -0
  63. package/src/components/primitives/Divider.tsx +17 -0
  64. package/src/components/primitives/Icon.tsx +40 -0
  65. package/src/components/primitives/MarkdownText.tsx +72 -0
  66. package/src/components/primitives/Modal.tsx +53 -0
  67. package/src/components/primitives/Surface.tsx +42 -0
  68. package/src/components/primitives/Text.tsx +83 -0
  69. package/src/components/primitives/index.ts +35 -0
  70. package/src/components/primitives/types.ts +30 -0
  71. package/src/components/studio-sheet/StudioBottomSheet.tsx +114 -0
  72. package/src/components/studio-sheet/StudioSheetBackground.tsx +63 -0
  73. package/src/components/studio-sheet/StudioSheetHeader.tsx +35 -0
  74. package/src/components/studio-sheet/StudioSheetHeaderIconButton.tsx +109 -0
  75. package/src/components/studio-sheet/StudioSheetPager.tsx +66 -0
  76. package/src/components/studio-sheet/index.ts +18 -0
  77. package/src/components/studio-sheet/types.ts +5 -0
  78. package/src/components/utils/color.ts +25 -0
  79. package/src/components/utils/formatTimeAgo.ts +19 -0
  80. package/src/core/logger.ts +42 -0
  81. package/src/core/services/http/baseUrl.ts +3 -0
  82. package/src/core/services/http/index.ts +128 -0
  83. package/src/core/services/http/public.ts +14 -0
  84. package/src/core/services/supabase/auth.ts +41 -0
  85. package/src/core/services/supabase/client.ts +43 -0
  86. package/src/core/services/supabase/index.ts +7 -0
  87. package/src/data/agent/remote.ts +30 -0
  88. package/src/data/agent/repository.ts +34 -0
  89. package/src/data/agent/types.ts +28 -0
  90. package/src/data/apps/bundles/remote.ts +47 -0
  91. package/src/data/apps/bundles/repository.ts +35 -0
  92. package/src/data/apps/bundles/types.ts +27 -0
  93. package/src/data/apps/images/remote.ts +61 -0
  94. package/src/data/apps/images/repository.ts +47 -0
  95. package/src/data/apps/remote.ts +97 -0
  96. package/src/data/apps/repository.ts +185 -0
  97. package/src/data/apps/types.ts +206 -0
  98. package/src/data/attachment/remote.ts +32 -0
  99. package/src/data/attachment/repository.ts +40 -0
  100. package/src/data/attachment/types.ts +42 -0
  101. package/src/data/base-remote.ts +3 -0
  102. package/src/data/base-repository.ts +11 -0
  103. package/src/data/comments/likes/remote.ts +87 -0
  104. package/src/data/comments/likes/repository.ts +61 -0
  105. package/src/data/comments/likes/types.ts +47 -0
  106. package/src/data/comments/remote.ts +71 -0
  107. package/src/data/comments/repository.ts +53 -0
  108. package/src/data/comments/types.ts +60 -0
  109. package/src/data/github/remote.ts +23 -0
  110. package/src/data/github/repository.ts +35 -0
  111. package/src/data/github/types.ts +23 -0
  112. package/src/data/home/remote.ts +24 -0
  113. package/src/data/home/repository.ts +28 -0
  114. package/src/data/home/types.ts +70 -0
  115. package/src/data/index.ts +3 -0
  116. package/src/data/likes/remote.ts +57 -0
  117. package/src/data/likes/repository.ts +47 -0
  118. package/src/data/likes/types.ts +46 -0
  119. package/src/data/me/remote.ts +28 -0
  120. package/src/data/me/repository.ts +30 -0
  121. package/src/data/me/types.ts +14 -0
  122. package/src/data/merge-requests/remote.ts +76 -0
  123. package/src/data/merge-requests/repository.ts +66 -0
  124. package/src/data/merge-requests/types.ts +33 -0
  125. package/src/data/messages/remote.ts +21 -0
  126. package/src/data/messages/repository.ts +104 -0
  127. package/src/data/messages/types.ts +20 -0
  128. package/src/data/public/studio-config/remote.ts +19 -0
  129. package/src/data/public/studio-config/repository.ts +23 -0
  130. package/src/data/public/studio-config/types.ts +6 -0
  131. package/src/data/ratings/remote.ts +76 -0
  132. package/src/data/ratings/repository.ts +63 -0
  133. package/src/data/ratings/types.ts +57 -0
  134. package/src/data/threads/remote.ts +40 -0
  135. package/src/data/threads/repository.ts +41 -0
  136. package/src/data/threads/types.ts +25 -0
  137. package/src/data/types.ts +8 -0
  138. package/src/data/users/remote.ts +31 -0
  139. package/src/data/users/repository.ts +45 -0
  140. package/src/data/users/types.ts +15 -0
  141. package/src/index.ts +6 -0
  142. package/src/studio/ComergeStudio.tsx +246 -0
  143. package/src/studio/bootstrap/StudioBootstrap.tsx +45 -0
  144. package/src/studio/bootstrap/useStudioBootstrap.ts +51 -0
  145. package/src/studio/hooks/useApp.ts +83 -0
  146. package/src/studio/hooks/useAppStats.ts +111 -0
  147. package/src/studio/hooks/useAttachmentUpload.ts +59 -0
  148. package/src/studio/hooks/useBundleManager.ts +389 -0
  149. package/src/studio/hooks/useMergeRequests.ts +173 -0
  150. package/src/studio/hooks/useStudioActions.ts +96 -0
  151. package/src/studio/hooks/useThreadMessages.ts +85 -0
  152. package/src/studio/lib/chat.ts +34 -0
  153. package/src/studio/ui/ChatPanel.tsx +154 -0
  154. package/src/studio/ui/ConfirmMergeFlow.tsx +55 -0
  155. package/src/studio/ui/PreviewPanel.tsx +131 -0
  156. package/src/studio/ui/RuntimeRenderer.tsx +40 -0
  157. package/src/studio/ui/StudioOverlay.tsx +257 -0
  158. package/src/studio/ui/preview-panel/PressableCardRow.tsx +49 -0
  159. package/src/studio/ui/preview-panel/PreviewCollaborateSection.tsx +174 -0
  160. package/src/studio/ui/preview-panel/PreviewCustomizeSection.tsx +160 -0
  161. package/src/studio/ui/preview-panel/PreviewHeroSection.tsx +56 -0
  162. package/src/studio/ui/preview-panel/PreviewMetaSection.tsx +67 -0
  163. package/src/studio/ui/preview-panel/PreviewPanelHeader.tsx +48 -0
  164. package/src/studio/ui/preview-panel/SectionTitle.tsx +31 -0
  165. package/src/studio/ui/preview-panel/usePreviewPanelData.ts +132 -0
  166. package/src/studio/ui/preview-panel/utils.ts +29 -0
  167. package/src/theme/index.ts +5 -0
  168. package/src/theme/tokens.ts +118 -0
  169. package/src/theme/types.ts +90 -0
  170. package/src/theme/useTheme.ts +11 -0
  171. package/dist/assets/images/merge.svg +0 -3
  172. package/dist/merge-72UG27QV.svg +0 -3
@@ -0,0 +1,46 @@
1
+ import * as React from 'react';
2
+ import { TextInput, type TextInputProps, type TextStyle } from 'react-native';
3
+ import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
4
+
5
+ import { useTheme } from '../../theme';
6
+
7
+ export type MultilineTextInputProps = Omit<TextInputProps, 'style'> & {
8
+ useBottomSheetTextInput?: boolean;
9
+ style?: TextStyle;
10
+ };
11
+
12
+ export const MultilineTextInput = React.forwardRef<TextInput, MultilineTextInputProps>(function MultilineTextInput(
13
+ { useBottomSheetTextInput = false, placeholder, placeholderTextColor, style, ...props }: MultilineTextInputProps,
14
+ ref
15
+ ) {
16
+ const theme = useTheme();
17
+
18
+ const baseStyle: TextStyle = {
19
+ minHeight: 44,
20
+ maxHeight: 160,
21
+ paddingVertical: theme.spacing.md,
22
+ paddingHorizontal: theme.spacing.lg,
23
+ color: theme.colors.text,
24
+ fontSize: theme.typography.fontSize.md,
25
+ lineHeight: theme.typography.lineHeight.md,
26
+ };
27
+
28
+ const resolvedPlaceholderColor = placeholderTextColor ?? theme.colors.textSubtle;
29
+
30
+ const commonProps: TextInputProps = {
31
+ ...props,
32
+ multiline: true,
33
+ placeholder,
34
+ placeholderTextColor: resolvedPlaceholderColor,
35
+ style: [baseStyle, style],
36
+ textAlignVertical: 'top',
37
+ };
38
+
39
+ return useBottomSheetTextInput ? (
40
+ <BottomSheetTextInput ref={ref} {...commonProps} />
41
+ ) : (
42
+ <TextInput ref={ref} {...commonProps} />
43
+ );
44
+ });
45
+
46
+
@@ -0,0 +1,78 @@
1
+ import * as React from 'react';
2
+ import { Pressable, View, type ViewStyle } from 'react-native';
3
+ import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
4
+
5
+ import { useTheme } from '../../theme';
6
+ import { withAlpha } from '../utils/color';
7
+
8
+ export type ScrollToBottomButtonProps = {
9
+ visible: boolean;
10
+ onPress: () => void;
11
+ children: React.ReactNode;
12
+ style?: ViewStyle;
13
+ };
14
+
15
+ export function ScrollToBottomButton({ visible, onPress, children, style }: ScrollToBottomButtonProps) {
16
+ const theme = useTheme();
17
+ const progress = useSharedValue(visible ? 1 : 0);
18
+ const [pressed, setPressed] = React.useState(false);
19
+
20
+ React.useEffect(() => {
21
+ progress.value = withTiming(visible ? 1 : 0, { duration: 200, easing: Easing.out(Easing.ease) });
22
+ }, [progress, visible]);
23
+
24
+ const animStyle = useAnimatedStyle(() => ({
25
+ opacity: progress.value,
26
+ transform: [{ translateY: (1 - progress.value) * 20 }],
27
+ }));
28
+
29
+ const bg = theme.scheme === 'dark' ? 'rgba(39,39,42,0.9)' : 'rgba(244,244,245,0.95)';
30
+ const border = theme.scheme === 'dark' ? withAlpha('#FFFFFF', 0.12) : withAlpha('#000000', 0.08);
31
+
32
+ return (
33
+ <Animated.View
34
+ pointerEvents={visible ? 'auto' : 'none'}
35
+ style={[
36
+ {
37
+ position: 'absolute',
38
+ left: 0,
39
+ right: 0,
40
+ alignItems: 'center',
41
+ },
42
+ style,
43
+ animStyle,
44
+ ]}
45
+ >
46
+ <View
47
+ style={{
48
+ width: 44,
49
+ height: 44,
50
+ borderRadius: 22,
51
+ backgroundColor: bg,
52
+ borderWidth: 1,
53
+ borderColor: border,
54
+ alignItems: 'center',
55
+ justifyContent: 'center',
56
+ shadowColor: '#000',
57
+ shadowOffset: { width: 0, height: 2 },
58
+ shadowOpacity: 0.25,
59
+ shadowRadius: 4,
60
+ elevation: 5,
61
+ opacity: pressed ? 0.85 : 1,
62
+ }}
63
+ >
64
+ <Pressable
65
+ onPress={onPress}
66
+ onPressIn={() => setPressed(true)}
67
+ onPressOut={() => setPressed(false)}
68
+ hitSlop={10}
69
+ style={{ width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center' }}
70
+ >
71
+ {children}
72
+ </Pressable>
73
+ </View>
74
+ </Animated.View>
75
+ );
76
+ }
77
+
78
+
@@ -0,0 +1,54 @@
1
+ import * as React from 'react';
2
+ import { Animated, View, type ViewStyle } from 'react-native';
3
+
4
+ import { useTheme } from '../../theme';
5
+
6
+ export type TypingIndicatorProps = {
7
+ style?: ViewStyle;
8
+ };
9
+
10
+ export function TypingIndicator({ style }: TypingIndicatorProps) {
11
+ const theme = useTheme();
12
+ const dotColor = theme.colors.textSubtle;
13
+ const anims = React.useMemo(
14
+ () => [new Animated.Value(0.3), new Animated.Value(0.3), new Animated.Value(0.3)],
15
+ []
16
+ );
17
+
18
+ React.useEffect(() => {
19
+ const loops: Animated.CompositeAnimation[] = [];
20
+ anims.forEach((a, idx) => {
21
+ const seq = Animated.sequence([
22
+ Animated.timing(a, { toValue: 1, duration: 420, useNativeDriver: true, delay: idx * 140 }),
23
+ Animated.timing(a, { toValue: 0.3, duration: 420, useNativeDriver: true }),
24
+ ]);
25
+ const loop = Animated.loop(seq);
26
+ loops.push(loop);
27
+ loop.start();
28
+ });
29
+ return () => {
30
+ loops.forEach((l) => l.stop());
31
+ };
32
+ }, [anims]);
33
+
34
+ return (
35
+ <View style={[{ flexDirection: 'row', alignItems: 'center' }, style]}>
36
+ {anims.map((a, i) => (
37
+ <Animated.View
38
+ key={i}
39
+ style={{
40
+ width: 8,
41
+ height: 8,
42
+ borderRadius: 4,
43
+ marginHorizontal: 3,
44
+ backgroundColor: dotColor,
45
+ opacity: a,
46
+ transform: [{ translateY: Animated.multiply(Animated.subtract(a, 0.3), 2) }],
47
+ }}
48
+ />
49
+ ))}
50
+ </View>
51
+ );
52
+ }
53
+
54
+
@@ -0,0 +1,28 @@
1
+ export { TypingIndicator } from './TypingIndicator';
2
+ export type { TypingIndicatorProps } from './TypingIndicator';
3
+
4
+ export { ChatMessageBubble } from './ChatMessageBubble';
5
+ export type { ChatMessageBubbleProps } from './ChatMessageBubble';
6
+
7
+ export { ChatMessageList } from './ChatMessageList';
8
+ export type { ChatMessageListProps, ChatMessageListRef } from './ChatMessageList';
9
+
10
+ export { ScrollToBottomButton } from './ScrollToBottomButton';
11
+ export type { ScrollToBottomButtonProps } from './ScrollToBottomButton';
12
+
13
+ export { ForkNoticeBanner } from './ForkNoticeBanner';
14
+ export type { ForkNoticeBannerProps } from './ForkNoticeBanner';
15
+
16
+ export { MultilineTextInput } from './MultilineTextInput';
17
+ export type { MultilineTextInputProps } from './MultilineTextInput';
18
+
19
+ export { ChatComposer } from './ChatComposer';
20
+ export type { ChatComposerProps } from './ChatComposer';
21
+
22
+ export { ChatHeader } from './ChatHeader';
23
+ export type { ChatHeaderProps } from './ChatHeader';
24
+
25
+ export { ChatPage } from './ChatPage';
26
+ export type { ChatPageProps } from './ChatPage';
27
+
28
+
@@ -0,0 +1,213 @@
1
+ import * as React from 'react';
2
+ import { ActivityIndicator, Keyboard, Platform, Pressable, View } from 'react-native';
3
+ import {
4
+ BottomSheetBackdrop,
5
+ BottomSheetModal,
6
+ BottomSheetScrollView,
7
+ } from '@gorhom/bottom-sheet';
8
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
9
+ import { LiquidGlassView, isLiquidGlassSupported } from '@callstack/liquid-glass';
10
+ import { Play } from 'lucide-react-native';
11
+
12
+ import { useTheme } from '../../theme';
13
+ import { withAlpha } from '../utils/color';
14
+ import { Text } from '../primitives/Text';
15
+ import { ChatComposer } from '../chat/ChatComposer';
16
+ import { CommentRow } from './CommentRow';
17
+ import { useAppComments } from './useAppComments';
18
+ import { useAppDetails } from './useAppDetails';
19
+ import { useIosKeyboardSnapFix } from './useIosKeyboardSnapFix';
20
+
21
+ export type AppCommentsSheetProps = {
22
+ appId: string | null;
23
+ onClose: () => void;
24
+ onCountChange?: (count: number) => void;
25
+ onPlayApp?: (appId: string) => void | Promise<void>;
26
+ };
27
+
28
+ export function AppCommentsSheet({ appId, onClose, onCountChange, onPlayApp }: AppCommentsSheetProps) {
29
+ const theme = useTheme();
30
+ const insets = useSafeAreaInsets();
31
+ const sheetRef = React.useRef<BottomSheetModal | null>(null);
32
+ const snapPoints = React.useMemo(() => ['50%', '90%'], []);
33
+
34
+ const { comments, loading, sending, error, create, refresh } = useAppComments(appId);
35
+ const { app, loading: loadingApp } = useAppDetails(appId);
36
+ const { keyboardVisible } = useIosKeyboardSnapFix(sheetRef);
37
+
38
+ React.useEffect(() => {
39
+ if (appId) {
40
+ sheetRef.current?.present();
41
+ void refresh();
42
+ } else {
43
+ sheetRef.current?.dismiss();
44
+ }
45
+ }, [appId, refresh]);
46
+
47
+ React.useEffect(() => {
48
+ if (!appId) return;
49
+ onCountChange?.(comments.length);
50
+ }, [appId, comments.length, onCountChange]);
51
+
52
+ const renderBackdrop = React.useCallback(
53
+ (props: any) => <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />,
54
+ []
55
+ );
56
+
57
+ const handleChange = React.useCallback(
58
+ (index: number) => {
59
+ if (index === -1) onClose();
60
+ },
61
+ [onClose]
62
+ );
63
+
64
+ const handlePlay = React.useCallback(async () => {
65
+ if (!appId) return;
66
+ sheetRef.current?.dismiss();
67
+ await onPlayApp?.(appId);
68
+ onClose();
69
+ }, [appId, onClose, onPlayApp]);
70
+
71
+ return (
72
+ <BottomSheetModal
73
+ ref={sheetRef}
74
+ index={1}
75
+ snapPoints={snapPoints}
76
+ enableDynamicSizing={false}
77
+ backdropComponent={renderBackdrop}
78
+ onChange={handleChange}
79
+ backgroundStyle={{
80
+ backgroundColor: theme.scheme === 'dark' ? '#0B080F' : '#FFFFFF',
81
+ borderTopLeftRadius: Platform.OS === 'ios' ? 39 : 16,
82
+ borderTopRightRadius: Platform.OS === 'ios' ? 39 : 16,
83
+ }}
84
+ handleIndicatorStyle={{ backgroundColor: theme.colors.handleIndicator }}
85
+ keyboardBehavior="interactive"
86
+ keyboardBlurBehavior="restore"
87
+ android_keyboardInputMode="adjustResize"
88
+ topInset={insets.top}
89
+ >
90
+ <View style={{ flex: 1 }}>
91
+ <View
92
+ style={{
93
+ flexDirection: 'row',
94
+ alignItems: 'center',
95
+ justifyContent: 'space-between',
96
+ paddingHorizontal: theme.spacing.lg,
97
+ paddingBottom: theme.spacing.sm,
98
+ borderBottomWidth: 1,
99
+ borderBottomColor: withAlpha(theme.colors.border, 0.1),
100
+ }}
101
+ >
102
+ <Text
103
+ numberOfLines={1}
104
+ style={{
105
+ flex: 1,
106
+ marginRight: theme.spacing.sm,
107
+ fontSize: 18,
108
+ lineHeight: 22,
109
+ fontWeight: theme.typography.fontWeight.bold,
110
+ }}
111
+ >
112
+ {loadingApp ? 'Loading...' : app?.name || 'Comments'}
113
+ </Text>
114
+
115
+ <LiquidGlassView
116
+ style={[
117
+ { borderRadius: 24 },
118
+ !isLiquidGlassSupported && { backgroundColor: theme.scheme === 'dark' ? '#18181B' : '#F6F6F6' },
119
+ ]}
120
+ interactive
121
+ effect="clear"
122
+ >
123
+ <View
124
+ style={{
125
+ width: 32,
126
+ height: 32,
127
+ borderRadius: 999,
128
+ backgroundColor: theme.colors.primary,
129
+ alignItems: 'center',
130
+ justifyContent: 'center',
131
+ opacity: appId ? 1 : 0.5,
132
+ }}
133
+ >
134
+ <Pressable
135
+ disabled={!appId}
136
+ onPress={() => void handlePlay()}
137
+ hitSlop={8}
138
+ style={({ pressed }) => [
139
+ {
140
+ width: '100%',
141
+ height: '100%',
142
+ alignItems: 'center',
143
+ justifyContent: 'center',
144
+ },
145
+ pressed ? { opacity: 0.85 } : null,
146
+ ]}
147
+ >
148
+ <Play size={16} color={theme.colors.onPrimary} />
149
+ </Pressable>
150
+ </View>
151
+ </LiquidGlassView>
152
+ </View>
153
+
154
+ <BottomSheetScrollView
155
+ style={{ flex: 1 }}
156
+ contentContainerStyle={{
157
+ padding: theme.spacing.lg,
158
+ paddingBottom: 100,
159
+ flexGrow: 1,
160
+ }}
161
+ keyboardShouldPersistTaps="handled"
162
+ >
163
+ {loading && comments.length === 0 ? (
164
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
165
+ <ActivityIndicator />
166
+ </View>
167
+ ) : comments.length === 0 ? (
168
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
169
+ <Text variant="bodyMuted" style={{ textAlign: 'center' }}>
170
+ No comments yet
171
+ </Text>
172
+ </View>
173
+ ) : (
174
+ comments.map((c, idx) => <CommentRow key={c.id} comment={c} showDivider={idx < comments.length - 1} />)
175
+ )}
176
+ {error ? (
177
+ <Text variant="captionMuted" style={{ marginTop: theme.spacing.lg }}>
178
+ Failed to load comments.
179
+ </Text>
180
+ ) : null}
181
+ </BottomSheetScrollView>
182
+
183
+ <View
184
+ style={{
185
+ position: 'absolute',
186
+ left: 0,
187
+ right: 0,
188
+ bottom: 0,
189
+ paddingHorizontal: theme.spacing.lg,
190
+ paddingTop: theme.spacing.sm,
191
+ paddingBottom: Platform.OS === 'ios' ? (keyboardVisible ? theme.spacing.lg : insets.bottom) : insets.bottom + 10,
192
+ borderTopWidth: 1,
193
+ borderTopColor: withAlpha(theme.colors.border, 0.1),
194
+ backgroundColor: withAlpha(theme.colors.background, 0.8),
195
+ }}
196
+ >
197
+ <ChatComposer
198
+ placeholder="Write a comment..."
199
+ disabled={sending}
200
+ sending={sending}
201
+ useBottomSheetTextInput
202
+ onSend={async (text) => {
203
+ await create(text);
204
+ Keyboard.dismiss();
205
+ }}
206
+ />
207
+ </View>
208
+ </View>
209
+ </BottomSheetModal>
210
+ );
211
+ }
212
+
213
+
@@ -0,0 +1,63 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import type { AppComment } from '../../data/comments/types';
5
+ import { usersRepository } from '../../data/users/repository';
6
+ import { useTheme } from '../../theme';
7
+ import { Avatar } from '../primitives/Avatar';
8
+ import { Text } from '../primitives/Text';
9
+ import { withAlpha } from '../utils/color';
10
+ import { formatTimeAgo } from './formatTimeAgo';
11
+
12
+ export function CommentRow({ comment, showDivider }: { comment: AppComment; showDivider: boolean }) {
13
+ const theme = useTheme();
14
+ const [authorName, setAuthorName] = React.useState<string | null>(null);
15
+ const [authorAvatar, setAuthorAvatar] = React.useState<string | null>(null);
16
+
17
+ React.useEffect(() => {
18
+ let cancelled = false;
19
+ (async () => {
20
+ try {
21
+ const stats = await usersRepository.getStats(comment.authorId);
22
+ if (cancelled) return;
23
+ setAuthorName(stats.name);
24
+ setAuthorAvatar(stats.avatar);
25
+ } catch {
26
+ // ignore
27
+ }
28
+ })();
29
+ return () => {
30
+ cancelled = true;
31
+ };
32
+ }, [comment.authorId]);
33
+
34
+ return (
35
+ <View
36
+ style={{
37
+ flexDirection: 'row',
38
+ gap: theme.spacing.md,
39
+ paddingVertical: theme.spacing.md,
40
+ borderBottomWidth: showDivider ? 1 : 0,
41
+ borderBottomColor: withAlpha(theme.colors.border, 0.5),
42
+ }}
43
+ >
44
+ <Avatar size={32} uri={authorAvatar} name={authorName ?? comment.authorId} style={{ marginTop: 6 }} />
45
+
46
+ <View style={{ flex: 1, minWidth: 0, gap: 4 }}>
47
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm }}>
48
+ <Text style={{ fontSize: 14, lineHeight: 18, fontWeight: theme.typography.fontWeight.bold, color: theme.colors.text }}>
49
+ {authorName ?? 'Unknown User'}
50
+ </Text>
51
+ <Text style={{ fontSize: 12, lineHeight: 16, color: theme.colors.textMuted }}>
52
+ {formatTimeAgo(comment.createdAt)}
53
+ </Text>
54
+ </View>
55
+ <Text style={{ fontSize: 14, lineHeight: 20, color: theme.colors.text }}>
56
+ {comment.body ?? comment.description ?? ''}
57
+ </Text>
58
+ </View>
59
+ </View>
60
+ );
61
+ }
62
+
63
+
@@ -0,0 +1,3 @@
1
+ export { formatTimeAgo } from '../utils/formatTimeAgo';
2
+
3
+
@@ -0,0 +1,3 @@
1
+ export { AppCommentsSheet } from './AppCommentsSheet';
2
+
3
+
@@ -0,0 +1,74 @@
1
+ import * as React from 'react';
2
+
3
+ import { appCommentsRepository } from '../../data/comments/repository';
4
+ import type { AppComment } from '../../data/comments/types';
5
+
6
+ export type UseAppCommentsResult = {
7
+ comments: AppComment[];
8
+ loading: boolean;
9
+ sending: boolean;
10
+ error: Error | null;
11
+ refresh: () => Promise<void>;
12
+ create: (text: string) => Promise<void>;
13
+ };
14
+
15
+ export function useAppComments(appId: string | null): UseAppCommentsResult {
16
+ const [comments, setComments] = React.useState<AppComment[]>([]);
17
+ const [loading, setLoading] = React.useState(false);
18
+ const [sending, setSending] = React.useState(false);
19
+ const [error, setError] = React.useState<Error | null>(null);
20
+
21
+ const sortByCreatedAtAsc = React.useCallback((items: AppComment[]) => {
22
+ return [...items].sort((a, b) => {
23
+ const at = a.createdAt ? new Date(a.createdAt).getTime() : 0;
24
+ const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0;
25
+ return at - bt;
26
+ });
27
+ }, []);
28
+
29
+ const refresh = React.useCallback(async () => {
30
+ if (!appId) {
31
+ setComments([]);
32
+ return;
33
+ }
34
+ setLoading(true);
35
+ setError(null);
36
+ try {
37
+ const res = await appCommentsRepository.list(appId, { page: 1, pageSize: 50, includeDeleted: false });
38
+ setComments(sortByCreatedAtAsc(res.items));
39
+ } catch (e) {
40
+ setError(e instanceof Error ? e : new Error(String(e)));
41
+ setComments([]);
42
+ } finally {
43
+ setLoading(false);
44
+ }
45
+ }, [appId, sortByCreatedAtAsc]);
46
+
47
+ React.useEffect(() => {
48
+ void refresh();
49
+ }, [refresh]);
50
+
51
+ const create = React.useCallback(
52
+ async (text: string) => {
53
+ if (!appId) return;
54
+ const trimmed = text.trim();
55
+ if (!trimmed) return;
56
+ setSending(true);
57
+ setError(null);
58
+ try {
59
+ const newComment = await appCommentsRepository.create(appId, { body: trimmed, commentType: 'general' });
60
+ setComments((prev) => sortByCreatedAtAsc([...prev, newComment]));
61
+ } catch (e) {
62
+ setError(e instanceof Error ? e : new Error(String(e)));
63
+ throw e;
64
+ } finally {
65
+ setSending(false);
66
+ }
67
+ },
68
+ [appId, sortByCreatedAtAsc]
69
+ );
70
+
71
+ return { comments, loading, sending, error, refresh, create };
72
+ }
73
+
74
+
@@ -0,0 +1,35 @@
1
+ import * as React from 'react';
2
+
3
+ import { appsRepository } from '../../data/apps/repository';
4
+ import type { App } from '../../data/apps/types';
5
+
6
+ export function useAppDetails(appId: string | null) {
7
+ const [app, setApp] = React.useState<App | null>(null);
8
+ const [loading, setLoading] = React.useState(false);
9
+
10
+ React.useEffect(() => {
11
+ if (!appId) {
12
+ setApp(null);
13
+ return;
14
+ }
15
+ let cancelled = false;
16
+ setLoading(true);
17
+ (async () => {
18
+ try {
19
+ const res = await appsRepository.getById(appId);
20
+ if (!cancelled) setApp(res);
21
+ } catch {
22
+ if (!cancelled) setApp(null);
23
+ } finally {
24
+ if (!cancelled) setLoading(false);
25
+ }
26
+ })();
27
+ return () => {
28
+ cancelled = true;
29
+ };
30
+ }, [appId]);
31
+
32
+ return { app, loading };
33
+ }
34
+
35
+
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ import { Keyboard, Platform } from 'react-native';
3
+ import type { BottomSheetModal } from '@gorhom/bottom-sheet';
4
+
5
+ export function useIosKeyboardSnapFix(sheetRef: React.RefObject<BottomSheetModal | null>) {
6
+ const [keyboardVisible, setKeyboardVisible] = React.useState(false);
7
+
8
+ React.useEffect(() => {
9
+ if (Platform.OS !== 'ios') return;
10
+ const show = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true));
11
+ const hide = Keyboard.addListener('keyboardWillHide', () => {
12
+ setKeyboardVisible(false);
13
+ setTimeout(() => sheetRef.current?.snapToIndex?.(1), 10);
14
+ });
15
+ return () => {
16
+ show.remove();
17
+ hide.remove();
18
+ };
19
+ }, [sheetRef]);
20
+
21
+ return { keyboardVisible };
22
+ }
23
+
24
+