Learn how to build a robust user mention system with autocomplete, role-based permissions, and real-time notifications using React, TypeScript, and Drizzle ORM.
Building a user mention system involves several complex challenges: database queries, role-based permissions, real-time updates, and proper error handling. In this guide, we'll walk through implementing a complete mention system that handles all these aspects.
Our mention system includes:
@
// Frontend: React 19 + TypeScript + TanStack Query
// Backend: Drizzle ORM + libSQL + oRPC (RPC framework)
// Real-time: oRPC EventPublisher + Server-Sent Events (SSE)
Note: This implementation uses oRPC EventPublisher with Server-Sent Events (SSE) instead of WebSockets for:
The core mention functionality is implemented in src/components/app/comment-section.tsx
:
// Key state variables for mention handling
const [showMentionSuggestions, setShowMentionSuggestions] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0);
// Fetch available users for mentions
const { data: availableUsers } = useQuery({
queryKey: ["taskUsers", taskId],
queryFn: () => orpc.task.getTaskUsers.call({ taskId }),
enabled: isVisible && !!taskId,
});
// Handle mention suggestions
const handleCommentChange = (value: string) => {
setNewComment(value);
// Check for @ symbol to show mention suggestions
const lastAtIndex = value.lastIndexOf('@');
if (lastAtIndex !== -1) {
const query = value.slice(lastAtIndex + 1);
// Show suggestions as soon as @ is typed (and until a space/newline occurs)
if (!query.includes(' ')) {
setMentionQuery(query);
setMentionStartIndex(lastAtIndex);
setShowMentionSuggestions(true);
setSelectedMentionIndex(0);
return;
}
}
setShowMentionSuggestions(false);
};
// Handle mention selection
const selectMention = (user: User) => {
if (mentionStartIndex !== -1) {
const beforeMention = newComment.slice(0, mentionStartIndex);
const afterMention = newComment.slice(mentionStartIndex + mentionQuery.length + 1);
const newValue = `${beforeMention}@${user.name} ${afterMention}`;
setNewComment(newValue);
setShowMentionSuggestions(false);
setMentionStartIndex(-1);
setMentionQuery("");
// Focus back to textarea
commentInputRef.current?.focus();
}
};
The getTaskUsers
endpoint in src/server/routes/task.ts
implements role-based mention permissions:
getTaskUsers: protectedProcedure
.input(z.object({ taskId: z.string() }))
.handler(async ({ input, context }) => {
try {
// Get the task
const task = await db.query.tasks.findFirst({
where: eq(tasks.id, input.taskId),
});
if (!task) {
throw new Error("Task not found");
}
// Check access permissions
const hasAccess = task.userId === context.session.user.id ||
task.isPublic ||
(task.sharedWith &&
JSON.parse(task.sharedWith).includes(context.session.user.email));
if (!hasAccess) {
throw new Error("Unauthorized to view this task");
}
// Disable mentions for public tasks
if (task.isPublic) {
return [];
}
// Role-based mention logic:
// - Task owner: can mention shared users
// - Shared users: can mention task owner
if (task.userId === context.session.user.id) {
// Task owner - show shared users
return await getSharedUsers(task.sharedWith, context.session.user.id);
} else {
// Shared user - show task owner
return await getTaskOwner(task.userId, context.session.user.id);
}
} catch (error) {
console.error("Get task users error:", error);
throw new Error("Failed to fetch task users");
}
}),
Real-time updates are handled through src/server/orpc.ts
:
// Create EventPublisher for real-time task updates
export const taskPublisher = new EventPublisher<{
"task-created": { taskId: string; userId: string; task: Task };
"task-updated": { taskId: string; userId: string; task: Task };
"task-deleted": { taskId: string; userId: string };
"task-status-changed": {
taskId: string;
userId: string;
oldStatus: string;
newStatus: string;
task: Task
};
}>();
// Real-time task updates using Event Iterator
export const realtimeTasks = protectedProcedure.handler(async function* ({
context,
signal,
}) {
try {
// Send initial connection confirmation
yield withEventMeta(
{
type: "connected",
message: "Connected to real-time task updates",
userId: context.session.user.id,
},
{ id: "connection", retry: 5000 },
);
// Subscribe to task events for this user
for await (const event of taskPublisher.subscribe("task-created", {
signal,
})) {
// Only send events relevant to the current user
if (
event.userId === context.session.user.id ||
event.task.isPublic ||
(event.task.sharedWith &&
JSON.parse(event.task.sharedWith).includes(
context.session.user.email,
))
) {
yield withEventMeta(
{
type: "task-created",
task: event.task,
timestamp: new Date().toISOString(),
},
{ id: `task-created-${event.taskId}`, retry: 3000 },
);
}
}
} catch (error) {
console.error("Real-time connection error:", error);
}
});
The real-time functionality is consumed through src/lib/hooks/useRealtimeTasks.ts
:
export function useRealtimeTasks() {
return useQuery({
queryKey: ["realtime-tasks"],
queryFn: () => client.realtimeTasks(),
refetchInterval: false, // Disable polling since we're using SSE
staleTime: Infinity, // Keep the data fresh since it's real-time
gcTime: 0, // Don't cache since it's streaming data
});
}
// Hook to handle real-time task updates
export function useTaskUpdates(onUpdate?: (event: RealtimeTaskEvent) => void) {
const { data: eventGenerator } = useRealtimeTasks();
React.useEffect(() => {
if (!eventGenerator) return;
const processEvents = async () => {
try {
for await (const event of eventGenerator) {
onUpdate?.(event);
}
} catch (error) {
console.error("Error reading real-time stream:", error);
}
};
processEvents();
}, [eventGenerator, onUpdate]);
return eventGenerator;
}
const handleSubmitComment = () => {
if (!newComment.trim()) return;
// Parse mentions from comment text (only @Name)
const mentionParts = newComment.split('@').slice(1);
const mentions: string[] = [];
mentionParts.forEach(part => {
const nameMatch = part.match(/^([a-zA-Z\s]+)/);
if (nameMatch) {
const userName = nameMatch[1].trim();
// Find user by name (exact, case-insensitive, or partial match)
const user = availableUsers?.find((u: User) =>
u.name === userName ||
u.name.toLowerCase() === userName.toLowerCase() ||
userName.toLowerCase().startsWith(u.name.toLowerCase()) ||
u.name.toLowerCase().startsWith(userName.toLowerCase())
);
if (user && !mentions.includes(user.email)) {
mentions.push(user.email);
}
}
});
// Submit comment with mentions
addCommentMutation.mutate({
content: newComment.trim(),
mentions: mentions,
parentCommentId: replyingTo || undefined,
});
};
// Highlight mentions in comment text
const highlightMentions = (text: string) => {
const nameMentionRegex = /@([a-zA-Z\s]+)/g;
const processedText = text.replace(
nameMentionRegex,
'<span class="text-blue-600 font-medium bg-blue-50 dark:bg-blue-900/20 px-1 rounded">$&</span>',
);
return processedText;
};
Problem: users.email
was undefined, causing SQL syntax errors.
Solution: Use raw SQL queries to bypass Drizzle ORM import issues:
const result = await client.execute(
`SELECT * FROM users WHERE email IN (${placeholders})`,
emails
);
Problem: Different users should see different mentionable users.
Solution: Implement conditional logic based on task ownership:
if (task.userId === context.session.user.id) {
// Task owner: show shared users
return sharedUsers;
} else if (isSharedUser) {
// Shared user: show task owner
return [taskOwner];
}
Problem: Comments need to refresh when new mentions are added.
Solution: Use oRPC EventPublisher with Server-Sent Events (SSE):
// Backend: Publish events when comments are added
commentPublisher.publish("comment-added", {
commentId: newComment.id,
taskId: newComment.taskId,
userId: context.session.user.id,
comment: newComment,
});
// Frontend: Subscribe to real-time updates
const { data: eventGenerator } = useQuery({
queryKey: ["realtime-comments"],
queryFn: () => client.realtimeComments(),
refetchInterval: false,
staleTime: Infinity,
gcTime: 0,
});
During our implementation, we encountered a critical issue when migrating from local SQLite to Turso cloud database:
ORPCError: Unauthorized
Error in renderToPipeableStream: TypeError: Cannot read properties of null
Root Cause: Protected procedures were being called during Server-Side Rendering (SSR) when no user session exists, causing the application to crash.
// Before (caused SSR crashes)
export const Route = createFileRoute("/_authed/task")({
loader: () => orpc.task.getAll.call(), // โ Protected call during SSR
component: RouteComponent,
});
// After (SSR-safe)
export const Route = createFileRoute("/_authed/task")({
// loader removed - data fetched client-side only
component: RouteComponent,
});
getAll: protectedProcedure.handler(async ({ context }) => {
// SSR-safe check
if (!context.session?.user) {
return []; // Return empty array instead of throwing
}
// ... rest of the logic
});
// Use client-side queries instead of SSR loaders
const { data: tasks } = useQuery({
queryKey: ["tasks"],
queryFn: () => orpc.task.getAll.call(),
enabled: !!session?.user, // Only fetch when authenticated
});
@
Building a user mention system involves multiple layers: frontend UI, backend APIs, database queries, and real-time updates. By following this guide and implementing proper error handling, validation, and role-based permissions, you can create a robust and user-friendly mention system.
The key is to start simple and gradually add complexity while maintaining good practices for security, performance, and user experience.
This implementation is part of TSStarter - Premium TypeScript Starter for Modern Web Applications.