feat(presence): PresenceBadge and display (P1.3)

This commit is contained in:
senke 2026-02-21 05:22:52 +01:00
parent 182b28011f
commit 5cc3d7b181
9 changed files with 132 additions and 14 deletions

View file

@ -65,19 +65,26 @@ export const ChatSidebar: React.FC = () => {
{conversations.length === 0 ? (
<ChatSidebarEmpty />
) : (
conversations.map((conv) => (
<ConversationItem
key={conv.id}
conversation={{
id: conv.id,
name: conv.name,
type: conv.type,
unread_count: conv.unread_count,
}}
onSelect={(id) => setCurrentConversation(id)}
isSelected={conv.id === currentConversationId}
/>
))
conversations.map((conv) => {
const otherParticipantId =
conv.type === 'direct' && userId
? conv.participants?.find((p) => p !== userId)
: undefined;
return (
<ConversationItem
key={conv.id}
conversation={{
id: conv.id,
name: conv.name,
type: conv.type,
unread_count: conv.unread_count,
participantId: otherParticipantId,
}}
onSelect={(id) => setCurrentConversation(id)}
isSelected={conv.id === currentConversationId}
/>
);
})
)}
</div>

View file

@ -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<Error | null>(null);
@ -118,7 +120,13 @@ function ConversationItemInner({
<Avatar
fallback={conversation.name || 'U'}
size="sm"
status="online"
status={
presence?.status === 'online' ||
presence?.status === 'away' ||
presence?.status === 'busy'
? presence.status
: 'offline'
}
/>
) : (
<div

View file

@ -7,4 +7,6 @@ export interface ConversationItemData {
name: string;
type: string;
unread_count?: number;
/** For direct messages: the other participant's user ID (for presence) */
participantId?: string;
}

View file

@ -0,0 +1,42 @@
/**
* PresenceBadge (v0.301 Lot P1)
* Displays a colored dot for user presence status (online/away/offline)
*/
import React from 'react';
import { cn } from '@/lib/utils';
export type PresenceStatus = 'online' | 'away' | 'busy' | 'offline';
export interface PresenceBadgeProps {
status: PresenceStatus;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const statusColors: Record<PresenceStatus, string> = {
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 (
<span
className={cn(
'inline-block rounded-full border-2 border-background shrink-0',
statusColors[status],
sizeClasses[size],
className
)}
aria-label={`Status: ${status}`}
/>
);
}

View file

@ -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
});
}

View file

@ -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';

View file

@ -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<UserPresence> {
const response = await apiClient.get(`/users/${userId}/presence`);
const data = (response.data as { data?: UserPresence })?.data ?? response.data;
return data as UserPresence;
}

View file

@ -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 })),

View file

@ -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,
};