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');
+ });
+});