441 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
};
|