upLegal’s messaging system enables direct communication between clients and lawyers. Conversations persist across sessions and support real-time updates.
Messaging Overview
The platform uses a conversation-based messaging system:
One-on-one chats with individual lawyers
Group conversations for team consultations
Real-time message delivery
Read receipts and delivery status
Message history preserved
Message Features
Real-Time Messages appear instantly using Supabase real-time subscriptions
Persistent Full message history stored and accessible anytime
Status Indicators See when messages are sent, delivered, and read
Attachments Share documents and images with lawyers
Starting a Conversation
Clients can initiate conversations in two ways:
From Lawyer Profile
Visit Lawyer Profile
Click on a lawyer’s card from search results or navigate to their profile page.
Click Contact Button
Find the “Contactar” or “Enviar mensaje” button on the profile.
Authentication Check
If not logged in, you’ll be prompted to sign in or create an account.
Conversation Created
A new conversation is created with the lawyer. You can immediately start typing.
After Booking
Once a consultation is booked (even before it occurs), a conversation is automatically created between client and lawyer.
Message Context System
The messaging system uses React Context for state management:
// src/contexts/MessageProvider.tsx:62-89
export function MessageProvider ({ children } : { children : ReactNode }) {
const { user } = useAuth ();
const [ conversations , setConversations ] = useState < Conversation []>([]);
const [ currentConversation , setCurrentConversation ] = useState < Conversation | null >( null );
const [ messages , setMessages ] = useState < Message []>([]);
const [ isLoading , setIsLoading ] = useState ( false );
const [ error , setError ] = useState < string | null >( null );
const currentUser = useMemo < MessageUser | null >(() => {
if ( ! user ) return null ;
return {
id: user . id ,
name: user . user_metadata ?. full_name || user . user_metadata ?. name || user . email ?. split ( '@' )[ 0 ] || 'User' ,
email: user . email || undefined ,
avatarUrl: user . user_metadata ?. avatar_url ,
role: user . role || 'client' ,
isOnline: true
};
}, [ user ]);
// ... sendMessage, createConversation, etc.
}
Conversation List
Clients access their conversations from the dashboard:
Conversation Display
Lawyer’s name and avatar
Last message preview
Timestamp of last message
Unread message count badge
Online/offline indicator
Sorting
Most recent conversations appear first
Updated whenever a new message is sent/received
Mock Data Example
// src/contexts/MessageProvider.tsx:100-136
const mockConversations : Conversation [] = [
createConversation (
'1' ,
[
currentUser ,
{
id: 'user2' ,
name: 'María González' ,
email: '[email protected] ' ,
role: 'lawyer' ,
isOnline: true
}
],
false
),
createConversation (
'2' ,
[
currentUser ,
{
id: 'user3a' ,
name: 'Ana López' ,
email: '[email protected] ' ,
role: 'lawyer' ,
isOnline: false
},
{
id: 'user3b' ,
name: 'Carlos Mendez' ,
email: '[email protected] ' ,
role: 'lawyer' ,
isOnline: true
}
],
true ,
'Equipo Legal'
)
];
The code currently uses mock data for demonstration. In production, conversations are fetched from the conversations and messages tables in Supabase.
Chat Window
The chat interface displays the conversation:
// src/components/messages/ChatWindow.tsx:12-82
export function ChatWindow ({ conversationId } : ChatWindowProps ) {
const { messages , isLoading , currentConversation , fetchMessages } = useMessages ();
const { user } = useAuth ();
// Fetch messages when conversationId changes
useEffect (() => {
if ( conversationId ) {
fetchMessages ( conversationId );
}
}, [ conversationId , fetchMessages ]);
const conversationMessages = messages . filter (
message => message . conversationId === conversationId
);
return (
< div className = "flex flex-col h-full" >
{ /* Messages Container */ }
< div className = "flex-1 overflow-y-auto p-4 space-y-4" >
{ conversationMessages . map (( message ) => (
< div
key = {message. id }
className = { `flex ${ message . senderId === user ?. id ? 'justify-end' : 'justify-start' } ` }
>
< div
className = { `max-w-[80%] rounded-lg px-4 py-2 ${
message . senderId === user ?. id
? 'bg-primary text-primary-foreground rounded-br-none'
: 'bg-muted rounded-bl-none'
} ` }
>
< p className = "break-words" > {message. content } </ p >
< p className = "text-xs mt-1 text-right" >
{ formatDistanceToNow ( new Date ( message . timestamp ), {
addSuffix : true ,
locale : es
})}
</ p >
</ div >
</ div >
))}
</ div >
{ /* Message Input */ }
< div className = "border-t p-4 bg-background" >
< MessageInput conversationId = { conversationId } />
</ div >
</ div >
);
}
Layout
Client messages: Right-aligned, blue background
Lawyer messages: Left-aligned, gray background
Timestamps: Below each message in relative format (“hace 5 minutos”)
Auto-scroll to latest message
Sending Messages
The message input component handles message composition:
// src/components/messages/MessageInput.tsx:10-61
export function MessageInput ({ conversationId } : MessageInputProps ) {
const [ message , setMessage ] = useState ( '' );
const [ isSending , setIsSending ] = useState ( false );
const { sendMessage } = useMessages ();
const handleSubmit = async ( e : React . FormEvent ) => {
e . preventDefault ();
const msg = message . trim ();
if ( ! msg || isSending ) return ;
setIsSending ( true );
try {
await sendMessage ({
conversationId ,
content: msg ,
});
setMessage ( '' );
} catch ( error ) {
console . error ( 'Error sending message:' , error );
} finally {
setIsSending ( false );
}
};
return (
< form onSubmit = { handleSubmit } className = "flex gap-2 w-full" >
< Input
type = "text"
placeholder = "Escribe un mensaje..."
value = { message }
onChange = {(e) => setMessage (e.target.value)}
disabled = { isSending }
/>
< Button
type = "submit"
disabled = {!message.trim() || isSending }
>
{ isSending ? 'Enviando...' : 'Enviar' }
</ Button >
</ form >
);
}
Features
Text input with placeholder
Submit on Enter key
Send button disabled when empty
Loading state while sending
Input cleared after successful send
Error handling with console logging
Message Status
Messages have three states:
Message appears in chat with “Enviando…” indicator
Gray color or spinner icon
Not yet saved to database
Optimistic UI update
Message saved to database
Single checkmark icon
Visible to recipient
Can be loaded in future sessions
Recipient has opened the conversation
Double checkmark icon
Updates unreadCount in conversation
Triggers read receipt notification
// Message status type definition
type MessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed' ;
interface Message {
id : string ;
content : string ;
senderId : string ;
conversationId : string ;
status : MessageStatus ;
timestamp : Date ;
attachments ?: Attachment [];
}
Lawyers can set a contact fee for initial messages:
// src/contexts/MessageProvider.tsx:230-303
const sendMessage = useCallback ( async ( payload : Omit < SendMessagePayload , 'senderId' >) => {
if ( ! currentUser ) throw new Error ( 'User not authenticated' );
const conversation = conversations . find ( c => c . id === payload . conversationId );
if ( ! conversation ) throw new Error ( 'Conversation not found' );
const lawyer = conversation . participants . find ( p => p . role === 'lawyer' );
if ( ! lawyer ) throw new Error ( 'Lawyer not found in conversation' );
// Check if it's the first message from this user
const isFirstMessage = await isFirstMessageInConversation ( payload . conversationId , currentUser . id );
// If not the first message, check if payment is required
if ( ! isFirstMessage ) {
const contactFee = await getLawyerContactFee ( lawyer . id );
if ( contactFee > 0 ) {
// Check if payment is already made for this conversation
const { data : payment , error } = await supabase
. from ( 'payments' )
. select ( 'id, status' )
. eq ( 'conversation_id' , payload . conversationId )
. eq ( 'user_id' , currentUser . id )
. eq ( 'type' , 'contact_fee' )
. single ();
if ( error || ! payment || payment . status !== 'succeeded' ) {
// Redirect to payment page
window . location . href = `/checkout?type=contact_fee&conversation_id= ${ payload . conversationId } &amount= ${ contactFee } &lawyer_id= ${ lawyer . id } ` ;
return ;
}
}
}
// ... continue with sending message
}, [ currentUser ]);
Flow
Client sends first message: Free, no payment required
Lawyer responds
Client tries to send second message:
Check if lawyer has contact_fee_clp set
If yes, check payment status
If not paid, redirect to payment page
After payment, all messages are free
Contact fees are one-time payments per conversation. Once paid, clients can message the lawyer unlimited times within that conversation.
Real-Time Updates
Messages use Supabase real-time subscriptions:
// Pseudocode for real-time subscription
const channel = supabase
. channel ( 'messages' )
. on (
'postgres_changes' ,
{
event: 'INSERT' ,
schema: 'public' ,
table: 'messages' ,
filter: `conversation_id=eq. ${ conversationId } `
},
( payload ) => {
setMessages ( prev => [ ... prev , payload . new ]);
}
)
. subscribe ();
Benefits
No polling required
Instant message delivery
Battery efficient
Scales to thousands of users
Unread Count
Conversations track unread messages:
// src/contexts/MessageProvider.tsx:318-332
// Update conversation's last message and timestamp
setConversations ( prev =>
prev . map ( conv =>
conv . id === payload . conversationId
? {
... conv ,
lastMessage: newMessage ,
updatedAt: new Date (),
// Increment unread count for other participants
unreadCount: currentConversationIdRef . current === payload . conversationId
? 0
: conv . unreadCount + 1
}
: conv
)
);
Logic
If conversation is currently open: unreadCount = 0
If conversation is in background: unreadCount++
Badge displayed on conversation list item
Cleared when conversation is opened
Mark as Read
// src/contexts/MessageProvider.tsx:464-472
const markAsRead = useCallback (( conversationId : string ) => {
setConversations ( prev =>
prev . map ( conv =>
conv . id === conversationId
? { ... conv , unreadCount: 0 }
: conv
)
);
}, []);
Called when:
Client opens a conversation
Client sends a message in the conversation
Window gains focus while conversation is open
Group Conversations
For team consultations, group chats support multiple lawyers:
// src/contexts/MessageProvider.tsx:16-48
interface Conversation {
id : string ;
participants : MessageUser [];
unreadCount : number ;
isGroup : boolean ;
groupName ?: string ;
createdAt : Date ;
updatedAt : Date ;
lastMessage ?: Message ;
}
const createConversation = (
id : string ,
participants : MessageUser [],
isGroup : boolean = false ,
groupName ?: string
) : Conversation => ({
id ,
participants ,
unreadCount: 0 ,
isGroup ,
groupName ,
createdAt: new Date (),
updatedAt: new Date (),
lastMessage: undefined
});
Group Features
Custom group name (e.g., “Equipo Legal”)
Multiple participants
Shared message thread
All participants see all messages
Typing indicators for multiple users
Error Handling
Send Failed
Connection Lost
Conversation Not Found
If message send fails: // src/contexts/MessageProvider.tsx:378-396
catch ( err ) {
console . error ( 'Failed to send message:' , err );
setError ( 'Failed to send message' );
// Revert on error
setMessages ( prev => prev . filter ( msg => msg . id !== newMessage . id ));
// Revert conversation update
setConversations ( prev =>
prev . map ( conv =>
conv . id === payload . conversationId && conv . lastMessage ?. id === newMessage . id
? {
... conv ,
lastMessage: undefined ,
updatedAt: new Date ( conv . updatedAt . getTime () - 1 )
}
: conv
)
);
}
Message removed from UI
Error message displayed
User can retry sending
Messages queued locally
Auto-retry when connection restored
Warning banner shown
Error toast displayed
Redirect to conversation list
Suggest refreshing
Privacy & Security
Encrypted Transit All messages encrypted with TLS in transit
Database Security Row-level security ensures users only see their conversations
Content Moderation Automated filtering for inappropriate content
Reporting Users can report messages for review
Mobile Experience
The chat interface is fully responsive:
Full-screen on mobile devices
Swipe gestures to go back
Native-like scrolling
Keyboard auto-shows when focused
Input stays above keyboard
Future Features
File Attachments Send PDFs, images, and documents
Voice Messages Record and send audio clips
Typing Indicators See when the other person is typing
Message Search Search message history
Message Reactions React to messages with emojis
Push Notifications Get notified of new messages
Best Practices
For Clients:
Be clear and concise in messages
Provide all relevant details upfront
Use messaging for quick questions; book consultations for detailed advice
Respect lawyer response times (typically within 24 hours)
Messages are not a substitute for formal legal consultations. For detailed legal advice, book a video consultation.