feat(presence): PresenceBadge and display (P1.3)
This commit is contained in:
parent
182b28011f
commit
5cc3d7b181
9 changed files with 132 additions and 14 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
42
apps/web/src/features/presence/components/PresenceBadge.tsx
Normal file
42
apps/web/src/features/presence/components/PresenceBadge.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
apps/web/src/features/presence/hooks/usePresence.ts
Normal file
16
apps/web/src/features/presence/hooks/usePresence.ts
Normal 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
|
||||
});
|
||||
}
|
||||
5
apps/web/src/features/presence/index.ts
Normal file
5
apps/web/src/features/presence/index.ts
Normal 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';
|
||||
19
apps/web/src/features/presence/services/presenceService.ts
Normal file
19
apps/web/src/features/presence/services/presenceService.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue