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:
senke 2026-02-21 16:47:09 +01:00
parent 0c8cd43303
commit 49bb633fc6
12 changed files with 300 additions and 24 deletions

View file

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

View file

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

View 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]);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;