Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

IssueDetailScreen.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

IssueDetailScreen.tsxBlame419 lines · 1 contributor
9a8fbedClaude1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 KeyboardAvoidingView,
5 Platform,
6 ScrollView,
7 StyleSheet,
8 Text,
9 TextInput,
10 TouchableOpacity,
11 View,
12} from 'react-native';
13import { SafeAreaView } from 'react-native-safe-area-context';
14import type { NativeStackScreenProps } from '@react-navigation/native-stack';
15import { getIssue, getIssueComments, createIssueComment, closeIssue, reopenIssue } from '../api/issues';
16import type { Issue, IssueComment } from '../api/issues';
17import { LoadingSpinner } from '../components/LoadingSpinner';
18import { ErrorBanner } from '../components/ErrorBanner';
19import { colors } from '../theme/colors';
20import type { RepoStackParamList } from '../navigation/AppNavigator';
21import { useAuthStore } from '../store/authStore';
22
23type Props = NativeStackScreenProps<RepoStackParamList, 'IssueDetail'>;
24
25function formatRelative(dateStr: string): string {
26 const diff = Date.now() - new Date(dateStr).getTime();
27 const mins = Math.floor(diff / 60000);
28 if (mins < 1) return 'just now';
29 if (mins < 60) return `${mins}m ago`;
30 const hrs = Math.floor(mins / 60);
31 if (hrs < 24) return `${hrs}h ago`;
32 const days = Math.floor(hrs / 24);
33 if (days < 30) return `${days}d ago`;
34 const months = Math.floor(days / 30);
35 if (months < 12) return `${months}mo ago`;
36 return `${Math.floor(months / 12)}y ago`;
37}
38
39function CommentCard({ comment }: { comment: IssueComment }): React.ReactElement {
40 return (
41 <View style={styles.commentCard}>
42 <View style={styles.commentHeader}>
43 <Text style={styles.commentAuthor}>{comment.authorUsername}</Text>
44 <Text style={styles.commentTime}>{formatRelative(comment.createdAt)}</Text>
45 </View>
46 <Text style={styles.commentBody}>{comment.body}</Text>
47 </View>
48 );
49}
50
51export function IssueDetailScreen({ route }: Props): React.ReactElement {
52 const { owner, repo, number } = route.params;
53 const currentUser = useAuthStore((s) => s.user);
54
55 const [issue, setIssue] = useState<Issue | null>(null);
56 const [comments, setComments] = useState<IssueComment[]>([]);
57 const [isLoading, setIsLoading] = useState(true);
58 const [error, setError] = useState<string | null>(null);
59 const [commentText, setCommentText] = useState('');
60 const [isSubmitting, setIsSubmitting] = useState(false);
61 const [tick, setTick] = useState(0);
62
63 useEffect(() => {
64 let cancelled = false;
65 setIsLoading(true);
66 setError(null);
67
68 Promise.all([
69 getIssue(owner, repo, number),
70 getIssueComments(owner, repo, number),
71 ])
72 .then(([issueData, commentData]) => {
73 if (!cancelled) {
74 setIssue(issueData);
75 setComments(commentData);
76 }
77 })
78 .catch((err) => {
79 if (!cancelled) {
80 setError(err instanceof Error ? err.message : 'Failed to load issue');
81 }
82 })
83 .finally(() => {
84 if (!cancelled) setIsLoading(false);
85 });
86
87 return () => {
88 cancelled = true;
89 };
90 }, [owner, repo, number, tick]);
91
92 const handleSubmitComment = useCallback(async () => {
93 const body = commentText.trim();
94 if (!body || isSubmitting) return;
95 setIsSubmitting(true);
96 try {
97 const newComment = await createIssueComment(owner, repo, number, { body });
98 setComments((prev) => [...prev, newComment]);
99 setCommentText('');
100 } catch (err) {
101 setError(err instanceof Error ? err.message : 'Failed to post comment');
102 } finally {
103 setIsSubmitting(false);
104 }
105 }, [owner, repo, number, commentText, isSubmitting]);
106
107 const handleToggleState = useCallback(async () => {
108 if (!issue || isSubmitting) return;
109 setIsSubmitting(true);
110 try {
111 const updated = issue.state === 'open'
112 ? await closeIssue(owner, repo, number)
113 : await reopenIssue(owner, repo, number);
114 setIssue(updated);
115 } catch (err) {
116 setError(err instanceof Error ? err.message : 'Failed to update issue');
117 } finally {
118 setIsSubmitting(false);
119 }
120 }, [issue, owner, repo, number, isSubmitting]);
121
122 const renderComment = useCallback(
123 ({ item }: { item: IssueComment }) => <CommentCard comment={item} />,
124 [],
125 );
126
127 const keyExtractor = useCallback((item: IssueComment) => String(item.id), []);
128
129 if (isLoading) return <LoadingSpinner fullScreen />;
130
131 if (issue === null) {
132 return (
133 <SafeAreaView style={styles.safe} edges={['bottom']}>
134 <ErrorBanner message={error ?? 'Issue not found'} onRetry={() => setTick((t) => t + 1)} />
135 </SafeAreaView>
136 );
137 }
138
139 const stateColor = issue.state === 'open' ? colors.accent : colors.accentRed;
140
141 return (
142 <SafeAreaView style={styles.safe} edges={['bottom']}>
143 <KeyboardAvoidingView
144 style={styles.flex}
145 behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
146 keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
147 >
148 <FlatList
149 data={comments}
150 keyExtractor={keyExtractor}
151 renderItem={renderComment}
152 style={styles.list}
153 contentContainerStyle={styles.listContent}
154 ListHeaderComponent={
155 <View>
156 {error !== null && <ErrorBanner message={error} />}
157
158 {/* Issue header */}
159 <View style={styles.issueHeader}>
160 <View style={[styles.stateBadge, { backgroundColor: stateColor }]}>
161 <Text style={styles.stateText}>{issue.state}</Text>
162 </View>
163 <Text style={styles.issueTitle}>{issue.title}</Text>
164 <Text style={styles.issueMeta}>
165 #{issue.number} opened {formatRelative(issue.createdAt)} by {issue.authorUsername}
166 </Text>
167
168 {/* Labels */}
169 {issue.labels.length > 0 && (
170 <View style={styles.labelsRow}>
171 {issue.labels.map((label) => (
172 <View
173 key={label.id}
174 style={[styles.label, { borderColor: `#${label.color}` }]}
175 >
176 <Text style={[styles.labelText, { color: `#${label.color}` }]}>
177 {label.name}
178 </Text>
179 </View>
180 ))}
181 </View>
182 )}
183 </View>
184
185 {/* Issue body */}
186 {issue.body !== null && issue.body.length > 0 && (
187 <View style={styles.issueBody}>
188 <Text style={styles.bodyText}>{issue.body}</Text>
189 </View>
190 )}
191
192 {/* Comments section header */}
193 {comments.length > 0 && (
194 <View style={styles.commentsHeader}>
195 <Text style={styles.commentsHeaderText}>
196 {comments.length} {comments.length === 1 ? 'comment' : 'comments'}
197 </Text>
198 </View>
199 )}
200 </View>
201 }
202 ListEmptyComponent={
203 <View style={styles.noComments}>
204 <Text style={styles.noCommentsText}>No comments yet</Text>
205 </View>
206 }
207 />
208
209 {/* Comment input + actions */}
210 <View style={styles.inputArea}>
211 <View style={styles.inputRow}>
212 <TextInput
213 style={styles.commentInput}
214 value={commentText}
215 onChangeText={setCommentText}
216 placeholder="Leave a comment..."
217 placeholderTextColor={colors.textMuted}
218 multiline
219 maxLength={65536}
220 />
221 <TouchableOpacity
222 style={[styles.sendButton, (!commentText.trim() || isSubmitting) && styles.sendButtonDisabled]}
223 onPress={handleSubmitComment}
224 disabled={!commentText.trim() || isSubmitting}
225 activeOpacity={0.8}
226 >
227 <Text style={styles.sendIcon}>↑</Text>
228 </TouchableOpacity>
229 </View>
230
231 {currentUser !== null && (
232 <TouchableOpacity
233 style={[styles.stateToggle, { borderColor: stateColor }, isSubmitting && styles.disabled]}
234 onPress={handleToggleState}
235 disabled={isSubmitting}
236 activeOpacity={0.8}
237 >
238 <Text style={[styles.stateToggleText, { color: stateColor }]}>
239 {issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
240 </Text>
241 </TouchableOpacity>
242 )}
243 </View>
244 </KeyboardAvoidingView>
245 </SafeAreaView>
246 );
247}
248
249const styles = StyleSheet.create({
250 safe: {
251 flex: 1,
252 backgroundColor: colors.bg,
253 },
254 flex: {
255 flex: 1,
256 },
257 list: {
258 flex: 1,
259 },
260 listContent: {
261 paddingBottom: 8,
262 },
263 issueHeader: {
264 padding: 16,
265 backgroundColor: colors.bgSecondary,
266 borderBottomWidth: 1,
267 borderBottomColor: colors.border,
268 gap: 8,
269 },
270 stateBadge: {
271 alignSelf: 'flex-start',
272 paddingHorizontal: 10,
273 paddingVertical: 4,
274 borderRadius: 20,
275 },
276 stateText: {
277 fontSize: 12,
278 fontWeight: '600',
279 color: colors.text,
280 textTransform: 'capitalize',
281 },
282 issueTitle: {
283 fontSize: 18,
284 fontWeight: '700',
285 color: colors.text,
286 lineHeight: 26,
287 },
288 issueMeta: {
289 fontSize: 13,
290 color: colors.textMuted,
291 },
292 labelsRow: {
293 flexDirection: 'row',
294 flexWrap: 'wrap',
295 gap: 6,
296 marginTop: 4,
297 },
298 label: {
299 paddingHorizontal: 8,
300 paddingVertical: 3,
301 borderRadius: 20,
302 borderWidth: 1,
303 },
304 labelText: {
305 fontSize: 11,
306 fontWeight: '500',
307 },
308 issueBody: {
309 padding: 16,
310 borderBottomWidth: 1,
311 borderBottomColor: colors.border,
312 },
313 bodyText: {
314 fontSize: 14,
315 color: colors.text,
316 lineHeight: 22,
317 },
318 commentsHeader: {
319 paddingHorizontal: 16,
320 paddingVertical: 10,
321 borderBottomWidth: 1,
322 borderBottomColor: colors.border,
323 backgroundColor: colors.bgTertiary,
324 },
325 commentsHeaderText: {
326 fontSize: 12,
327 fontWeight: '600',
328 color: colors.textMuted,
329 textTransform: 'uppercase',
330 letterSpacing: 0.5,
331 },
332 commentCard: {
333 padding: 16,
334 borderBottomWidth: 1,
335 borderBottomColor: colors.border,
336 backgroundColor: colors.bgSecondary,
337 gap: 8,
338 },
339 commentHeader: {
340 flexDirection: 'row',
341 alignItems: 'center',
342 justifyContent: 'space-between',
343 },
344 commentAuthor: {
345 fontSize: 13,
346 fontWeight: '600',
347 color: colors.text,
348 },
349 commentTime: {
350 fontSize: 12,
351 color: colors.textMuted,
352 },
353 commentBody: {
354 fontSize: 14,
355 color: colors.text,
356 lineHeight: 20,
357 },
358 noComments: {
359 padding: 32,
360 alignItems: 'center',
361 },
362 noCommentsText: {
363 fontSize: 14,
364 color: colors.textMuted,
365 },
366 inputArea: {
367 padding: 12,
368 backgroundColor: colors.bgSecondary,
369 borderTopWidth: 1,
370 borderTopColor: colors.border,
371 gap: 8,
372 },
373 inputRow: {
374 flexDirection: 'row',
375 alignItems: 'flex-end',
376 gap: 10,
377 },
378 commentInput: {
379 flex: 1,
380 backgroundColor: colors.bg,
381 borderWidth: 1,
382 borderColor: colors.border,
383 borderRadius: 8,
384 padding: 10,
385 fontSize: 14,
386 color: colors.text,
387 maxHeight: 100,
388 minHeight: 44,
389 },
390 sendButton: {
391 width: 40,
392 height: 40,
393 borderRadius: 20,
394 backgroundColor: colors.accentBlue,
395 alignItems: 'center',
396 justifyContent: 'center',
397 },
398 sendButtonDisabled: {
399 opacity: 0.4,
400 },
401 sendIcon: {
402 fontSize: 18,
403 color: colors.bg,
404 fontWeight: '700',
405 },
406 stateToggle: {
407 paddingVertical: 10,
408 borderRadius: 8,
409 borderWidth: 1,
410 alignItems: 'center',
411 },
412 stateToggleText: {
413 fontSize: 14,
414 fontWeight: '600',
415 },
416 disabled: {
417 opacity: 0.5,
418 },
419});