Implementing a User Mention System

Article
Featured

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.

TS Starter Team
Author
8/22/2025
Published

Implementing a User Mention System in TS STARTER

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

  1. 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,
});
  1. 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
});
  1. 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

  1. Always validate inputs before database queries
  2. Use proper error boundaries for graceful failures
  3. Implement role-based access control for security
  4. Optimize for performance with proper caching
  5. Test edge cases like empty results and errors
  6. Document your API for team collaboration
  7. 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.


This implementation is part of TSStarter - Premium TypeScript Starter for Modern Web Applications.