feat: Implement conversation mute and favorite features with UI, API, and state management.
This commit is contained in:
@@ -14,9 +14,13 @@ interface ChatHeaderProps {
|
|||||||
expertise?: string[];
|
expertise?: string[];
|
||||||
isTyping?: boolean;
|
isTyping?: boolean;
|
||||||
typingText?: string;
|
typingText?: string;
|
||||||
|
isMuted?: boolean;
|
||||||
|
isStarred?: boolean;
|
||||||
onClearChat?: () => void;
|
onClearChat?: () => void;
|
||||||
onDeleteConversation?: () => void;
|
onDeleteConversation?: () => void;
|
||||||
onReportUser?: () => void;
|
onReportUser?: () => void;
|
||||||
|
onToggleMute?: (muted: boolean) => void;
|
||||||
|
onToggleStar?: (starred: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
@@ -29,13 +33,15 @@ export function ChatHeader({
|
|||||||
expertise = [],
|
expertise = [],
|
||||||
isTyping = false,
|
isTyping = false,
|
||||||
typingText,
|
typingText,
|
||||||
|
isMuted = false,
|
||||||
|
isStarred = false,
|
||||||
onClearChat,
|
onClearChat,
|
||||||
onDeleteConversation,
|
onDeleteConversation,
|
||||||
onReportUser,
|
onReportUser,
|
||||||
|
onToggleMute,
|
||||||
|
onToggleStar,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const [isStarred, setIsStarred] = useState(false);
|
|
||||||
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
const [avatarLoaded, setAvatarLoaded] = useState(false);
|
||||||
|
|
||||||
const placeholder = '/assets/icons/user-placeholder-icon.svg';
|
const placeholder = '/assets/icons/user-placeholder-icon.svg';
|
||||||
@@ -48,7 +54,7 @@ export function ChatHeader({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: isMuted ? 'Unmute Notifications' : 'Mute Notifications',
|
label: isMuted ? 'Unmute Notifications' : 'Mute Notifications',
|
||||||
onClick: () => setIsMuted((prev) => !prev),
|
onClick: () => onToggleMute?.(!isMuted),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Report User',
|
label: 'Report User',
|
||||||
@@ -118,7 +124,7 @@ export function ChatHeader({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsStarred((prev) => !prev)}
|
onClick={() => onToggleStar?.(!isStarred)}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{isStarred ? (
|
{isStarred ? (
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export function MessagingPage({ initialConversationId }: { initialConversationId
|
|||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
deleteConversation,
|
deleteConversation,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
toggleMute,
|
||||||
|
toggleFavorite,
|
||||||
} = useMessaging();
|
} = useMessaging();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -796,9 +798,17 @@ export function MessagingPage({ initialConversationId }: { initialConversationId
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{(userRole === 'AGENT' ? conversation.agentFavorited : conversation.userFavorited) && (
|
||||||
|
<Image src="/assets/icons/star-filled-icon.svg" alt="Starred" width={14} height={14} className="flex-shrink-0" />
|
||||||
|
)}
|
||||||
<p className="font-fractul font-medium text-[14px] leading-[17px] text-[#00293D]">
|
<p className="font-fractul font-medium text-[14px] leading-[17px] text-[#00293D]">
|
||||||
{conversation.otherParty?.name || 'Unknown'}
|
{conversation.otherParty?.name || 'Unknown'}
|
||||||
</p>
|
</p>
|
||||||
|
{(userRole === 'AGENT' ? conversation.agentMuted : conversation.userMuted) && (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="flex-shrink-0 text-[#00293D]/40">
|
||||||
|
<path d="M11 5L6 9H2v6h4l5 4V5zM23 9l-6 6M17 9l6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
{conversation.unreadCount > 0 && (
|
{conversation.unreadCount > 0 && (
|
||||||
<span className="bg-[#e58625] text-white text-xs font-bold px-2 py-0.5 rounded-full min-w-[20px] text-center">
|
<span className="bg-[#e58625] text-white text-xs font-bold px-2 py-0.5 rounded-full min-w-[20px] text-center">
|
||||||
{conversation.unreadCount}
|
{conversation.unreadCount}
|
||||||
@@ -829,9 +839,13 @@ export function MessagingPage({ initialConversationId }: { initialConversationId
|
|||||||
{...selectedUserInfo}
|
{...selectedUserInfo}
|
||||||
isTyping={typingUserNames.length > 0}
|
isTyping={typingUserNames.length > 0}
|
||||||
typingText={typingUserNames.length > 0 ? `${typingUserNames.join(', ')} is typing...` : undefined}
|
typingText={typingUserNames.length > 0 ? `${typingUserNames.join(', ')} is typing...` : undefined}
|
||||||
|
isMuted={userRole === 'AGENT' ? (currentConversation?.agentMuted || false) : (currentConversation?.userMuted || false)}
|
||||||
|
isStarred={userRole === 'AGENT' ? (currentConversation?.agentFavorited || false) : (currentConversation?.userFavorited || false)}
|
||||||
onClearChat={clearMessages}
|
onClearChat={clearMessages}
|
||||||
onDeleteConversation={handleDeleteConversation}
|
onDeleteConversation={handleDeleteConversation}
|
||||||
onReportUser={() => setIsReportModalOpen(true)}
|
onReportUser={() => setIsReportModalOpen(true)}
|
||||||
|
onToggleMute={(muted) => currentConversation && toggleMute(currentConversation.id, muted)}
|
||||||
|
onToggleStar={(starred) => currentConversation && toggleFavorite(currentConversation.id, starred)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Messages area wrapper */}
|
{/* Messages area wrapper */}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ interface UseMessagingReturn {
|
|||||||
deleteConversation: (conversationId: string) => Promise<void>;
|
deleteConversation: (conversationId: string) => Promise<void>;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
updateUserStatus: (userId: string, isOnline: boolean) => void;
|
updateUserStatus: (userId: string, isOnline: boolean) => void;
|
||||||
|
toggleMute: (conversationId: string, muted: boolean) => Promise<void>;
|
||||||
|
toggleFavorite: (conversationId: string, favorited: boolean) => Promise<void>;
|
||||||
hasMoreMessages: boolean;
|
hasMoreMessages: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +374,59 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Toggle mute status (only for current user's side)
|
||||||
|
const toggleMute = useCallback(async (conversationId: string, muted: boolean) => {
|
||||||
|
try {
|
||||||
|
await messagesService.toggleMute(conversationId, muted);
|
||||||
|
// Backend sets the correct field based on caller's role;
|
||||||
|
// optimistically update both fields since we don't know which one the server set,
|
||||||
|
// but the API response and next fetch will have the correct values.
|
||||||
|
// For the current user's perspective, both fields reflect "is this muted for me"
|
||||||
|
const update = (c: Conversation) => {
|
||||||
|
if (c.id !== conversationId) return c;
|
||||||
|
// Determine which field to update based on the user's role in the conversation
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
const isUser = c.userId === userId;
|
||||||
|
return isUser
|
||||||
|
? { ...c, userMuted: muted }
|
||||||
|
: { ...c, agentMuted: muted };
|
||||||
|
};
|
||||||
|
setConversations((prev) => prev.map(update));
|
||||||
|
setCurrentConversation((prev) => prev ? update(prev) : prev);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle mute:', err);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
// Toggle favorite status (only for current user's side)
|
||||||
|
const toggleFavorite = useCallback(async (conversationId: string, favorited: boolean) => {
|
||||||
|
try {
|
||||||
|
await messagesService.toggleFavorite(conversationId, favorited);
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
const update = (c: Conversation) => {
|
||||||
|
if (c.id !== conversationId) return c;
|
||||||
|
const isUser = c.userId === userId;
|
||||||
|
return isUser
|
||||||
|
? { ...c, userFavorited: favorited }
|
||||||
|
: { ...c, agentFavorited: favorited };
|
||||||
|
};
|
||||||
|
setConversations((prev) => {
|
||||||
|
const updated = prev.map(update);
|
||||||
|
return updated.sort((a, b) => {
|
||||||
|
const aFav = a.userId === userId ? a.userFavorited : a.agentFavorited;
|
||||||
|
const bFav = b.userId === userId ? b.userFavorited : b.agentFavorited;
|
||||||
|
if (aFav !== bFav) return aFav ? -1 : 1;
|
||||||
|
const aTime = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
||||||
|
const bTime = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setCurrentConversation((prev) => prev ? update(prev) : prev);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle favorite:', err);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
// Update user online status (called from status change events)
|
// Update user online status (called from status change events)
|
||||||
const updateUserStatus = useCallback((userId: string, isOnline: boolean) => {
|
const updateUserStatus = useCallback((userId: string, isOnline: boolean) => {
|
||||||
setConversations((prev) =>
|
setConversations((prev) =>
|
||||||
@@ -571,6 +626,8 @@ export function useMessaging(options: UseMessagingOptions = {}): UseMessagingRet
|
|||||||
deleteConversation,
|
deleteConversation,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
updateUserStatus,
|
updateUserStatus,
|
||||||
|
toggleMute,
|
||||||
|
toggleFavorite,
|
||||||
hasMoreMessages,
|
hasMoreMessages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ class MessagesService {
|
|||||||
const response = await api.get<ApiResponse<{ unreadCount: number }>>('/messages/unread-count');
|
const response = await api.get<ApiResponse<{ unreadCount: number }>>('/messages/unread-count');
|
||||||
return response.data.data.unreadCount;
|
return response.data.data.unreadCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle mute status for a conversation
|
||||||
|
*/
|
||||||
|
async toggleMute(conversationId: string, muted: boolean): Promise<void> {
|
||||||
|
await api.patch(`/messages/conversations/${conversationId}/mute`, { muted });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle favorite/star status for a conversation
|
||||||
|
*/
|
||||||
|
async toggleFavorite(conversationId: string, favorited: boolean): Promise<void> {
|
||||||
|
await api.patch(`/messages/conversations/${conversationId}/favorite`, { favorited });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messagesService = new MessagesService();
|
export const messagesService = new MessagesService();
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface Conversation {
|
|||||||
userUnreadCount: number;
|
userUnreadCount: number;
|
||||||
agentUnreadCount: number;
|
agentUnreadCount: number;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
|
userMuted: boolean;
|
||||||
|
agentMuted: boolean;
|
||||||
|
userFavorited: boolean;
|
||||||
|
agentFavorited: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
otherParty: OtherParty;
|
otherParty: OtherParty;
|
||||||
|
|||||||
Reference in New Issue
Block a user