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