From 5cc3d7b181b3850f3b4b2b43485da77f061f64b5 Mon Sep 17 00:00:00 2001 From: senke Date: Sat, 21 Feb 2026 05:22:52 +0100 Subject: [PATCH] feat(presence): PresenceBadge and display (P1.3) --- .../components/chat-sidebar/ChatSidebar.tsx | 33 +++++++++------ .../chat-sidebar/ConversationItem.tsx | 10 ++++- .../chat/components/chat-sidebar/types.ts | 2 + .../presence/components/PresenceBadge.tsx | 42 +++++++++++++++++++ .../features/presence/hooks/usePresence.ts | 16 +++++++ apps/web/src/features/presence/index.ts | 5 +++ .../presence/services/presenceService.ts | 19 +++++++++ apps/web/src/mocks/handlers-misc.ts | 12 ++++++ apps/web/src/services/api/users.ts | 7 ++++ 9 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/features/presence/components/PresenceBadge.tsx create mode 100644 apps/web/src/features/presence/hooks/usePresence.ts create mode 100644 apps/web/src/features/presence/index.ts create mode 100644 apps/web/src/features/presence/services/presenceService.ts diff --git a/apps/web/src/features/chat/components/chat-sidebar/ChatSidebar.tsx b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebar.tsx index 975f99fa5..2d8375ef5 100644 --- a/apps/web/src/features/chat/components/chat-sidebar/ChatSidebar.tsx +++ b/apps/web/src/features/chat/components/chat-sidebar/ChatSidebar.tsx @@ -65,19 +65,26 @@ export const ChatSidebar: React.FC = () => { {conversations.length === 0 ? ( ) : ( - conversations.map((conv) => ( - setCurrentConversation(id)} - isSelected={conv.id === currentConversationId} - /> - )) + conversations.map((conv) => { + const otherParticipantId = + conv.type === 'direct' && userId + ? conv.participants?.find((p) => p !== userId) + : undefined; + return ( + setCurrentConversation(id)} + isSelected={conv.id === currentConversationId} + /> + ); + }) )} diff --git a/apps/web/src/features/chat/components/chat-sidebar/ConversationItem.tsx b/apps/web/src/features/chat/components/chat-sidebar/ConversationItem.tsx index 7d46478bd..251be7dc8 100644 --- a/apps/web/src/features/chat/components/chat-sidebar/ConversationItem.tsx +++ b/apps/web/src/features/chat/components/chat-sidebar/ConversationItem.tsx @@ -11,6 +11,7 @@ import { import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; import { Hash, LogOut, MoreVertical, Trash2 } from 'lucide-react'; import { Avatar } from '@/components/ui/avatar'; +import { usePresence } from '@/features/presence'; import { useConversationActions } from './useConversationActions'; import { cn } from '@/lib/utils'; import type { ConversationItemData } from './types'; @@ -37,6 +38,7 @@ function ConversationItemInner({ isSelected, }: ConversationItemProps) { const { data: user } = useUser(); + const { data: presence } = usePresence(conversation.participantId); const [showLeaveDialog, setShowLeaveDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [mutationError, setMutationError] = useState(null); @@ -118,7 +120,13 @@ function ConversationItemInner({ ) : (
= { + online: 'bg-success', + away: 'bg-warning', + busy: 'bg-destructive', + offline: 'bg-muted', +}; + +const sizeClasses = { + sm: 'w-2 h-2', + md: 'w-2.5 h-2.5', + lg: 'w-3 h-3', +}; + +export function PresenceBadge({ status, size = 'md', className }: PresenceBadgeProps) { + return ( + + ); +} diff --git a/apps/web/src/features/presence/hooks/usePresence.ts b/apps/web/src/features/presence/hooks/usePresence.ts new file mode 100644 index 000000000..8bbd5d310 --- /dev/null +++ b/apps/web/src/features/presence/hooks/usePresence.ts @@ -0,0 +1,16 @@ +/** + * usePresence hook (v0.301 Lot P1) + * Fetches and caches user presence for display in chat/social + */ + +import { useQuery } from '@tanstack/react-query'; +import { getPresence } from '../services/presenceService'; + +export function usePresence(userId: string | null | undefined) { + return useQuery({ + queryKey: ['presence', userId], + queryFn: () => getPresence(userId!), + enabled: !!userId, + staleTime: 30_000, // 30s - presence doesn't change that often + }); +} diff --git a/apps/web/src/features/presence/index.ts b/apps/web/src/features/presence/index.ts new file mode 100644 index 000000000..fe3730d75 --- /dev/null +++ b/apps/web/src/features/presence/index.ts @@ -0,0 +1,5 @@ +export { getPresence } from './services/presenceService'; +export type { UserPresence } from './services/presenceService'; +export { usePresence } from './hooks/usePresence'; +export { PresenceBadge } from './components/PresenceBadge'; +export type { PresenceBadgeProps, PresenceStatus } from './components/PresenceBadge'; diff --git a/apps/web/src/features/presence/services/presenceService.ts b/apps/web/src/features/presence/services/presenceService.ts new file mode 100644 index 000000000..adfa8900c --- /dev/null +++ b/apps/web/src/features/presence/services/presenceService.ts @@ -0,0 +1,19 @@ +/** + * Presence API Service (v0.301 Lot P1) + * Fetches user presence (online/away/offline, last_seen_at) + */ + +import { apiClient } from '@/services/api/client'; + +export interface UserPresence { + user_id: string; + status: 'online' | 'away' | 'busy' | 'offline'; + last_seen_at: string | null; + status_message: string | null; +} + +export async function getPresence(userId: string): Promise { + const response = await apiClient.get(`/users/${userId}/presence`); + const data = (response.data as { data?: UserPresence })?.data ?? response.data; + return data as UserPresence; +} diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts index 7a50b2441..0b4c63b94 100644 --- a/apps/web/src/mocks/handlers-misc.ts +++ b/apps/web/src/mocks/handlers-misc.ts @@ -394,6 +394,18 @@ export const handlersMisc = [ }); }), + http.get('*/api/v1/users/:id/presence', ({ params }) => { + return HttpResponse.json({ + success: true, + data: { + user_id: params.id, + status: 'online', + last_seen_at: new Date().toISOString(), + status_message: null, + }, + }); + }), + http.post('*/api/v1/users/:id/follow', () => HttpResponse.json({ success: true })), http.delete('*/api/v1/users/:id/follow', () => HttpResponse.json({ success: true })), http.post('*/api/v1/users/:id/block', () => HttpResponse.json({ success: true })), diff --git a/apps/web/src/services/api/users.ts b/apps/web/src/services/api/users.ts index eb755248c..fcd5955e3 100644 --- a/apps/web/src/services/api/users.ts +++ b/apps/web/src/services/api/users.ts @@ -32,6 +32,7 @@ import { deleteAvatar, type UploadAvatarResponse, } from '@/features/profile/services/avatarService'; +import { getPresence, type UserPresence } from '@/features/presence'; /** * Users API Service Object @@ -97,6 +98,11 @@ export const usersApi = { * Delete user avatar */ deleteAvatar, + + /** + * Get user presence (v0.301 Lot P1) + */ + getPresence, }; // Re-export types for convenience @@ -110,4 +116,5 @@ export type { UserSettings, UpdateSettingsRequest, UploadAvatarResponse, + UserPresence, };