diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 151fc10a0..58caf840b 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -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(); diff --git a/apps/web/src/features/presence/components/PresenceBadge.tsx b/apps/web/src/features/presence/components/PresenceBadge.tsx index 98d89a512..d8f1cc01a 100644 --- a/apps/web/src/features/presence/components/PresenceBadge.tsx +++ b/apps/web/src/features/presence/components/PresenceBadge.tsx @@ -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 = ( ); + + if (statusMessage) { + return ( + + {badge} + + ); + } + + return badge; } diff --git a/apps/web/src/features/presence/hooks/usePresenceSync.ts b/apps/web/src/features/presence/hooks/usePresenceSync.ts new file mode 100644 index 000000000..9285daba7 --- /dev/null +++ b/apps/web/src/features/presence/hooks/usePresenceSync.ts @@ -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(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]); +} diff --git a/apps/web/src/features/presence/services/presenceService.ts b/apps/web/src/features/presence/services/presenceService.ts index adfa8900c..4794828d6 100644 --- a/apps/web/src/features/presence/services/presenceService.ts +++ b/apps/web/src/features/presence/services/presenceService.ts @@ -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 { @@ -17,3 +30,13 @@ export async function getPresence(userId: string): Promise { 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 { + await apiClient.put('/users/me/presence', payload); +} diff --git a/apps/web/src/features/settings/components/PresenceInvisibleToggle.tsx b/apps/web/src/features/settings/components/PresenceInvisibleToggle.tsx new file mode 100644 index 000000000..f90e75f74 --- /dev/null +++ b/apps/web/src/features/settings/components/PresenceInvisibleToggle.tsx @@ -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 ( + + + Mode invisible + + Masquer votre statut en ligne aux autres utilisateurs + + + mutation.mutate(checked === true)} + disabled={mutation.isPending} + /> + + ); +} diff --git a/apps/web/src/features/settings/components/PrivacySettings.tsx b/apps/web/src/features/settings/components/PrivacySettings.tsx index da0211888..818df85a3 100644 --- a/apps/web/src/features/settings/components/PrivacySettings.tsx +++ b/apps/web/src/features/settings/components/PrivacySettings.tsx @@ -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) { } /> + + {/* P2.2: Mode invisible */} + ); diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts index fecf0f100..2b3349ead 100644 --- a/apps/web/src/mocks/handlers-misc.ts +++ b/apps/web/src/mocks/handlers-misc.ts @@ -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, }, }); }), diff --git a/veza-backend-api/internal/api/routes_users.go b/veza-backend-api/internal/api/routes_users.go index d913ad884..b07f78e90 100644 --- a/veza-backend-api/internal/api/routes_users.go +++ b/veza-backend-api/internal/api/routes_users.go @@ -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) diff --git a/veza-backend-api/internal/handlers/presence_handler.go b/veza-backend-api/internal/handlers/presence_handler.go index a068f99f6..9c5efa533 100644 --- a/veza-backend-api/internal/handlers/presence_handler.go +++ b/veza-backend-api/internal/handlers/presence_handler.go @@ -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 } diff --git a/veza-backend-api/internal/models/user_presence.go b/veza-backend-api/internal/models/user_presence.go index 315adf5a0..9e55056bc 100644 --- a/veza-backend-api/internal/models/user_presence.go +++ b/veza-backend-api/internal/models/user_presence.go @@ -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 diff --git a/veza-backend-api/internal/services/presence_service.go b/veza-backend-api/internal/services/presence_service.go index 50b13e4de..5a0f0db08 100644 --- a/veza-backend-api/internal/services/presence_service.go +++ b/veza-backend-api/internal/services/presence_service.go @@ -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 } diff --git a/veza-backend-api/migrations/094_user_presence_rich.sql b/veza-backend-api/migrations/094_user_presence_rich.sql new file mode 100644 index 000000000..30bcf493e --- /dev/null +++ b/veza-backend-api/migrations/094_user_presence_rich.sql @@ -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;
+ Masquer votre statut en ligne aux autres utilisateurs +