Skip to main content

Workspace Messenger

Workspace - Messenger

Overview

The Messenger feature provides direct and group messaging capabilities between users within the organization. It's accessible from the "My Workspace" hub in the sidebar.

Gmail-style inbox (Apr 2026)

The full-page Messenger uses a Gmail-style layout:

  • Left folder sidebar (FolderSidebar): Inbox, Unread, Drafts, Volunteers, user-created folders, and “New folder”. Filing is per user (private); other participants do not see your folders.
  • Main area shows either the conversation list or the message thread full width (not side-by-side). Opening a conversation replaces the list; Back returns to the list (desktop and mobile).
  • Navigation: The left sidebar is the only primary filter surface. The top pill row was removed; Volunteers is now a built-in sidebar view.
  • Ordering: Inbox and header dropdown both sort conversations unread first, then by most recent message activity, so newly received unread messages rise to the top immediately.
  • Drafts: Composer text is debounced auto-saved (message_drafts table). Saved attachment metadata is restored as chips when reopening the draft; users must reattach the actual files before sending. Sending a message deletes the draft. The header MessengerDropdown is unchanged (no folders).

Migrations: 20260412100000_conversation_folders.sql, 20260412100100_message_drafts.sql

Components

| Component | File | Purpose |
|-----------|------|---------|
| WorkspacePage | `src/features/workspace/components/WorkspacePage.tsx` | Hub page with tool selection |
| Messenger | `src/features/workspace/components/Messenger.tsx` | Gmail-style layout: folder sidebar + list or thread |
| FolderSidebar | `src/features/workspace/components/FolderSidebar.tsx` | Left nav: Inbox, Unread, Drafts, Volunteers, custom folders |
| FolderManageDialog | `src/features/workspace/components/FolderManageDialog.tsx` | Create/rename folder |
| ConversationList | `src/features/workspace/components/ConversationList.tsx` | Search, unread-first conversation rows, move-to-folder menu |
| MessageThread | `src/features/workspace/components/MessageThread.tsx` | Message display area + manage members popover |
| MessageBubble | `src/features/workspace/components/MessageBubble.tsx` | Individual message rendering; avatars outside bubbles (left = peer, right = you) |
| MessageInput | `src/features/workspace/components/MessageInput.tsx` | Message composition + attachments |
| NewConversationSheet | `src/features/workspace/components/NewConversationSheet.tsx` | New conversation drawer (direct + group) |
| MessengerDropdown | `src/features/workspace/components/MessengerDropdown.tsx` | Header dropdown for quick messaging (replaces MintyAI dropdown) |

Hooks

| Hook | File | Purpose |
|------|------|---------|
| useConversations | `src/features/workspace/hooks/useConversations.ts` | Fetch/manage conversations, add/remove participants (React Query, exports `messagingKeys`) |
| useMessages | `src/features/workspace/hooks/useMessages.ts` | Fetch/send/delete messages, attachment uploads (React Query) |
| useRealtimeMessages | `src/features/workspace/hooks/useRealtimeMessages.ts` | Real-time message updates via Supabase Realtime |
| useMessagingPermissions | `src/features/workspace/hooks/useMessagingPermissions.ts` | Eligible recipients based on org scope |
| useUnreadMessageCount | `src/features/workspace/hooks/useUnreadMessageCount.ts` | Total unread message count for header badge (realtime) |
| useMessageReactions | `src/features/workspace/hooks/useMessageReactions.ts` | Fetch/toggle emoji reactions on messages (React Query + realtime) |
| useConversationFolders | `src/features/workspace/hooks/useConversationFolders.ts` | Custom folders + per-user filing (React Query) |
| useMessageDrafts | `src/features/workspace/hooks/useMessageDrafts.ts` | Debounced draft auto-save + delete on send |

Data Access & Utilities

| Module | File | Purpose |
|--------|------|---------|
| messaging | `src/lib/db/messaging.ts` | All Supabase queries for Messenger — retry-enabled user fetches, conversation/message/reaction CRUD, folders, drafts |
| resolveUserName | `src/features/workspace/utils/resolveUserName.ts` | Shared name resolver with email fallback — used by db/messaging.ts and components |

i18n

All user-facing strings use the messenger namespace (src/i18n/locales/{lang}/messenger.json). Components use useTranslation() with keys like t('messenger.noMessagesYet').

Database Tables

| Table | Purpose |
|-------|---------|
| `conversations` | Conversation threads — `type` is `'direct'` or `'group'`, `name` for groups, `created_by` |
| `conversation_participants` | Junction table `(conversation_id, user_id, organization_id)` with UNIQUE constraint |
| `messages` | Individual messages — in `supabase_realtime` publication for real-time updates |
| `message_attachments` | File metadata — references storage bucket `message-attachments` |
| `conversation_read_status` | Per-user unread counts — `(conversation_id, user_id)` UNIQUE |
| `message_email_queue` | Delayed email notification queue — `(recipient_id, conversation_id)` UNIQUE, processed by cron every 1 min |
| `message_reactions` | Emoji reactions on messages — `(message_id, user_id, emoji)` UNIQUE, RLS scoped to conversation participants, in `supabase_realtime` publication |
| `conversation_folders` | User-created folder names — `user_id`, `name`, `sort_order`; RLS: own rows only |
| `conversation_folder_assignments` | Filing — `(conversation_id, user_id)` UNIQUE → `folder_id`; RLS: own rows only, folder must belong to the same user, conversation must pass `is_conversation_participant(...)` |
| `message_drafts` | Auto-saved composer state — `(user_id, conversation_id)` UNIQUE, `attachments_meta` jsonb for restored attachment chips; RLS: own rows only and conversation must pass `is_conversation_participant(...)` |

Database Functions (RPCs)

| Function | Type | Purpose |
|----------|------|---------|
| `create_conversation_with_read_status(recipient_id, sender_org_id)` | RPC | Creates a direct conversation, checks for existing, sets up read status |
| `create_group_conversation(group_name, participant_ids[])` | RPC | Creates a group conversation with validation |
| `add_conversation_participant(conv_id, new_user_id)` | RPC (SECURITY DEFINER) | Adds a participant — creates read_status, looks up correct org_id, auto-upgrades direct→group when >2 participants |
| `remove_conversation_participant(conv_id, remove_user_id)` | RPC (SECURITY DEFINER) | Removes a participant — cleans up read_status, reverts group→direct when ≤2, deletes conversation when 0 remain |
| `get_user_conversations(for_user_id)` | RPC | Returns all conversations for a user with last message info and unread counts |
| `get_eligible_message_recipients(for_user_id)` | RPC | Returns users the caller can message based on org scope |
| `is_conversation_participant(conv_id, user_id)` | Helper | Boolean check used by RLS policies |
| `validate_messaging_permission(sender_id, recipient_id)` | Helper | Validates org-scoped messaging rules |

Database Triggers

| Trigger | Table | Purpose |
|---------|-------|---------|
| `trigger_increment_unread_count` | `messages` (AFTER INSERT) | Increments `unread_count` in `conversation_read_status` for all participants except sender |
| `trigger_notify_new_message` | `messages` (AFTER INSERT) | Creates/updates in-app notifications with 5s suppression + dedup; queues email notifications into `message_email_queue` |
| `trigger_update_conversation_timestamp` | `messages` (AFTER INSERT) | Updates `conversations.updated_at` for sort ordering |

Storage

  • Bucket: message-attachments (private)
  • Upload path: {user_id}/{conversation_id}/{timestamp}_{filename}
  • RLS: Upload to own path, read if conversation participant, delete own uploads
  • Limits: 50MB per file, max 5 attachments per message
  • Accepted types: Images (jpeg, png, gif, webp, svg), PDF, Word docs, Excel spreadsheets

Group Conversation Management

Adding a participant

1. Validates caller is a participant and has messaging permission 2. Looks up the new user's actual organization_id from organization_users 3. Inserts into conversation_participants and conversation_read_status 4. Auto-upgrades conversations.type from direct to group when >2 participants 5. Auto-generates a group name from participant first names if none exists

  • Available from the Settings (gear) icon in the MessageThread header
  • Uses add_conversation_participant RPC which:

Removing a participant

1. Deletes from conversation_participants and conversation_read_status 2. If 0 participants remain: deletes the conversation (FK cascades handle messages/attachments) 3. If ≤2 participants remain: reverts type to direct, clears group name

  • Available via the remove (UserMinus) button next to each member in the popover
  • Uses remove_conversation_participant RPC which:

Leaving a conversation

  • "Leave" option in the conversation list dropdown menu
  • Uses the same remove_conversation_participant RPC with remove_user_id = auth.uid()

Display name logic

  • Group: Uses conversation_name if set, otherwise auto-generates from participant first names
  • Direct: Uses the other participant's full name
  • Orphan threads (no other participant): Displays "Former Participant"

Name resolution (resolveUserName)

All Messenger surfaces use a shared utility (src/features/workspace/utils/resolveUserName.ts) for consistent name display: 1. first_name + last_name (trimmed) 2. email (fallback when names are empty) 3. "Unknown User" (last resort)

This resolver is used by useConversations, useMessages, useMessageReactions, and MessageBubble.

Layout Pattern

The Messenger uses a flexbox layout similar to Minty AI:

// Root container - must have overflow-hidden and minHeight: 0
<div className="flex flex-col h-full overflow-hidden" style={{ minHeight: 0 }}>
  {/* Header - shrink-0 to prevent shrinking */}
  <div className="shrink-0 border-b">...</div>
  
  {/* Scrollable content wrapper - flex-1 with min-h-0 and overflow-hidden */}
  <div className="flex-1 min-h-0 overflow-hidden relative">
    <ScrollArea className="h-full">
      <div className="p-4">
        {/* Content here */}
      </div>
    </ScrollArea>
  </div>
  
  {/* Input - sticky + flex-shrink-0 to stay at bottom */}
  <div className="sticky bottom-0 flex-shrink-0 border-t z-10">...</div>
</div>

Key CSS patterns for scrollable flex containers: 1. Parent must have overflow-hidden 2. Scrollable wrapper needs flex-1 min-h-0 overflow-hidden 3. ScrollArea uses h-full with content padding inside a child div 4. Fixed elements use sticky bottom-0 flex-shrink-0 z-10

Features

Implemented

  • Gmail-style full-page layout: folder sidebar, full-width list or thread with back navigation
  • Per-user custom folders and “Move to folder” on conversation rows; Inbox shows all conversations; selecting a custom folder shows only conversations filed there
  • Sidebar built-ins for Unread, Drafts, and Volunteers; no secondary pill row in the conversation list
  • Debounced draft auto-save per conversation; Drafts folder lists conversations with unsent text
  • Direct messaging between users
  • Group conversations (2+ participants, named groups)
  • Add/remove participants (auto-upgrades direct↔group)
  • Real-time message updates via Supabase Realtime (messages in publication)
  • File attachments (up to 5 per message, 50MB max each, stored in message-attachments bucket)
  • Unread message counts (via increment_unread_count trigger)
  • In-app notifications for new messages (via notify_new_message trigger, 5s suppression) — stored in notifications table but excluded from bell panel; messenger badge is the sole UI channel
  • Email notifications for new messages (delayed, preference-controlled, via send-message-email edge function)
  • Search conversations (searches names, group members, conversation names, message previews)
  • Leave conversation (self-removal with cleanup)
  • Mobile responsive layout
  • Optimistic updates for realtime messages
  • Deep-link from notification click to specific conversation
  • iPhone-style emoji reactions (❤️ 👍 👎 😂 😮 😢) — click a message bubble to react, toggle to remove
  • Header messenger dropdown with conversation list, inline thread view, and unread badge

Planned (Not Yet Implemented)

  • Voice/video calls (buttons removed until implemented)
  • Typing indicators

Header Integration (Feb 15, 2026)

The Messenger is accessible directly from the header via a MessageSquare icon button (desktop only, hidden md:flex). This replaced the MintyAI dropdown.

MessengerDropdown behavior

  • List view (default): Shows all conversations sorted by unread-first, then most recent. Displays avatar, name, last message preview, unread badge, and relative timestamp.
  • Thread view: Click a conversation → sidebar collapses, hamburger (Menu) icon appears, message thread fills the panel. Full message input with attachments.
  • "+ New" button: Redirects to the full Messenger page via navigateTo('my-workspace', 'messenger').
  • Size: Same as the former MintyAI dropdown (w-80 sm:w-96 max-h-[400px]).
  • Unread badge: useUnreadMessageCount hook queries conversation_read_status with a realtime subscription for live updates. Includes 30s safety-net poll, visibility-change refetch, and reconnect recovery. Badge shows on the header icon.
  • NotificationPanel: new_message notifications are completely excluded from the bell notification system — they never enter the Zustand store, never show toasts, and are filtered at the DB query level. The messenger badge is the sole UI channel for message awareness.

Navigation

The Messenger is accessed via: 1. Header → Messenger icon (dropdown with inline messaging) 2. Sidebar → My Workspace → Messenger tile (full page) 3. Notification click → deep-links to specific conversation via navigationContext.conversationId

Back navigation returns to the Workspace hub.

Permissions

Users can only message other users within their organization scope (enforced by validate_messaging_permission and get_eligible_message_recipients):

  • Parent org admins (director, bookkeeper, assistant, custom) can message all users across all child organizations
  • Fund users can message users within their own fund and parent org admins

Email Notifications (Feb 11, 2026)

Users can opt in to receive email notifications when they get new messages in My Workspace. This is controlled via Settings → Notifications → Workspace Messages.

How it works

1. Trigger queues email: When a new message is inserted, notify_new_message() creates the in-app notification AND inserts a row into message_email_queue with send_after = NOW() + delay_minutes 2. Cron processes queue: pg_cron calls process-message-email-queue every 2 minutes, and the SQL command no-ops unless there is at least one due queue row 3. Read check: Before sending, the processor checks if the recipient has read the conversation in-app during the delay window. If yes, the email is silently skipped. 4. Email sent: If still unread, send-message-email edge function sends a branded email via Resend with a "View in Messenger" CTA

User preferences (Settings → Notifications → Workspace Messages)

| Setting | Default | Description |
|---------|---------|-------------|
| Enable message email alerts | ON | Master toggle for all message emails |
| Direct messages | ON | Email for 1:1 direct messages |
| Group messages | OFF | Email for group conversation messages |
| Delay before sending | 5 min | Wait time before sending (1–30 min) — gives user time to read in-app first |

Safeguards

  • Read check: Email skipped if user reads the conversation in-app before the delay expires
  • Rate limit: Max 5 message emails per user per hour
  • Dedup (trigger): Only one queue row per (recipient, conversation) at a time — newer messages update the existing row
  • Dedup (email): No duplicate emails for the same conversation within 10 minutes
  • Demo block: Demo accounts cannot send external emails
  • Global email toggle: Respects the user's global email channel on/off setting

Edge Functions

| Function | JWT | Purpose |
|----------|-----|--------|
| `send-message-email` | Yes | Sends a single message notification email via Resend |
| `process-message-email-queue` | Yes | Cron processor — polls `message_email_queue`, calls `send-message-email` for eligible rows |

Database

  • Table: message_email_queue — queue with send_after timestamp, UNIQUE on (recipient_id, conversation_id)
  • Cron: pg_cron job process-message-email-queue runs */2 * * * * (every 2 minutes) via pg_net, with an EXISTS gate so empty queues do not invoke the edge function
  • Migration: 20260211_message_email_notifications.sql

Unknown Names Fix (Feb 16, 2026)

1. Shared name resolver — Created resolveUserName() utility with fallback chain: first_name + last_nameemail"Unknown User". All Messenger hooks and components now use this single resolver instead of inline || 'Unknown' fallbacks. 2. Email fallback — All user queries in useConversations, useMessages, and useMessageReactions now fetch email alongside first_name/last_name so names are never blank when only email exists in the DB. 3. Error handling — Added console.warn for failed user/org/role queries in useConversations so silent failures are visible in DevTools instead of silently degrading all names to "Unknown". 4. Orphan thread handling — Direct conversations where the other participant was removed now show "Former Participant" instead of "Unknown". 5. Consistent fallback string — All fallback strings unified to "Unknown User" (was inconsistently "Unknown" across different files).

Files modified

  • src/features/workspace/utils/resolveUserName.ts (new)
  • src/features/workspace/hooks/useConversations.ts
  • src/features/workspace/hooks/useMessages.ts
  • src/features/workspace/hooks/useMessageReactions.ts
  • src/features/workspace/components/MessageBubble.tsx

Recent Fixes (Feb 2026)

1. Scroll issues fixed - Added overflow-hidden to container divs 2. Layout fixed - Added minHeight: 0 for proper flex behavior 3. Input pinning - Changed from sticky to flex-shrink-0 4. Removed placeholder buttons - Phone/video call buttons removed until feature is implemented

Messenger Dropdown & Emoji Reactions (Feb 15, 2026)

1. Header integration — Replaced MintyAI dropdown with MessengerDropdown in Header.tsx. MessageSquare icon with unread badge. 2. MessengerDropdown — Two-view component (list ↔ thread) with auto-collapsing sidebar, realtime updates, and inline messaging. 3. useUnreadMessageCount — Lightweight hook for header badge, realtime subscription on conversation_read_status. 4. Emoji reactionsmessage_reactions table (migration applied), useMessageReactions hook with optimistic updates and realtime, reaction picker on MessageBubble click, aggregated reaction pills below bubbles. 5. NotificationPanel filternew_message notifications filtered from bell panel, shown only in Messenger dropdown. 6. Bubble background fix — Received message bubbles changed from bg-muted to bg-accent for contrast in dark mode. 7. CSS utilities addedbg-primary/15, border-primary/40 in index.css. 8. Message delete button removed — Trash icon on hover removed from MessageBubble. Delete handlers and dialog removed from Messenger.tsx and MessageThread.tsx. 9. Emoji reactions bug fixes — Fixed pickerRef placement (was on inner bubble div, not outer wrapper — caused outside-click handler to close picker before emoji click registered). Fixed empty-array .in() crash in fetchReactions. Fixed stale closure in toggleReaction via ref. Added debounce + pending-mutation guard to prevent realtime refetch from stomping optimistic updates.

Unknown User Fix v2 — React Query Migration (Feb 2026)

Root Cause

The "Unknown User" bug was a transient data-fetching issue, not a data integrity problem. When Supabase user lookups failed transiently (network blip, RLS race), resolveUserName received null and correctly returned "Unknown User". The fix addresses the upstream fetch reliability.

Phase 1 — Retry Logic (immediate fix)

  • `addMessageFromRealtime` (useMessages.ts): Sender lookup now retries once after 500 ms before falling back to resolveUserName(null).
  • `fetchMessages` (useMessages.ts): If the PostgREST join returns null for sender, a direct users lookup with retry is attempted.
  • `fetchConversations` (useConversations.ts): Batch user query retries once on failure before proceeding with partial data.

Phase 2 — React Query Migration

All three messaging hooks now use @tanstack/react-query for automatic retries, caching, and shared state:

| Hook | Query Key | Retry | Stale Time |
|------|-----------|-------|------------|
| `useConversations` | `['messaging', 'conversations', userId]` | 2 × 1 s | 30 s |
| `useMessages` | `['messaging', 'messages', conversationId]` | 2 × 1 s | 30 s |
| `useMessageReactions` | `['messaging', 'reactions', conversationId]` | 2 × 1 s | 30 s |

Query key factory exported from useConversations.ts as messagingKeys:

export const messagingKeys = {
  all: ['messaging'] as const,
  conversations: (userId) => [...messagingKeys.all, 'conversations', userId],
  messages: (conversationId) => [...messagingKeys.all, 'messages', conversationId],
  reactions: (conversationId) => [...messagingKeys.all, 'reactions', conversationId],
};

Benefits:

  • Shared cache between Messenger and MessengerDropdown (no duplicate fetches)
  • Automatic retry on transient failures (the root cause of "Unknown User")
  • Optimistic updates via queryClient.setQueryData (same UX as before)
  • Stale-while-revalidate for instant UI on conversation switch

Phase 3 — i18n

All hardcoded user-facing strings in the Messenger components are now translated via the messenger i18n namespace:

  • Locale files: src/i18n/locales/{en,es,zh,de,fr,th}/messenger.json
  • Components updated: ConversationList, MessageThread, MessageBubble, MessengerDropdown
  • Uses { nsSeparator: '.' } config — keys accessed as t('messenger.keyName')

Phase 4 — Data Access Layer & Code Quality

  • `src/lib/db/messaging.ts` (new): Dedicated data access module with all Supabase queries for the Messenger feature. Every user-fetch function includes automatic retry. Hooks are now thin wrappers around these functions.
  • `formatTime` fix: Added isNaN guard for invalid date strings in MessengerDropdown.
  • `aria-labels`: Added translated aria-label attributes to interactive elements (back button, manage members, conversation items).

Files Modified

  • src/lib/db/messaging.ts (new)
  • src/features/workspace/hooks/useConversations.ts (React Query)
  • src/features/workspace/hooks/useMessages.ts (React Query)
  • src/features/workspace/hooks/useMessageReactions.ts (React Query)
  • src/features/workspace/components/ConversationList.tsx (i18n)
  • src/features/workspace/components/MessageThread.tsx (i18n)
  • src/features/workspace/components/MessageBubble.tsx (i18n)
  • src/features/workspace/components/MessengerDropdown.tsx (i18n + formatTime fix)
  • src/i18n/index.ts (messenger namespace)
  • src/i18n/locales/{en,es,zh,de,fr,th}/messenger.json (new, 6 files)

Notification Separation Fix (Feb 18, 2026)

Message notifications are now fully separated from the bell notification system. The messenger badge (useUnreadMessageCount) is the sole UI channel for message awareness.

Root Cause

1. conversation_read_status was missing from supabase_realtime publication — the header message badge never updated in real-time, only on component mount 2. new_message notifications were being added to the Zustand notification store and counted in the bell badge unreadCount, inflating the bell number with message counts 3. useUnreadMessageCount had no resilience mechanisms (no safety-net poll, no visibility-change refetch, no reconnect recovery)

Changes

1. DB migration — Added conversation_read_status to supabase_realtime publication (20260218_add_conversation_read_status_to_realtime.sql) 2. `useNotificationInit.ts`handleRealtimeNotification now returns early for new_message type — no store addition, no toast 3. `notifications.ts`fetchNotifications and fetchUnreadNotificationCount now filter .neq('type', 'new_message') at the DB query level 4. `notificationSlice.ts`unreadCount computation excludes new_message type as a safety net 5. `NotificationPanel.tsx` — Removed the useMemo filter for new_message (no longer needed since they never enter the store) 6. `useUnreadMessageCount.ts` — Added 30s safety-net poll, visibilitychange refetch, and reconnect recovery via wasConnectedRef

Files Modified

  • src/store/slices/notificationSlice.ts
  • src/hooks/useNotificationInit.ts
  • src/lib/db/notifications.ts
  • src/features/auth/components/NotificationPanel.tsx
  • src/features/workspace/hooks/useUnreadMessageCount.ts
  • backend/migrations/20260218_add_conversation_read_status_to_realtime.sql (new)

Chat Bubble & Initials Audit (Feb 25, 2026)

Problems

1. Initials showed first 2 chars of first name.slice(0, 2) on full name "Maribeth Carlton" → "MA" instead of "MC". Same for "Christy Nelson" → "CH" instead of "CN". 2. Avatar too close to message bubblegap-2 (8px) between avatar and bubble was cramped. 3. Avatar fallback had no visible background — No bg-* class on fallback, making initials float without a circle. 4. Group sender name low contrasttext-muted-foreground on bg-accent was hard to read in dark mode.

Fixes Applied

1. New `getInitials()` utility (src/features/workspace/utils/resolveUserName.ts):

  • Splits name on whitespace → first char of first word + first char of last word
  • Single-word name → first 2 chars (graceful fallback)
  • Null/empty → "?"

2. All 7 `.slice(0, 2)` sites replaced with `getInitials()`:

  • MessageBubble.tsx — received message avatar
  • ConversationList.tsx — conversation list avatars
  • MessageThread.tsx — header avatar, member list avatars (×2)
  • MessengerDropdown.tsx — list view avatar, thread view header avatar

3. Avatar fallback background — Added bg-muted-foreground/20 to all avatar fallbacks for a visible circle behind initials.

4. Bubble spacingMessageBubble.tsx gap increased from gap-2gap-3 (12px) between avatar and message.

5. Group sender name contrast — Changed from text-muted-foreground to text-foreground/70 for better readability on bg-accent.

Files Modified (5)

  • src/features/workspace/utils/resolveUserName.ts — Added getInitials() export
  • src/features/workspace/components/MessageBubble.tsx — Import, initials, gap, sender name color
  • src/features/workspace/components/ConversationList.tsx — Import, initials, fallback bg
  • src/features/workspace/components/MessageThread.tsx — Import, initials (×3), fallback bg
  • src/features/workspace/components/MessengerDropdown.tsx — Import, initials (×2), fallback bg

Build: Clean — 0 TypeScript errors

Full Audit (March 2026)

P0: Received message bubble color

  • Root cause: bg-accent in dark mode was nearly identical to the card background, making sender bubbles indistinguishable from the page.
  • Fix: Changed received bubble from bg-accent to bg-muted in MessageBubble.tsx for clear visual distinction.

P0: New message toast notification

  • Root cause: new_message notifications were intentionally excluded from the bell panel (useNotificationInit returns early), but no alternative toast was implemented. Users had no visual/audible alert when messages arrived while on other pages — only the badge number changed silently.
  • Fix: Added prevCountRef + toast logic in useUnreadMessageCount — when unread_count increases, fires a Sonner toast with "💬 New message" + "View" action button that navigates to Messenger.

P1: i18n sweep — Messenger.tsx

  • Wrapped ~15 hardcoded English strings with t() calls: empty state, error states (×3), delete/leave dialog, page titles, button labels.
  • Added useTranslation import.

P1: i18n sweep — useConversations.ts

  • Wrapped 7 hardcoded toast messages with t() calls: create conversation error, create group error, leave success/error, add/remove member success/error.
  • Added useTranslation import.

Email notification preferences — verified correct

  • send-message-email edge function properly checks: global email channel toggle, messaging category enabled, direct_messages preference, group_messages preference, rate limit (5/hour), dedup (10 min per conversation), demo block, read check (skips if user read in-app during delay).
  • DB trigger notify_new_message also respects preferences before queuing emails.
  • No changes needed.

Files Modified

1. src/features/workspace/components/MessageBubble.tsxbg-accentbg-muted on received bubbles 2. src/features/workspace/components/Messenger.tsx — Added useTranslation, wrapped ~15 hardcoded strings 3. src/features/workspace/hooks/useConversations.ts — Added useTranslation, wrapped 7 toast strings 4. src/features/workspace/hooks/useUnreadMessageCount.ts — Added toast notification on unread count increase 5. documentation/workspace/01-MESSENGER.md — This changelog

UI polish (Mar 20, 2026)

1. Message composer height after sendMessageInput uses the shared Textarea, which shipped with field-sizing: content and min-h-16, so the box often stayed multi-line tall after the value cleared. Fix: force field-sizing: fixed (inline + class), !min-h-[36px], reset height in useLayoutEffect when content === '', and double-requestAnimationFrame after send so the DOM updates after React commits. 2. Conversation list spacing — Increased horizontal gap between avatars and name/preview text in ConversationList (gap-3.5) and MessengerDropdown list rows (gap-4) so copy isn’t tight against profile photos. 3. Symmetric avatars in threadMessageBubble shows the peer’s avatar to the left of incoming bubbles and the current user’s avatar to the right of outgoing bubbles (same message.sender shape from messaging.ts; optimistic sends already include the sender). 4. Reaction affordance hover (sent = received) — Global globals.css rules were lifting/shadowing every .bg-primary on hover, so only *your* bubbles looked “clickable.” Incoming bubbles use bg-muted and had no equivalent. Fix: exclude .message-bubble from those global .bg-primary:hover / :active rules and apply one shared lift + shadow + dark-mode glow/brightness stack in MessageBubble for both roles. MessengerDropdown already renders MessageBubble, so header messenger matches the full-page thread.

Files touched

  • src/features/workspace/components/MessageInput.tsx
  • src/features/workspace/components/ConversationList.tsx
  • src/features/workspace/components/MessengerDropdown.tsx
  • src/features/workspace/components/MessageBubble.tsx
  • src/styles/globals.css

Audit Fixes (Feb 7, 2026)

1. Real-time messaging fixed - messages table was missing from supabase_realtime publication; real-time was completely non-functional 2. Add participant fixed - Replaced raw table inserts with add_conversation_participant SECURITY DEFINER RPC; fixes RLS 403 on conversation_read_status, wrong organization_id, and missing type upgrade 3. Remove participant fixed - Replaced raw delete with remove_conversation_participant RPC; now cleans up read_status, handles last-person cascade delete, and reverts group→direct 4. Leave conversation - "Delete Conversation" renamed to "Leave Conversation"; uses same remove RPC for self-removal 5. Group display name - Now uses conversation_name when available instead of always auto-generating 6. Group search - ConversationList search now matches all participant names and conversation name 7. Storage bucket created - message-attachments private bucket with RLS policies for upload/read/delete 8. Data repair - Fixed 2 conversations stuck as type='direct' with 3 participants; created missing read_status rows, corrected wrong organization_id

Contextual Message Timestamps (Mar 24, 2026)

Implemented compact, contextual timestamping in both Messenger surfaces (full page thread + header dropdown thread) to answer the "time/date stamped messages" feedback without adding visual noise.

UX behavior

1. Per-message time (tiny) — Every message now shows a compact time label (h:mm AM/PM) in very small muted text below the bubble. 2. Contextual date separators — A thin centered separator appears when the day changes:

3. Visual scale — Timestamp and separator labels use text-[10px] so they remain present but unobtrusive.

  • Today
  • Yesterday
  • Mar 24 (same year)
  • Mar 24, 2025 (different year)

Standards alignment

1. Date/time consistency (Playbook §22) — Added shared formatter helpers in src/lib/dateUtils.ts (formatChatTime, formatChatDayLabel, getLocalDayKey) so Messenger does not rely on ad-hoc locale formatting in components. 2. i18n coverage — Added today and yesterday keys in all Messenger locale files (en, es, zh, de, fr, th, ne).

Files touched

  • src/lib/dateUtils.ts
  • src/features/workspace/components/MessageBubble.tsx
  • src/features/workspace/components/MessageThread.tsx
  • src/features/workspace/components/MessengerDropdown.tsx
  • src/i18n/locales/en/messenger.json
  • src/i18n/locales/es/messenger.json
  • src/i18n/locales/zh/messenger.json
  • src/i18n/locales/de/messenger.json
  • src/i18n/locales/fr/messenger.json
  • src/i18n/locales/th/messenger.json
  • src/i18n/locales/ne/messenger.json

Audit hardening (Jun 14, 2026)

Addressed deep-audit findings across Messenger frontend + Supabase RPCs/policies.

Frontend/runtime fixes

1. Attachment send reliability + limit alignment

  • uploadMessageAttachment now enforces 50MB (was 10MB) to match UI + storage policy.
  • Added image/svg+xml to allowed upload MIME list.
  • Message send no longer silently drops failed uploads. If any selected file upload fails, send fails with a surfaced error and already uploaded files are cleaned up.
  • If DB attachment insert fails after message row creation, code now performs best-effort cleanup and soft-deletes the orphaned message.

2. Dropdown thread pagination

  • Fixed load-more jump: loading older messages from top now preserves viewport anchor instead of auto-snapping to bottom.
  • Auto-scroll to bottom still occurs for initial thread open and for new messages when the user is already near the bottom.

3. Realtime delete handling

  • Fixed useRealtimeMessages guard so DELETE events are processed correctly (payload.old path now reachable).

4. Reaction realtime scope

  • useMessageReactions no longer invalidates on every global reaction event.
  • Realtime reaction invalidation now checks whether the changed message_id belongs to the current loaded thread cache before refetching.

5. Read-status error surfacing

  • markConversationRead now throws on Supabase upsert errors instead of silently swallowing failures.

Backend / SQL hardening

Migration: supabase/migrations/20260614120000_messenger_audit_hardening.sql

1. `validate_messaging_permission` scope tightened

  • Non-fund_user roles are now constrained to sender org tree (same org, children, and sender parent tree when applicable), replacing the previous permissive RETURN TRUE.

2. `create_conversation_with_read_status` restored to junction-table model

  • Recreated against conversation_participants schema (no legacy participant_1_id/participant_2_id assumptions).
  • Reuses existing direct conversations with exactly 2 participants and ensures read-status rows exist.

3. `remove_conversation_participant` historical-message guard

  • Prevents automatic group→direct downgrade when removed participant still has historical messages in the thread.
  • Keeps conversation as group and refreshes group name to preserve accurate sender attribution.

Synced from IFMmvp-Frontend documentation: workspace/01-MESSENGER.md

Ready to Get Started?

See how Alignmint can simplify your nonprofit's operations. Schedule a free demo with our team and we'll walk you through everything.

Questions? Email us at steven@getalignmint.org

Ready to get started?Start Plus Trial