6 Commits

Author SHA1 Message Date
Mathis HERRIOT
ca4b594828 chore: bump version to 1.9.2
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m37s
CI/CD Pipeline / Valider documentation (push) Successful in 1m48s
CI/CD Pipeline / Valider frontend (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m36s
2026-01-29 18:22:00 +01:00
Mathis HERRIOT
2ea16773c8 feat(users): add boolean fields for online status and read receipts
- Added `showOnlineStatus` and `showReadReceipts` fields to `UpdateUserDto` with validation.
2026-01-29 18:21:54 +01:00
Mathis HERRIOT
616d7f76d7 feat: add support for online status and read receipt preferences
- Added `showOnlineStatus` and `showReadReceipts` fields to settings form.
- Introduced real-time synchronization for read receipts in message threads.
- Enhanced avatars to display online status indicators.
- Automatically mark messages as read when viewing active conversations.
2026-01-29 18:20:58 +01:00
Mathis HERRIOT
f882a70343 feat: add read receipt handling based on user preferences
- Integrated `UsersService` into `MessagesService` for retrieving user preferences.
- Updated `markAsRead` functionality to respect `showReadReceipts` preference.
- Enhanced real-time read receipt notifications via `EventsGateway`.
- Added `markAsRead` method to the frontend message service.
2026-01-29 18:20:18 +01:00
Mathis HERRIOT
779bb5c112 feat: integrate user preferences for online status in WebSocket gateway
- Added `UsersService` to manage user preferences in `EventsGateway`.
- Enhanced online/offline broadcasting to respect user `showOnlineStatus` preference.
- Updated `handleTyping` and `check_status` to verify user preferences before emitting events.
- Abstracted status broadcasting logic into `broadcastStatus`.
2026-01-29 18:20:04 +01:00
Mathis HERRIOT
5753477717 feat: add user preferences for online status and read receipts with real-time updates
- Introduced `showOnlineStatus` and `showReadReceipts` fields in the user schema and API.
- Integrated real-time status broadcasting in `UsersService` via `EventsGateway`.
- Updated repository and frontend user types to align with new fields.
- Enhanced user update handling to support dynamic preference changes for online status.
2026-01-29 18:18:52 +01:00
18 changed files with 280 additions and 28 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/backend",
"version": "1.9.1",
"version": "1.9.2",
"description": "",
"author": "",
"private": true,

View File

@@ -36,6 +36,8 @@ export const users = pgTable(
// Sécurité
twoFactorSecret: pgpEncrypted("two_factor_secret"),
isTwoFactorEnabled: boolean("is_two_factor_enabled").notNull().default(false),
showOnlineStatus: boolean("show_online_status").notNull().default(true),
showReadReceipts: boolean("show_read_receipts").notNull().default(true),
// RGPD & Conformité
termsVersion: varchar("terms_version", { length: 16 }), // Version des CGU acceptées

View File

@@ -1,6 +1,7 @@
import { ForbiddenException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import { MessagesService } from "./messages.service";
import { MessagesRepository } from "./repositories/messages.repository";
@@ -16,6 +17,7 @@ describe("MessagesService", () => {
createMessage: jest.fn(),
findAllConversations: jest.fn(),
isParticipant: jest.fn(),
getParticipants: jest.fn(),
findMessagesByConversationId: jest.fn(),
markAsRead: jest.fn(),
countUnreadMessages: jest.fn(),
@@ -25,12 +27,17 @@ describe("MessagesService", () => {
sendToUser: jest.fn(),
};
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagesService,
{ provide: MessagesRepository, useValue: mockMessagesRepository },
{ provide: EventsGateway, useValue: mockEventsGateway },
{ provide: UsersService, useValue: mockUsersService },
],
}).compile();

View File

@@ -1,5 +1,6 @@
import { ForbiddenException, Injectable } from "@nestjs/common";
import { EventsGateway } from "../realtime/events.gateway";
import { UsersService } from "../users/users.service";
import type { CreateMessageDto } from "./dto/create-message.dto";
import { MessagesRepository } from "./repositories/messages.repository";
@@ -8,6 +9,7 @@ export class MessagesService {
constructor(
private readonly messagesRepository: MessagesRepository,
private readonly eventsGateway: EventsGateway,
private readonly usersService: UsersService,
) {}
async sendMessage(senderId: string, dto: CreateMessageDto) {
@@ -62,8 +64,24 @@ export class MessagesService {
throw new ForbiddenException("You are not part of this conversation");
}
// Marquer comme lus
await this.messagesRepository.markAsRead(conversationId, userId);
// Récupérer les préférences de l'utilisateur actuel
const user = await this.usersService.findOne(userId);
// Marquer comme lus seulement si l'utilisateur l'autorise
if (user?.showReadReceipts) {
await this.messagesRepository.markAsRead(conversationId, userId);
// Notifier l'expéditeur que les messages ont été lus
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
}
return this.messagesRepository.findMessagesByConversationId(conversationId);
}
@@ -76,6 +94,26 @@ export class MessagesService {
if (!isParticipant) {
throw new ForbiddenException("You are not part of this conversation");
}
return this.messagesRepository.markAsRead(conversationId, userId);
const user = await this.usersService.findOne(userId);
if (!user?.showReadReceipts) return;
const result = await this.messagesRepository.markAsRead(
conversationId,
userId,
);
// Notifier l'autre participant
const participants =
await this.messagesRepository.getParticipants(conversationId);
const otherParticipant = participants.find((p) => p.userId !== userId);
if (otherParticipant) {
this.eventsGateway.sendToUser(otherParticipant.userId, "messages_read", {
conversationId,
readerId: userId,
});
}
return result;
}
}

View File

@@ -1,6 +1,7 @@
import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
import { EventsGateway } from "./events.gateway";
describe("EventsGateway", () => {
@@ -15,12 +16,17 @@ describe("EventsGateway", () => {
get: jest.fn().mockReturnValue("secret-password-32-chars-long-!!!"),
};
const mockUsersService = {
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EventsGateway,
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: UsersService, useValue: mockUsersService },
],
}).compile();

View File

@@ -1,4 +1,4 @@
import { Logger } from "@nestjs/common";
import { forwardRef, Inject, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
ConnectedSocket,
@@ -14,6 +14,7 @@ import { getIronSession } from "iron-session";
import { Server, Socket } from "socket.io";
import { getSessionOptions, SessionData } from "../auth/session.config";
import { JwtService } from "../crypto/services/jwt.service";
import { UsersService } from "../users/users.service";
@WebSocketGateway({
transports: ["websocket"],
@@ -63,6 +64,8 @@ export class EventsGateway
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {}
afterInit(_server: Server) {
@@ -106,9 +109,15 @@ export class EventsGateway
// Gérer le statut en ligne
const userId = payload.sub as string;
if (!this.onlineUsers.has(userId)) {
this.onlineUsers.set(userId, new Set());
this.server.emit("user_status", { userId, status: "online" });
// Vérifier les préférences de l'utilisateur
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "online");
}
}
this.onlineUsers.get(userId)?.add(client.id);
@@ -119,19 +128,31 @@ export class EventsGateway
}
}
handleDisconnect(client: Socket) {
async handleDisconnect(client: Socket) {
const userId = client.data.user?.sub;
if (userId && this.onlineUsers.has(userId)) {
const sockets = this.onlineUsers.get(userId);
sockets?.delete(client.id);
if (sockets?.size === 0) {
this.onlineUsers.delete(userId);
this.server.emit("user_status", { userId, status: "offline" });
const user = await this.usersService.findOne(userId);
if (user?.showOnlineStatus) {
this.broadcastStatus(userId, "offline");
}
}
}
this.logger.log(`Client disconnected: ${client.id}`);
}
broadcastStatus(userId: string, status: "online" | "offline") {
this.server.emit("user_status", { userId, status });
}
isUserOnline(userId: string): boolean {
return this.onlineUsers.has(userId);
}
@SubscribeMessage("join_content")
handleJoinContent(
@ConnectedSocket() client: Socket,
@@ -151,13 +172,20 @@ export class EventsGateway
}
@SubscribeMessage("typing")
handleTyping(
async handleTyping(
@ConnectedSocket() client: Socket,
@MessageBody() data: { recipientId: string; isTyping: boolean },
) {
const userId = client.data.user?.sub;
if (!userId) return;
// Optionnel: vérifier si l'utilisateur autorise le statut en ligne avant d'émettre "typing"
// ou si on considère que typing est une interaction directe qui outrepasse le statut.
// Instagram affiche "Typing..." même si le statut en ligne est désactivé si on est dans le chat.
// Mais par souci de cohérence avec "showOnlineStatus", on peut le vérifier.
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return;
this.server.to(`user:${data.recipientId}`).emit("user_typing", {
userId,
isTyping: data.isTyping,
@@ -165,13 +193,19 @@ export class EventsGateway
}
@SubscribeMessage("check_status")
handleCheckStatus(
async handleCheckStatus(
@ConnectedSocket() _client: Socket,
@MessageBody() userId: string,
) {
const isOnline = this.onlineUsers.has(userId);
if (!isOnline) return { userId, status: "offline" };
const user = await this.usersService.findOne(userId);
if (!user?.showOnlineStatus) return { userId, status: "offline" };
return {
userId,
status: this.onlineUsers.has(userId) ? "online" : "offline",
status: "online",
};
}

View File

@@ -1,4 +1,4 @@
import { IsOptional, IsString, MaxLength } from "class-validator";
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
export class UpdateUserDto {
@IsOptional()
@@ -22,4 +22,12 @@ export class UpdateUserDto {
@IsOptional()
@IsString()
role?: string;
@IsOptional()
@IsBoolean()
showOnlineStatus?: boolean;
@IsOptional()
@IsBoolean()
showReadReceipts?: boolean;
}

View File

@@ -47,6 +47,8 @@ export class UsersRepository {
bio: users.bio,
status: users.status,
isTwoFactorEnabled: users.isTwoFactorEnabled,
showOnlineStatus: users.showOnlineStatus,
showReadReceipts: users.showReadReceipts,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})

View File

@@ -1,13 +1,19 @@
import { forwardRef, Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { MediaModule } from "../media/media.module";
import { RealtimeModule } from "../realtime/realtime.module";
import { S3Module } from "../s3/s3.module";
import { UsersRepository } from "./repositories/users.repository";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
imports: [forwardRef(() => AuthModule), MediaModule, S3Module],
imports: [
forwardRef(() => AuthModule),
MediaModule,
S3Module,
forwardRef(() => RealtimeModule),
],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService, UsersRepository],

View File

@@ -20,6 +20,7 @@ import { ConfigService } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import { RbacService } from "../auth/rbac.service";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { UsersRepository } from "./repositories/users.repository";
import { UsersService } from "./users.service";
@@ -49,6 +50,7 @@ describe("UsersService", () => {
const mockRbacService = {
getUserRoles: jest.fn(),
assignRoleToUser: jest.fn(),
};
const mockMediaService = {
@@ -65,6 +67,11 @@ describe("UsersService", () => {
get: jest.fn(),
};
const mockEventsGateway = {
isUserOnline: jest.fn(),
broadcastStatus: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
@@ -77,6 +84,7 @@ describe("UsersService", () => {
{ provide: MediaService, useValue: mockMediaService },
{ provide: S3Service, useValue: mockS3Service },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: EventsGateway, useValue: mockEventsGateway },
],
}).compile();

View File

@@ -12,6 +12,7 @@ import { RbacService } from "../auth/rbac.service";
import type { IMediaService } from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service";
import { EventsGateway } from "../realtime/events.gateway";
import { S3Service } from "../s3/s3.service";
import { UpdateUserDto } from "./dto/update-user.dto";
import { UsersRepository } from "./repositories/users.repository";
@@ -27,6 +28,8 @@ export class UsersService {
private readonly rbacService: RbacService,
@Inject(MediaService) private readonly mediaService: IMediaService,
@Inject(S3Service) private readonly s3Service: IStorageService,
@Inject(forwardRef(() => EventsGateway))
private readonly eventsGateway: EventsGateway,
) {}
private async clearUserCache(username?: string) {
@@ -137,6 +140,9 @@ export class UsersService {
const { role, ...userData } = data;
// On récupère l'utilisateur actuel avant mise à jour pour comparer les préférences
const oldUser = await this.usersRepository.findOne(uuid);
const result = await this.usersRepository.update(uuid, userData);
if (role) {
@@ -145,6 +151,21 @@ export class UsersService {
if (result[0]) {
await this.clearUserCache(result[0].username);
// Gérer le changement de préférence de statut en ligne
if (
data.showOnlineStatus !== undefined &&
data.showOnlineStatus !== oldUser?.showOnlineStatus
) {
const isOnline = this.eventsGateway.isUserOnline(uuid);
if (isOnline) {
if (data.showOnlineStatus) {
this.eventsGateway.broadcastStatus(uuid, "online");
} else {
this.eventsGateway.broadcastStatus(uuid, "offline");
}
}
}
}
return result;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/frontend",
"version": "1.9.1",
"version": "1.9.2",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -2,7 +2,15 @@
import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale";
import { ArrowLeft, Search, Send, UserPlus, X } from "lucide-react";
import {
ArrowLeft,
Check,
CheckCheck,
Search,
Send,
UserPlus,
X,
} from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import * as React from "react";
@@ -142,6 +150,8 @@ export default function MessagesPage() {
if (activeConv?.id === data.conversationId) {
setMessages((prev) => [...prev, data.message]);
setIsOtherTyping(false); // S'il a envoyé un message, il ne tape plus
// Marquer comme lu immédiatement si on est sur la conversation
MessageService.markAsRead(data.conversationId).catch(console.error);
}
// Mettre à jour la liste des conversations
setConversations((prev) => {
@@ -184,10 +194,26 @@ export default function MessagesPage() {
}
});
socket.on(
"messages_read",
(data: { conversationId: string; readerId: string }) => {
if (activeConv?.id === data.conversationId) {
setMessages((prev) =>
prev.map((msg) =>
msg.senderId !== data.readerId && !msg.readAt
? { ...msg, readAt: new Date().toISOString() }
: msg,
),
);
}
},
);
return () => {
socket.off("new_message");
socket.off("user_status");
socket.off("user_typing");
socket.off("messages_read");
};
}
}, [socket, activeConv]);
@@ -351,7 +377,7 @@ export default function MessagesPage() {
: "hover:bg-zinc-100 dark:hover:bg-zinc-900"
}`}
>
<Avatar>
<Avatar isOnline={onlineUsers.has(conv.recipient.uuid)}>
<AvatarImage src={conv.recipient.avatarUrl} />
<AvatarFallback>
{conv.recipient.username[0].toUpperCase()}
@@ -403,7 +429,10 @@ export default function MessagesPage() {
href={`/user/${activeConv.recipient.username}`}
className="flex-1 flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<Avatar className="h-8 w-8">
<Avatar
className="h-8 w-8"
isOnline={onlineUsers.has(activeConv.recipient.uuid)}
>
<AvatarImage src={activeConv.recipient.avatarUrl} />
<AvatarFallback>
{activeConv.recipient.username[0].toUpperCase()}
@@ -465,8 +494,12 @@ export default function MessagesPage() {
})}
</span>
{msg.senderId === user?.uuid && (
<span className="font-bold">
{msg.readAt ? "• Lu" : "• Envoyé"}
<span className="flex items-center">
{msg.readAt ? (
<CheckCheck className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</span>
)}
</div>

View File

@@ -10,6 +10,7 @@ import {
Palette,
Save,
Settings,
Shield,
Sun,
Trash2,
User as UserIcon,
@@ -53,6 +54,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider";
import { UserService } from "@/services/user.service";
@@ -60,6 +62,8 @@ import { UserService } from "@/services/user.service";
const settingsSchema = z.object({
displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(),
bio: z.string().max(255, "La bio est trop longue").optional(),
showOnlineStatus: z.boolean(),
showReadReceipts: z.boolean(),
});
type SettingsFormValues = z.infer<typeof settingsSchema>;
@@ -82,6 +86,8 @@ export default function SettingsPage() {
defaultValues: {
displayName: "",
bio: "",
showOnlineStatus: true,
showReadReceipts: true,
},
});
@@ -90,6 +96,8 @@ export default function SettingsPage() {
form.reset({
displayName: user.displayName || "",
bio: user.bio || "",
showOnlineStatus: user.showOnlineStatus ?? true,
showReadReceipts: user.showReadReceipts ?? true,
});
}
}, [user, form]);
@@ -265,6 +273,73 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Confidentialité */}
<Card className="border-none shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<CardTitle>Confidentialité</CardTitle>
<CardDescription>Gérez la visibilité de vos activités.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="showOnlineStatus"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Statut en ligne</FormLabel>
<FormDescription>
Affiche quand vous êtes actif sur le site.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="showReadReceipts"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Confirmations de lecture
</FormLabel>
<FormDescription>
Permet aux autres de voir quand vous avez lu leurs messages.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSaving} className="min-w-[150px]">
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Enregistrer
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
<TwoFactorSetup />
<Card className="border-none shadow-sm">

View File

@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
function Avatar({
className,
isOnline,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
}: React.ComponentProps<typeof AvatarPrimitive.Root> & { isOnline?: boolean }) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
<div className="relative inline-block">
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
{isOnline && (
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-white dark:ring-zinc-900" />
)}
{...props}
/>
</div>
);
}

View File

@@ -55,4 +55,8 @@ export const MessageService = {
});
return data;
},
async markAsRead(conversationId: string): Promise<void> {
await api.patch(`/messages/conversations/${conversationId}/read`);
},
};

View File

@@ -9,6 +9,8 @@ export interface User {
role?: "user" | "admin" | "moderator";
status?: "active" | "verification" | "suspended" | "pending" | "deleted";
twoFactorEnabled?: boolean;
showOnlineStatus?: boolean;
showReadReceipts?: boolean;
createdAt: string;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@memegoat/source",
"version": "1.9.1",
"version": "1.9.2",
"description": "",
"scripts": {
"version:get": "cmake -P version.cmake GET",