feat(presence): P2.1 rich presence, P2.2 invisible mode
Backend: - UserPresence: track_id, track_title, invisible - UpdatePresenceFull, GetPresenceForViewer (invisible hides for others) - PUT /users/me/presence - Migration 094 rich presence columns Frontend: - presenceService.updatePresence - usePresenceSync: sync currentTrack to presence - PresenceBadge: statusMessage tooltip - PresenceInvisibleToggle in PrivacySettings - MSW: PUT /users/me/presence
This commit is contained in:
parent
0c8cd43303
commit
49bb633fc6
12 changed files with 300 additions and 24 deletions
|
|
@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||
import { useUser } from '@/features/auth/hooks/useUser';
|
||||
import { usePresenceSync } from '@/features/presence/hooks/usePresenceSync';
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { EmailVerificationBadge } from '@/features/auth/components/EmailVerificationBadge';
|
||||
|
|
@ -30,6 +31,7 @@ export function Header(_props: HeaderProps) {
|
|||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const { logout } = useAuthStore();
|
||||
const { data: user } = useUser();
|
||||
usePresenceSync(!!user?.id);
|
||||
const { theme, setTheme, sidebarOpen, setSidebarOpen } = useUIStore();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
/**
|
||||
* PresenceBadge (v0.301 Lot P1)
|
||||
* PresenceBadge (v0.301 Lot P1, v0.302 P2)
|
||||
* Displays a colored dot for user presence status (online/away/offline)
|
||||
* P2: Optional status_message in tooltip (e.g. "Écoute Midnight Drive")
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type PresenceStatus = 'online' | 'away' | 'busy' | 'offline';
|
||||
|
||||
export interface PresenceBadgeProps {
|
||||
status: PresenceStatus;
|
||||
/** P2: Rich presence message (e.g. "Écoute Midnight Drive") */
|
||||
statusMessage?: string | null;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -27,8 +31,13 @@ const sizeClasses = {
|
|||
lg: 'w-3 h-3',
|
||||
};
|
||||
|
||||
export function PresenceBadge({ status, size = 'md', className }: PresenceBadgeProps) {
|
||||
return (
|
||||
export function PresenceBadge({
|
||||
status,
|
||||
statusMessage,
|
||||
size = 'md',
|
||||
className,
|
||||
}: PresenceBadgeProps) {
|
||||
const badge = (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block rounded-full border-2 border-background shrink-0',
|
||||
|
|
@ -36,7 +45,17 @@ export function PresenceBadge({ status, size = 'md', className }: PresenceBadgeP
|
|||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
aria-label={`Status: ${status}`}
|
||||
aria-label={statusMessage ? `${status}: ${statusMessage}` : `Status: ${status}`}
|
||||
/>
|
||||
);
|
||||
|
||||
if (statusMessage) {
|
||||
return (
|
||||
<Tooltip content={statusMessage}>
|
||||
{badge}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
}
|
||||
|
|
|
|||
33
apps/web/src/features/presence/hooks/usePresenceSync.ts
Normal file
33
apps/web/src/features/presence/hooks/usePresenceSync.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* usePresenceSync (v0.302 P2.1)
|
||||
* Syncs current track to presence (rich presence "Écoute X")
|
||||
* Call when user is authenticated
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '@/features/player/store/playerStore';
|
||||
import { updatePresence } from '../services/presenceService';
|
||||
|
||||
export function usePresenceSync(enabled: boolean) {
|
||||
const currentTrack = usePlayerStore((s) => s.currentTrack);
|
||||
const prevTrackIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const trackId = currentTrack?.id ?? null;
|
||||
if (trackId === prevTrackIdRef.current) return;
|
||||
prevTrackIdRef.current = trackId;
|
||||
|
||||
const payload = currentTrack
|
||||
? {
|
||||
status_message: `Écoute ${currentTrack.title}`,
|
||||
track_id: currentTrack.id,
|
||||
track_title: currentTrack.title,
|
||||
}
|
||||
: { status_message: undefined, track_id: null, track_title: null };
|
||||
|
||||
updatePresence(payload).catch(() => {
|
||||
// Silently ignore - presence update is best-effort
|
||||
});
|
||||
}, [enabled, currentTrack?.id, currentTrack?.title]);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* Presence API Service (v0.301 Lot P1)
|
||||
* Presence API Service (v0.301 Lot P1, v0.302 P2)
|
||||
* Fetches user presence (online/away/offline, last_seen_at)
|
||||
* P2: Rich presence (status_message, track_id, track_title), invisible mode
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/services/api/client';
|
||||
|
|
@ -10,6 +11,18 @@ export interface UserPresence {
|
|||
status: 'online' | 'away' | 'busy' | 'offline';
|
||||
last_seen_at: string | null;
|
||||
status_message: string | null;
|
||||
track_id?: string | null;
|
||||
track_title?: string | null;
|
||||
/** Only present when fetching own presence */
|
||||
invisible?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePresencePayload {
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
status_message?: string;
|
||||
track_id?: string | null;
|
||||
track_title?: string | null;
|
||||
invisible?: boolean;
|
||||
}
|
||||
|
||||
export async function getPresence(userId: string): Promise<UserPresence> {
|
||||
|
|
@ -17,3 +30,13 @@ export async function getPresence(userId: string): Promise<UserPresence> {
|
|||
const data = (response.data as { data?: UserPresence })?.data ?? response.data;
|
||||
return data as UserPresence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user's presence (P2)
|
||||
* PUT /users/me/presence
|
||||
*/
|
||||
export async function updatePresence(
|
||||
payload: UpdatePresencePayload,
|
||||
): Promise<void> {
|
||||
await apiClient.put('/users/me/presence', payload);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* P2.2: Mode invisible toggle
|
||||
* Updates presence via API when toggled
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
updatePresence,
|
||||
getPresence,
|
||||
} from '@/features/presence/services/presenceService';
|
||||
import { useUser } from '@/features/auth/hooks/useUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
export function PresenceInvisibleToggle() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: user } = useUser();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
|
||||
const { data: presence } = useQuery({
|
||||
queryKey: ['presence', user?.id],
|
||||
queryFn: () => getPresence(user!.id),
|
||||
enabled: !!user?.id,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (invisible: boolean) => updatePresence({ invisible }),
|
||||
onMutate: (invisible) => {
|
||||
if (user?.id) {
|
||||
queryClient.setQueryData(['presence', user.id], (old: unknown) =>
|
||||
old ? { ...(old as object), invisible } : { invisible }
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: (_, invisible) => {
|
||||
showSuccess(invisible ? 'Mode invisible activé' : 'Mode visible');
|
||||
queryClient.invalidateQueries({ queryKey: ['presence'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
showError(err instanceof Error ? err.message : 'Erreur');
|
||||
queryClient.invalidateQueries({ queryKey: ['presence'] });
|
||||
},
|
||||
});
|
||||
|
||||
const invisible = presence?.invisible ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="presence_invisible">Mode invisible</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Masquer votre statut en ligne aux autres utilisateurs
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="presence_invisible"
|
||||
checked={invisible}
|
||||
onCheckedChange={(checked) => mutation.mutate(checked === true)}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PresenceInvisibleToggle } from './PresenceInvisibleToggle';
|
||||
import { PrivacySettings as PrivacySettingsType } from '../types/settings';
|
||||
|
||||
interface PrivacySettingsProps {
|
||||
|
|
@ -51,6 +52,9 @@ export function PrivacySettings({ privacy, onChange }: PrivacySettingsProps) {
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* P2.2: Mode invisible */}
|
||||
<PresenceInvisibleToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -414,6 +414,10 @@ export const handlersMisc = [
|
|||
});
|
||||
}),
|
||||
|
||||
http.put('*/api/v1/users/me/presence', () => {
|
||||
return HttpResponse.json({ success: true, data: { message: 'Presence updated' } });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/users/:id/presence', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
|
|
@ -422,6 +426,9 @@ export const handlersMisc = [
|
|||
status: 'online',
|
||||
last_seen_at: new Date().toISOString(),
|
||||
status_message: null,
|
||||
track_id: null,
|
||||
track_title: null,
|
||||
invisible: false,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
|
|||
|
||||
presenceService := services.NewPresenceService(r.db.GormDB, r.logger)
|
||||
presenceHandler := handlers.NewPresenceHandler(presenceService, r.logger)
|
||||
protected.PUT("/me/presence", presenceHandler.UpdatePresence)
|
||||
protected.GET("/:id/presence", presenceHandler.GetPresence)
|
||||
|
||||
protected.POST("/:id/follow", profileHandler.FollowUser)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ func NewPresenceHandler(presenceService *services.PresenceService, logger *zap.L
|
|||
|
||||
// GetPresence returns presence for a user
|
||||
// GET /users/:id/presence
|
||||
// P2: If target is invisible, returns offline for other viewers
|
||||
func (h *PresenceHandler) GetPresence(c *gin.Context) {
|
||||
userIDStr := c.Param("id")
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
|
|
@ -37,7 +38,12 @@ func (h *PresenceHandler) GetPresence(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
p, err := h.presenceService.GetPresence(c.Request.Context(), userID)
|
||||
var viewerID *uuid.UUID
|
||||
if uid, ok := GetUserIDUUID(c); ok {
|
||||
viewerID = &uid
|
||||
}
|
||||
|
||||
p, err := h.presenceService.GetPresenceForViewer(c.Request.Context(), userID, viewerID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get presence", zap.Error(err), zap.String("user_id", userID.String()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get presence"})
|
||||
|
|
@ -45,17 +51,76 @@ func (h *PresenceHandler) GetPresence(c *gin.Context) {
|
|||
}
|
||||
if p == nil {
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"user_id": userID.String(),
|
||||
"status": "offline",
|
||||
"last_seen_at": nil,
|
||||
"user_id": userID.String(),
|
||||
"status": "offline",
|
||||
"last_seen_at": nil,
|
||||
"status_message": nil,
|
||||
"track_id": nil,
|
||||
"track_title": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"user_id": p.UserID.String(),
|
||||
"status": p.Status,
|
||||
"last_seen_at": p.LastSeenAt,
|
||||
resp := gin.H{
|
||||
"user_id": p.UserID.String(),
|
||||
"status": p.Status,
|
||||
"last_seen_at": p.LastSeenAt,
|
||||
"status_message": p.StatusMsg,
|
||||
})
|
||||
"track_id": nil,
|
||||
"track_title": nil,
|
||||
}
|
||||
if p.TrackID != nil {
|
||||
resp["track_id"] = p.TrackID.String()
|
||||
}
|
||||
if p.TrackTitle != "" {
|
||||
resp["track_title"] = p.TrackTitle
|
||||
}
|
||||
if viewerID != nil && *viewerID == userID {
|
||||
resp["invisible"] = p.Invisible
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UpdatePresenceRequest is the body for PUT /users/me/presence (P2)
|
||||
type UpdatePresenceRequest struct {
|
||||
Status *string `json:"status"`
|
||||
StatusMsg *string `json:"status_message"`
|
||||
TrackID *uuid.UUID `json:"track_id"`
|
||||
TrackTitle *string `json:"track_title"`
|
||||
Invisible *bool `json:"invisible"`
|
||||
}
|
||||
|
||||
// UpdatePresence updates the current user's presence
|
||||
// PUT /users/me/presence
|
||||
func (h *PresenceHandler) UpdatePresence(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdatePresenceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid request body"))
|
||||
return
|
||||
}
|
||||
|
||||
input := &services.UpdatePresenceInput{
|
||||
Status: req.Status,
|
||||
StatusMsg: req.StatusMsg,
|
||||
TrackID: req.TrackID,
|
||||
TrackTitle: req.TrackTitle,
|
||||
Invisible: req.Invisible,
|
||||
}
|
||||
// If no fields provided, default to online
|
||||
if input.Status == nil && input.StatusMsg == nil && input.TrackID == nil && input.TrackTitle == nil && input.Invisible == nil {
|
||||
input.Status = ptr("online")
|
||||
}
|
||||
|
||||
if err := h.presenceService.UpdatePresenceFull(c.Request.Context(), userID, input); err != nil {
|
||||
h.logger.Error("Failed to update presence", zap.Error(err), zap.String("user_id", userID.String()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update presence"})
|
||||
return
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Presence updated"})
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
|
|
|||
|
|
@ -6,13 +6,16 @@ import (
|
|||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// UserPresence represents a user's online status (v0.301 Lot P1)
|
||||
// UserPresence represents a user's online status (v0.301 Lot P1, v0.302 P2)
|
||||
type UserPresence struct {
|
||||
UserID uuid.UUID `gorm:"type:uuid;primaryKey" json:"user_id"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'offline'" json:"status"` // online, away, busy, offline
|
||||
LastSeenAt time.Time `gorm:"not null" json:"last_seen_at"`
|
||||
StatusMsg string `gorm:"type:text" json:"status_message,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;primaryKey" json:"user_id"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'offline'" json:"status"` // online, away, busy, offline
|
||||
LastSeenAt time.Time `gorm:"not null" json:"last_seen_at"`
|
||||
StatusMsg string `gorm:"type:text" json:"status_message,omitempty"`
|
||||
TrackID *uuid.UUID `gorm:"type:uuid" json:"track_id,omitempty"`
|
||||
TrackTitle string `gorm:"type:text" json:"track_title,omitempty"`
|
||||
Invisible bool `gorm:"not null;default:false" json:"invisible"`
|
||||
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the table name
|
||||
|
|
|
|||
|
|
@ -22,31 +22,73 @@ func NewPresenceService(db *gorm.DB, logger *zap.Logger) *PresenceService {
|
|||
return &PresenceService{db: db, logger: logger}
|
||||
}
|
||||
|
||||
// UpdatePresenceInput holds optional fields for presence update (P2)
|
||||
type UpdatePresenceInput struct {
|
||||
Status *string
|
||||
StatusMsg *string
|
||||
TrackID *uuid.UUID
|
||||
TrackTitle *string
|
||||
Invisible *bool
|
||||
}
|
||||
|
||||
// UpdatePresence updates or creates presence for the current user (call on each authenticated request)
|
||||
// P2: Extended with status_message, track_id, track_title, invisible
|
||||
func (s *PresenceService) UpdatePresence(ctx context.Context, userID uuid.UUID, status string) error {
|
||||
return s.UpdatePresenceFull(ctx, userID, &UpdatePresenceInput{Status: &status})
|
||||
}
|
||||
|
||||
// UpdatePresenceFull updates presence with optional fields (P2)
|
||||
func (s *PresenceService) UpdatePresenceFull(ctx context.Context, userID uuid.UUID, input *UpdatePresenceInput) error {
|
||||
if input == nil {
|
||||
return s.UpdatePresence(ctx, userID, "online")
|
||||
}
|
||||
now := time.Now()
|
||||
var p models.UserPresence
|
||||
err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&p).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
p = models.UserPresence{
|
||||
UserID: userID,
|
||||
Status: status,
|
||||
Status: "online",
|
||||
LastSeenAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
return s.db.WithContext(ctx).Create(&p).Error
|
||||
}
|
||||
if err != nil {
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
p.Status = status
|
||||
if input.Status != nil {
|
||||
p.Status = *input.Status
|
||||
}
|
||||
if input.StatusMsg != nil {
|
||||
p.StatusMsg = *input.StatusMsg
|
||||
}
|
||||
if input.TrackID != nil {
|
||||
p.TrackID = input.TrackID
|
||||
}
|
||||
if input.TrackTitle != nil {
|
||||
p.TrackTitle = *input.TrackTitle
|
||||
}
|
||||
if input.Invisible != nil {
|
||||
p.Invisible = *input.Invisible
|
||||
}
|
||||
p.LastSeenAt = now
|
||||
p.UpdatedAt = now
|
||||
if p.UserID == uuid.Nil {
|
||||
p.UserID = userID
|
||||
return s.db.WithContext(ctx).Create(&p).Error
|
||||
}
|
||||
return s.db.WithContext(ctx).Save(&p).Error
|
||||
}
|
||||
|
||||
// GetPresence returns presence for a user, or nil if not found
|
||||
// P2: If user is invisible, returns offline for others (caller must pass requestUserID to check)
|
||||
func (s *PresenceService) GetPresence(ctx context.Context, userID uuid.UUID) (*models.UserPresence, error) {
|
||||
return s.GetPresenceForViewer(ctx, userID, nil)
|
||||
}
|
||||
|
||||
// GetPresenceForViewer returns presence for a user as seen by another user
|
||||
// P2: If target is invisible and viewer is not self, returns offline
|
||||
func (s *PresenceService) GetPresenceForViewer(ctx context.Context, userID uuid.UUID, viewerID *uuid.UUID) (*models.UserPresence, error) {
|
||||
var p models.UserPresence
|
||||
err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&p).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
|
|
@ -55,6 +97,15 @@ func (s *PresenceService) GetPresence(ctx context.Context, userID uuid.UUID) (*m
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// P2: If invisible and viewer is someone else, return offline
|
||||
if p.Invisible && viewerID != nil && *viewerID != userID {
|
||||
return &models.UserPresence{
|
||||
UserID: p.UserID,
|
||||
Status: "offline",
|
||||
LastSeenAt: p.LastSeenAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
5
veza-backend-api/migrations/094_user_presence_rich.sql
Normal file
5
veza-backend-api/migrations/094_user_presence_rich.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Migration 094: User presence rich presence (v0.302 Lot P2.1)
|
||||
-- Adds track_id and track_title for "Écoute X" display
|
||||
|
||||
ALTER TABLE user_presence ADD COLUMN IF NOT EXISTS track_id UUID;
|
||||
ALTER TABLE user_presence ADD COLUMN IF NOT EXISTS track_title TEXT;
|
||||
Loading…
Reference in a new issue