Implementing a User Mention System
Implementing a User Mention System
Learn how to build a robust user mention system with autocomplete, role-based permissions, and real-time notifications using React, TypeScript, and Drizzle ORM.
Implementing a User Mention System in TSStarter
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.
๐ฏ What We're Building
Our mention system includes:
- @ Autocomplete: Dropdown suggestions when typing
@ - Role-Based Permissions: Different users can mention different people
- Real-Time Updates: Comments refresh automatically
- Name-Based Mentions: Users are mentioned by name, not email
- Public Task Handling: Mentions disabled for public tasks
๐๏ธ Architecture Overview
// 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:
- Simpler implementation than WebSockets
- Automatic reconnection handling
- Better integration with the existing oRPC framework
- Type-safe events with TypeScript
๐ Step 1: Frontend Implementation
Comment Section Component
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();
}
};
๐ง Step 2: Backend API Implementation
Role-Based User Fetching
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");
}
}),
๐ Step 3: Real-Time Architecture
oRPC EventPublisher Setup
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);
}
});
Frontend Real-Time Hook
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;
}
๐จ Step 4: Comment Submission with Mentions
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,
});
};
๐ฏ Step 5: Mention Highlighting
// 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;
};
๐จ Common Challenges & Solutions
1. Database Import Issues
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
);
2. Role-Based Permissions
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];
}
3. Real-Time Updates
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,
});
๐จ Critical Fix: oRPC Unauthorized Errors During SSR
During our implementation, we encountered a critical issue when migrating from local SQLite to Turso cloud database:
The Problem
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.
The Solution
- Remove SSR Loaders: Eliminate protected API calls from route loaders:
// 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,
});
- Add Defensive Checks: Make API handlers SSR-safe:
getAll: protectedProcedure.handler(async ({ context }) => {
// SSR-safe check
if (!context.session?.user) {
return []; // Return empty array instead of throwing
}
// ... rest of the logic
});
- Client-Side Data Fetching: Move protected queries to client components:
// 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
});
๐ Key Features Implemented
โ Autocomplete Dropdown
- Shows immediately when typing
@ - Keyboard navigation (arrow keys, enter, escape)
- Click outside to close
- Filters users by name
โ Role-Based Permissions
- Task owners can mention shared users
- Shared users can mention task owners
- Shared users cannot mention each other
- Public tasks have mentions disabled
โ Real-Time Updates
- Comments refresh automatically
- New mentions appear immediately
- oRPC EventPublisher with Server-Sent Events (SSE) for live updates
โ Error Handling
- Comprehensive validation
- Graceful fallbacks
- Detailed error logging
- User-friendly error messages
โ Performance Optimizations
- Debounced input handling
- Efficient filtering
- Optimized database queries
- Proper cleanup on unmount
๐ง Technical Stack
- Frontend: React 19, TypeScript, TanStack Query
- Backend: Drizzle ORM, libSQL, oRPC (RPC framework)
- Real-time: oRPC EventPublisher + Server-Sent Events (SSE)
- Styling: Tailwind CSS
- State Management: React hooks + TanStack Query
๐ฏ Best Practices
- Always validate inputs before database queries
- Use proper error boundaries for graceful failures
- Implement role-based access control for security
- Optimize for performance with proper caching
- Test edge cases like empty results and errors
- Document your API for team collaboration
- Avoid protected calls during SSR - use client-side fetching instead
๐ Conclusion
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.
TSStarter is crafted with the best TypeScript tools for building web and universal applications.