project setup
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user