diff --git a/src/components/mobile/CommentSection.tsx b/src/components/mobile/CommentSection.tsx new file mode 100644 index 0000000..5c465e5 --- /dev/null +++ b/src/components/mobile/CommentSection.tsx @@ -0,0 +1,344 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { AppText as Text } from '../common/AppText'; +import apiService from '../../services/api'; +import logger from '../../utils/logger'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface Comment { + id: string; + authorId: string; + authorName: string; + authorAvatar?: string; + body: string; + createdAt: string; + likeCount: number; + likedByMe: boolean; +} + +export interface CommentSectionProps { + /** The resource this comment thread belongs to (e.g. lesson or course id) */ + resourceId: string; + /** Resource type used to build the API path */ + resourceType: 'lesson' | 'course'; + /** Current authenticated user id – required to post / like */ + currentUserId?: string; + /** Current authenticated user display name */ + currentUserName?: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +interface CommentItemProps { + comment: Comment; + onLike: (id: string) => void; + onDelete: (id: string) => void; + currentUserId?: string; +} + +function CommentItem({ comment, onLike, onDelete, currentUserId }: CommentItemProps) { + const isOwner = currentUserId === comment.authorId; + + return ( + + + + {comment.authorName.charAt(0).toUpperCase()} + + + {comment.authorName} + {formatDate(comment.createdAt)} + + {isOwner && ( + onDelete(comment.id)} + accessibilityRole="button" + accessibilityLabel="Delete comment" + style={styles.deleteButton} + > + + + )} + + + {comment.body} + + onLike(comment.id)} + accessibilityRole="button" + accessibilityLabel={comment.likedByMe ? 'Unlike comment' : 'Like comment'} + accessibilityState={{ selected: comment.likedByMe }} + style={styles.likeButton} + > + + {comment.likedByMe ? '♥' : '♡'} + + {comment.likeCount} + + + ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function CommentSection({ + resourceId, + resourceType, + currentUserId, + currentUserName = 'Anonymous', +}: CommentSectionProps) { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(''); + + const apiBase = `/api/${resourceType}s/${resourceId}/comments`; + + // ── Fetch ────────────────────────────────────────────────────────────────── + + const fetchComments = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await apiService.get(apiBase); + setComments(res.data as Comment[]); + } catch (err: any) { + logger.errorSync('CommentSection: fetch failed', err); + setError('Failed to load comments. Please try again.'); + } finally { + setLoading(false); + } + }, [apiBase]); + + useEffect(() => { + fetchComments(); + }, [fetchComments]); + + // ── Submit ───────────────────────────────────────────────────────────────── + + const handleSubmit = async () => { + const body = draft.trim(); + if (!body || !currentUserId) return; + + setSubmitting(true); + try { + const res = await apiService.post(apiBase, { body }); + setComments((prev) => [res.data as Comment, ...prev]); + setDraft(''); + } catch (err: any) { + logger.errorSync('CommentSection: submit failed', err); + setError('Failed to post comment. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + // ── Like ─────────────────────────────────────────────────────────────────── + + const handleLike = async (commentId: string) => { + const target = comments.find((c) => c.id === commentId); + if (!target) return; + + // Optimistic update + setComments((prev) => + prev.map((c) => + c.id === commentId + ? { ...c, likedByMe: !c.likedByMe, likeCount: c.likeCount + (c.likedByMe ? -1 : 1) } + : c, + ), + ); + + try { + if (target.likedByMe) { + await apiService.delete(`${apiBase}/${commentId}/like`); + } else { + await apiService.post(`${apiBase}/${commentId}/like`, {}); + } + } catch (err: any) { + logger.errorSync('CommentSection: like failed', err); + // Revert optimistic update + setComments((prev) => + prev.map((c) => + c.id === commentId + ? { ...c, likedByMe: target.likedByMe, likeCount: target.likeCount } + : c, + ), + ); + } + }; + + // ── Delete ───────────────────────────────────────────────────────────────── + + const handleDelete = async (commentId: string) => { + setComments((prev) => prev.filter((c) => c.id !== commentId)); + try { + await apiService.delete(`${apiBase}/${commentId}`); + } catch (err: any) { + logger.errorSync('CommentSection: delete failed', err); + setError('Failed to delete comment.'); + } + }; + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( + + Comments ({comments.length}) + + {error && ( + + {error} + + Retry + + + )} + + {currentUserId && ( + + + + {submitting ? ( + + ) : ( + Post + )} + + + )} + + {loading ? ( + + ) : ( + item.id} + renderItem={({ item }) => ( + + )} + ListEmptyComponent={ + + No comments yet. Be the first to comment! + + } + scrollEnabled={false} + /> + )} + + ); +} + +export default CommentSection; + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { paddingHorizontal: 16, paddingVertical: 12 }, + heading: { fontSize: 18, fontWeight: '700', color: '#1e293b', marginBottom: 12 }, + errorBanner: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#fee2e2', + borderRadius: 8, + padding: 10, + marginBottom: 10, + }, + errorText: { color: '#dc2626', fontSize: 13, flex: 1 }, + retryText: { color: '#dc2626', fontWeight: '700', marginLeft: 8 }, + inputRow: { flexDirection: 'row', alignItems: 'flex-end', marginBottom: 16, gap: 8 }, + textInput: { + flex: 1, + borderWidth: 1.5, + borderColor: '#e2e8f0', + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 14, + color: '#1e293b', + minHeight: 44, + maxHeight: 120, + }, + submitButton: { + backgroundColor: '#19c3e6', + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + alignItems: 'center', + justifyContent: 'center', + minWidth: 60, + }, + submitDisabled: { opacity: 0.5 }, + submitText: { color: '#fff', fontWeight: '700', fontSize: 14 }, + loader: { marginTop: 24 }, + emptyText: { color: '#94a3b8', textAlign: 'center', marginTop: 24, fontSize: 14 }, + commentItem: { + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#f1f5f9', + }, + commentHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 }, + avatar: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: '#19c3e6', + alignItems: 'center', + justifyContent: 'center', + marginRight: 10, + }, + avatarText: { color: '#fff', fontWeight: '700', fontSize: 15 }, + commentMeta: { flex: 1 }, + authorName: { fontSize: 14, fontWeight: '600', color: '#1e293b' }, + commentDate: { fontSize: 12, color: '#94a3b8', marginTop: 1 }, + deleteButton: { padding: 4 }, + deleteText: { color: '#94a3b8', fontSize: 14 }, + commentBody: { fontSize: 14, color: '#334155', lineHeight: 20, marginBottom: 8 }, + likeButton: { flexDirection: 'row', alignItems: 'center', gap: 4 }, + likeIcon: { fontSize: 16, color: '#94a3b8' }, + likedIcon: { color: '#ef4444' }, + likeCount: { fontSize: 13, color: '#64748b' }, +}); diff --git a/src/components/mobile/index.ts b/src/components/mobile/index.ts index 7f1629f..00d852c 100644 --- a/src/components/mobile/index.ts +++ b/src/components/mobile/index.ts @@ -1,4 +1,5 @@ export * from './AchievementBadges'; +export * from './CommentSection'; export * from './AvatarCamera'; export * from './CourseCardSkeleton'; export * from './CourseViewerSkeleton'; @@ -22,5 +23,3 @@ export * from './SettingsSkeleton'; export * from './StatisticsDisplay'; export * from './SubscriptionSkeleton'; export * from './VoiceSearch'; -export * from './SwipeableRow'; -export * from './SwipeableCoordinator'; diff --git a/tests/components/CommentSection.test.tsx b/tests/components/CommentSection.test.tsx new file mode 100644 index 0000000..f9ba7c0 --- /dev/null +++ b/tests/components/CommentSection.test.tsx @@ -0,0 +1,363 @@ +import React from 'react'; +import { fireEvent, render, waitFor, act } from '@testing-library/react-native'; + +import CommentSection, { Comment } from '../../src/components/mobile/CommentSection'; +import apiService from '../../src/services/api'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +jest.mock('../../src/services/api', () => ({ + __esModule: true, + default: { + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + }, +})); + +jest.mock('../../src/utils/logger', () => ({ + __esModule: true, + default: { errorSync: jest.fn() }, +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn().mockResolvedValue(null), + setItem: jest.fn().mockResolvedValue(undefined), +})); + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const COMMENT_1: Comment = { + id: 'c1', + authorId: 'user-1', + authorName: 'Alice', + body: 'Great lesson!', + createdAt: '2024-01-15T10:00:00Z', + likeCount: 3, + likedByMe: false, +}; + +const COMMENT_2: Comment = { + id: 'c2', + authorId: 'user-2', + authorName: 'Bob', + body: 'Very helpful, thanks.', + createdAt: '2024-01-16T12:00:00Z', + likeCount: 1, + likedByMe: true, +}; + +const DEFAULT_PROPS = { + resourceId: 'lesson-42', + resourceType: 'lesson' as const, + currentUserId: 'user-1', + currentUserName: 'Alice', +}; + +const mockGet = apiService.get as jest.Mock; +const mockPost = apiService.post as jest.Mock; +const mockDelete = apiService.delete as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + mockGet.mockResolvedValue({ data: [COMMENT_1, COMMENT_2] }); + mockPost.mockResolvedValue({ data: { ...COMMENT_1, id: 'c-new', body: 'New comment' } }); + mockDelete.mockResolvedValue({ data: {} }); +}); + +// ── Loading state ────────────────────────────────────────────────────────────── + +describe('Loading state', () => { + it('shows loading indicator while fetching', () => { + // Never resolves during this test + mockGet.mockReturnValue(new Promise(() => {})); + const { getByTestId } = render(); + expect(getByTestId('comment-loading')).toBeTruthy(); + }); + + it('hides loading indicator after fetch completes', async () => { + const { queryByTestId } = render(); + await waitFor(() => expect(queryByTestId('comment-loading')).toBeNull()); + }); +}); + +// ── Rendering comments ───────────────────────────────────────────────────────── + +describe('Rendering comments', () => { + it('renders all fetched comments', async () => { + const { getByTestId } = render(); + await waitFor(() => { + expect(getByTestId('comment-item-c1')).toBeTruthy(); + expect(getByTestId('comment-item-c2')).toBeTruthy(); + }); + }); + + it('displays comment body text', async () => { + const { getByText } = render(); + await waitFor(() => { + expect(getByText('Great lesson!')).toBeTruthy(); + expect(getByText('Very helpful, thanks.')).toBeTruthy(); + }); + }); + + it('displays author names', async () => { + const { getByText } = render(); + await waitFor(() => { + expect(getByText('Alice')).toBeTruthy(); + expect(getByText('Bob')).toBeTruthy(); + }); + }); + + it('shows comment count in heading', async () => { + const { getByText } = render(); + await waitFor(() => expect(getByText('Comments (2)')).toBeTruthy()); + }); + + it('shows empty state when no comments', async () => { + mockGet.mockResolvedValue({ data: [] }); + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('comment-empty')).toBeTruthy()); + }); + + it('calls GET /api/lessons/:id/comments on mount', async () => { + render(); + await waitFor(() => + expect(mockGet).toHaveBeenCalledWith('/api/lessons/lesson-42/comments'), + ); + }); + + it('uses correct API path for course resource type', async () => { + render( + , + ); + await waitFor(() => + expect(mockGet).toHaveBeenCalledWith('/api/courses/course-7/comments'), + ); + }); +}); + +// ── Input visibility ─────────────────────────────────────────────────────────── + +describe('Comment input', () => { + it('shows input and submit button when currentUserId is provided', async () => { + const { getByTestId } = render(); + await waitFor(() => { + expect(getByTestId('comment-input')).toBeTruthy(); + expect(getByTestId('submit-comment')).toBeTruthy(); + }); + }); + + it('hides input when currentUserId is not provided', async () => { + const { queryByTestId } = render( + , + ); + await waitFor(() => { + expect(queryByTestId('comment-input')).toBeNull(); + expect(queryByTestId('submit-comment')).toBeNull(); + }); + }); +}); + +// ── Posting a comment ────────────────────────────────────────────────────────── + +describe('Posting a comment', () => { + it('calls POST with comment body and prepends new comment to list', async () => { + const newComment: Comment = { + id: 'c-new', + authorId: 'user-1', + authorName: 'Alice', + body: 'New comment', + createdAt: new Date().toISOString(), + likeCount: 0, + likedByMe: false, + }; + mockPost.mockResolvedValue({ data: newComment }); + + const { getByTestId } = render(); + await waitFor(() => getByTestId('comment-input')); + + fireEvent.changeText(getByTestId('comment-input'), 'New comment'); + fireEvent.press(getByTestId('submit-comment')); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith('/api/lessons/lesson-42/comments', { + body: 'New comment', + }); + expect(getByTestId('comment-item-c-new')).toBeTruthy(); + }); + }); + + it('clears the input after successful submission', async () => { + const { getByTestId } = render(); + await waitFor(() => getByTestId('comment-input')); + + fireEvent.changeText(getByTestId('comment-input'), 'Hello'); + fireEvent.press(getByTestId('submit-comment')); + + await waitFor(() => { + const input = getByTestId('comment-input'); + expect(input.props.value).toBe(''); + }); + }); + + it('does not submit when draft is empty', async () => { + const { getByTestId } = render(); + await waitFor(() => getByTestId('submit-comment')); + + fireEvent.press(getByTestId('submit-comment')); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('does not submit when draft is only whitespace', async () => { + const { getByTestId } = render(); + await waitFor(() => getByTestId('comment-input')); + + fireEvent.changeText(getByTestId('comment-input'), ' '); + fireEvent.press(getByTestId('submit-comment')); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('shows error banner when POST fails', async () => { + mockPost.mockRejectedValue(new Error('Network error')); + const { getByTestId } = render(); + await waitFor(() => getByTestId('comment-input')); + + fireEvent.changeText(getByTestId('comment-input'), 'Hello'); + fireEvent.press(getByTestId('submit-comment')); + + await waitFor(() => expect(getByTestId('comment-error')).toBeTruthy()); + }); +}); + +// ── Liking a comment ─────────────────────────────────────────────────────────── + +describe('Liking a comment', () => { + it('optimistically increments like count and calls POST like endpoint', async () => { + const { getByTestId, getByText } = render(); + await waitFor(() => getByTestId('like-comment-c1')); + + // c1 starts with likeCount=3, likedByMe=false + fireEvent.press(getByTestId('like-comment-c1')); + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + '/api/lessons/lesson-42/comments/c1/like', + {}, + ); + expect(getByText('4')).toBeTruthy(); + }); + }); + + it('optimistically decrements like count and calls DELETE like endpoint', async () => { + const { getByTestId, getByText } = render(); + await waitFor(() => getByTestId('like-comment-c2')); + + // c2 starts with likeCount=1, likedByMe=true + fireEvent.press(getByTestId('like-comment-c2')); + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith( + '/api/lessons/lesson-42/comments/c2/like', + ); + expect(getByText('0')).toBeTruthy(); + }); + }); + + it('reverts optimistic like update when API call fails', async () => { + mockPost.mockRejectedValueOnce(new Error('Network error')); + const { getByTestId, getByText } = render(); + await waitFor(() => getByTestId('like-comment-c1')); + + fireEvent.press(getByTestId('like-comment-c1')); + + await waitFor(() => { + // Should revert back to original likeCount=3 + expect(getByText('3')).toBeTruthy(); + }); + }); +}); + +// ── Deleting a comment ───────────────────────────────────────────────────────── + +describe('Deleting a comment', () => { + it('shows delete button only for comments owned by currentUser', async () => { + const { getByTestId, queryByTestId } = render(); + await waitFor(() => { + // c1 is owned by user-1 (currentUserId) + expect(getByTestId('delete-comment-c1')).toBeTruthy(); + // c2 is owned by user-2 + expect(queryByTestId('delete-comment-c2')).toBeNull(); + }); + }); + + it('removes comment from list and calls DELETE endpoint', async () => { + const { getByTestId, queryByTestId } = render(); + await waitFor(() => getByTestId('delete-comment-c1')); + + fireEvent.press(getByTestId('delete-comment-c1')); + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith('/api/lessons/lesson-42/comments/c1'); + expect(queryByTestId('comment-item-c1')).toBeNull(); + }); + }); +}); + +// ── Error handling ───────────────────────────────────────────────────────────── + +describe('Error handling', () => { + it('shows error banner when initial fetch fails', async () => { + mockGet.mockRejectedValue(new Error('Server error')); + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId('comment-error')).toBeTruthy()); + }); + + it('retry button re-fetches comments', async () => { + mockGet.mockRejectedValueOnce(new Error('Server error')); + mockGet.mockResolvedValueOnce({ data: [COMMENT_1] }); + + const { getByTestId, getByText } = render(); + await waitFor(() => getByTestId('comment-error')); + + fireEvent.press(getByText('Retry')); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(2); + expect(getByTestId('comment-item-c1')).toBeTruthy(); + }); + }); +}); + +// ── Accessibility ────────────────────────────────────────────────────────────── + +describe('Accessibility', () => { + it('like button has correct accessibilityLabel when not liked', async () => { + const { getByTestId } = render(); + await waitFor(() => getByTestId('like-comment-c1')); + const likeBtn = getByTestId('like-comment-c1'); + expect(likeBtn.props.accessibilityLabel).toBe('Like comment'); + }); + + it('like button has correct accessibilityLabel when already liked', async () => { + const { getByTestId } = render(); + await waitFor(() => getByTestId('like-comment-c2')); + const likeBtn = getByTestId('like-comment-c2'); + expect(likeBtn.props.accessibilityLabel).toBe('Unlike comment'); + }); + + it('delete button has correct accessibilityLabel', async () => { + const { getByTestId } = render(); + await waitFor(() => getByTestId('delete-comment-c1')); + expect(getByTestId('delete-comment-c1').props.accessibilityLabel).toBe('Delete comment'); + }); + + it('submit button has correct accessibilityLabel', async () => { + const { getByTestId } = render(); + await waitFor(() => getByTestId('submit-comment')); + expect(getByTestId('submit-comment').props.accessibilityLabel).toBe('Post comment'); + }); +});