Files
AthenaFE/components/features/todos/TodoComments.tsx
T
2025-11-02 10:08:56 +01:00

441 lines
16 KiB
TypeScript

import { useMemo, useState, useCallback } from "react";
import { View, ScrollView, TextInput, Pressable, Text } from "react-native";
import { Avatar, Button, Menu } from "react-native-paper";
import { Ionicons } from "@expo/vector-icons";
import { formatDistanceToNow, formatDistanceToNowStrict } from "date-fns";
import {
useGetTodoCommentsQuery,
useAddCommentMutation,
useUpdateCommentMutation,
useDeleteCommentMutation,
} from "@/store/api/commentsApi";
import { useAuthContext } from "@/auth/AuthContext";
import { useGetUsersQuery } from "@/store/api/usersApi";
import { AppSnackbar } from "@/components/ui/AppSnackbar";
interface TodoCommentsProps {
todoId: string;
}
// Helper: compact relative time like "2h ago"
const timeAgo = (dateInput: string | Date | null | undefined) => {
if (!dateInput) return "";
const date = typeof dateInput === "string" ? new Date(dateInput) : dateInput;
try {
const raw = formatDistanceToNowStrict(date, { addSuffix: false });
// raw examples: "2 hours", "1 minute", "less than a minute"
const m = raw.match(
/(\d+)\s+(second|seconds|minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)/
);
if (m) {
const n = m[1];
const unit = m[2];
const abbr = unit.startsWith("second")
? "s"
: unit.startsWith("minute")
? "m"
: unit.startsWith("hour")
? "h"
: unit.startsWith("day")
? "d"
: unit.startsWith("week")
? "w"
: unit.startsWith("month")
? "mo"
: "y";
return `${n}${abbr} ago`;
}
// fallback to full format
return formatDistanceToNow(date, { addSuffix: true });
} catch {
return "";
}
};
const LONG_COMMENT_THRESHOLD = 220;
interface TodoCommentsProps {
todoId: string;
}
export const TodoComments = ({ todoId }: TodoCommentsProps) => {
const { user } = useAuthContext();
const {
data: comments,
isLoading,
isError,
refetch,
} = useGetTodoCommentsQuery(todoId);
const [addComment, { isLoading: isAdding }] = useAddCommentMutation();
const [updateComment, { isLoading: isUpdating }] = useUpdateCommentMutation();
const [deleteComment, { isLoading: isDeleting }] = useDeleteCommentMutation();
const [content, setContent] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [menuId, setMenuId] = useState<string | null>(null);
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarVisible(true);
};
const canPost = useMemo(() => !!user, [user]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed) return;
try {
await addComment({ todoId, content: trimmed }).unwrap();
setContent("");
} catch {
showSnackbar("Error creating comment");
}
};
const startEdit = (id: string, current: string) => {
setEditingId(id);
setEditContent(current);
};
const cancelEdit = () => {
setEditingId(null);
setEditContent("");
};
const saveEdit = async () => {
const trimmed = editContent.trim();
if (!editingId || !trimmed) return;
try {
await updateComment({
todoId,
commentId: editingId,
content: trimmed,
}).unwrap();
setEditingId(null);
setEditContent("");
} catch {
showSnackbar("Error updating comment");
}
};
const onDelete = async (id: string) => {
try {
// simple confirm for web; on native it won't show but action still works if called
if (typeof window !== "undefined") {
const ok = window.confirm?.("Delete this comment?") ?? true;
if (!ok) return;
}
await deleteComment({ todoId, commentId: id }).unwrap();
} catch {
showSnackbar("Error deleting comment");
}
};
// Mention support
const extractMentionQuery = useCallback((text: string) => {
// find last segment starting with @ that has no whitespace afterwards yet
const match = text.match(/@([\w.-]{1,30})$/);
return match ? match[1] : "";
}, []);
const mentionQuery = extractMentionQuery(editingId ? editContent : content);
const { data: mentionUsers } = useGetUsersQuery(
mentionQuery
? { search: mentionQuery, page: 1, limit: 5, roleType: "admin" }
: undefined,
{ skip: !mentionQuery }
);
const insertMention = (fullName: string) => {
const setter = editingId ? setEditContent : setContent;
const text = editingId ? editContent : content;
// replace trailing @query with @Full Name and a space
const replaced = text.replace(/@([\w.-]{1,30})$/, `@${fullName} `);
setter(replaced);
};
const renderContentWithMentions = (text: string) => {
const parts = text.split(/(@[\w.-]+)/g);
return parts.map((p, i) =>
p.startsWith("@") ? (
<Text key={i} className="text-primaryPurpleDark font-medium">
{p}
</Text>
) : (
<Text key={i}>{p}</Text>
)
);
};
const getCommentAuthorName = (commentUser: {
id: string;
firstName?: string;
lastName?: string;
}) => {
if (!commentUser) return "Unknown User";
if (commentUser.id === user?.id) return "You";
return `${(commentUser.firstName || "").trim()} ${(
commentUser.lastName || ""
).trim()}`.trim();
};
return (
<>
<View className="flex-1">
<Text className="text-lg mb-2">Comments</Text>
{isLoading ? (
<Text>Loading comments...</Text>
) : isError ? (
<View>
<Text className="text-red-500 mb-2">Failed to load comments</Text>
<Button mode="text" onPress={refetch}>
Retry
</Button>
</View>
) : comments && comments.length > 0 ? (
<ScrollView
className="max-h-96"
contentContainerStyle={{ paddingBottom: 8, paddingTop: 4 }}
showsVerticalScrollIndicator
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
scrollEnabled
persistentScrollbar
style={{ maxHeight: 384, minHeight: 0, flexGrow: 0 }}
>
{comments.map((c, idx) => {
const isExpanded = !!expanded[c.id];
const isLong = (c.content?.length || 0) > LONG_COMMENT_THRESHOLD;
const prevUserId = idx > 0 ? comments[idx - 1]?.user?.id : null;
const isNewUserBlock = idx > 0 && prevUserId !== c.user?.id;
return (
<View key={c.id} className="mb-2">
{isNewUserBlock ? (
<View
style={{
height: 1,
backgroundColor: "#D9D9D9",
marginVertical: 8,
}}
/>
) : null}
<View className="py-1">
<View className="flex-row">
{c.user?.imageUrl ? (
<Avatar.Image
source={{ uri: c.user.imageUrl }}
size={32}
style={{ marginRight: 10, marginTop: 0 }}
/>
) : (
<Avatar.Text
label={
(c.user?.firstName?.[0] || "?") +
(c.user?.lastName?.[0] || "").toUpperCase()
}
size={32}
style={{ marginRight: 10, marginTop: 0 }}
/>
)}
<View className="flex-1">
<View
className="flex-row justify-between items-center"
style={{ minHeight: 18, marginBottom: 6 }}
>
<View className="flex-1 pr-2" style={{ minWidth: 0 }}>
<Text
className="text-[13px] font-medium text-gray-900"
numberOfLines={1}
style={{ lineHeight: 18 }}
>
{getCommentAuthorName(c.user)}
</Text>
</View>
<View className="flex-row items-center">
<Text className="text-[11px] text-gray-500">
{timeAgo(c.createdAt)}
</Text>
<View className="ml-2" style={{ flexShrink: 0 }}>
{user?.id === c.user?.id && editingId !== c.id ? (
<Menu
visible={menuId === c.id}
onDismiss={() => setMenuId(null)}
anchor={
<Pressable
onPress={() => setMenuId(c.id)}
style={{
height: 18,
width: 18,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons
name="ellipsis-vertical"
size={16}
color="#555"
/>
</Pressable>
}
>
<Menu.Item
leadingIcon="pencil-outline"
onPress={() => {
setMenuId(null);
startEdit(c.id, c.content);
}}
title="Edit"
/>
<Menu.Item
leadingIcon="delete-outline"
onPress={() => {
setMenuId(null);
onDelete(c.id);
}}
disabled={isDeleting}
title="Delete"
/>
</Menu>
) : null}
</View>
</View>
</View>
{editingId === c.id ? (
<View style={{ marginTop: 0 }}>
<TextInput
className="border border-gray-300 rounded-md p-2 bg-white"
placeholder="Edit your comment..."
placeholderTextColor="#d9d9d9"
value={editContent}
onChangeText={setEditContent}
multiline
/>
{mentionQuery && mentionUsers?.items?.length ? (
<View className="bg-white border border-gray-200 rounded-md mt-1 shadow-sm">
{mentionUsers.items.map((u) => (
<Pressable
key={u.id}
onPress={() =>
insertMention(
`${u.firstName} ${u.lastName}`
)
}
>
<Text className="p-2">
@{u.firstName} {u.lastName}
</Text>
</Pressable>
))}
</View>
) : null}
<View className="flex-row justify-end mt-2">
<Button
compact
mode="contained"
onPress={saveEdit}
disabled={!editContent.trim() || isUpdating}
>
Save
</Button>
<Button
compact
mode="text"
onPress={cancelEdit}
disabled={isUpdating}
>
Cancel
</Button>
</View>
</View>
) : (
<View style={{ marginTop: 0 }}>
<Text
className="text-[13px] text-gray-800"
numberOfLines={isExpanded ? undefined : 4}
>
{renderContentWithMentions(c.content)}
</Text>
{isLong ? (
<Pressable
onPress={() =>
setExpanded((prev) => ({
...prev,
[c.id]: !isExpanded,
}))
}
>
<Text className="mt-1 text-[12px] font-medium text-primaryPurpleDark">
{isExpanded ? "Show less" : "Show more"}
</Text>
</Pressable>
) : null}
</View>
)}
</View>
</View>
</View>
</View>
);
})}
</ScrollView>
) : (
<Text className="text-gray-500 mb-2">No comments yet</Text>
)}
{canPost ? (
<View className="mt-3">
<TextInput
className="border border-gray-300 rounded-md p-2 bg-white"
placeholder="Write a comment..."
placeholderTextColor="#d9d9d9"
value={content}
onChangeText={setContent}
multiline
/>
{mentionQuery && mentionUsers?.items?.length ? (
<View className="bg-white border border-gray-200 rounded-md mt-1 shadow-sm">
{mentionUsers.items.map((u) => (
<Pressable
key={u.id}
onPress={() =>
insertMention(`${u.firstName} ${u.lastName}`)
}
>
<Text className="p-2">
@{u.firstName} {u.lastName}
</Text>
</Pressable>
))}
</View>
) : null}
<View className="flex-row justify-end mt-2">
<Button
mode="contained"
onPress={handleSubmit}
disabled={!content.trim() || isAdding}
>
Post
</Button>
</View>
</View>
) : null}
</View>
<AppSnackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
>
{snackbarMessage}
</AppSnackbar>
</>
);
};