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.
This commit is contained in:
Mathis HERRIOT
2026-01-29 18:20:58 +01:00
parent f882a70343
commit 616d7f76d7
3 changed files with 127 additions and 13 deletions

View File

@@ -2,7 +2,15 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale"; 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 Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
@@ -142,6 +150,8 @@ export default function MessagesPage() {
if (activeConv?.id === data.conversationId) { if (activeConv?.id === data.conversationId) {
setMessages((prev) => [...prev, data.message]); setMessages((prev) => [...prev, data.message]);
setIsOtherTyping(false); // S'il a envoyé un message, il ne tape plus 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 // Mettre à jour la liste des conversations
setConversations((prev) => { 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 () => { return () => {
socket.off("new_message"); socket.off("new_message");
socket.off("user_status"); socket.off("user_status");
socket.off("user_typing"); socket.off("user_typing");
socket.off("messages_read");
}; };
} }
}, [socket, activeConv]); }, [socket, activeConv]);
@@ -351,7 +377,7 @@ export default function MessagesPage() {
: "hover:bg-zinc-100 dark:hover:bg-zinc-900" : "hover:bg-zinc-100 dark:hover:bg-zinc-900"
}`} }`}
> >
<Avatar> <Avatar isOnline={onlineUsers.has(conv.recipient.uuid)}>
<AvatarImage src={conv.recipient.avatarUrl} /> <AvatarImage src={conv.recipient.avatarUrl} />
<AvatarFallback> <AvatarFallback>
{conv.recipient.username[0].toUpperCase()} {conv.recipient.username[0].toUpperCase()}
@@ -403,7 +429,10 @@ export default function MessagesPage() {
href={`/user/${activeConv.recipient.username}`} href={`/user/${activeConv.recipient.username}`}
className="flex-1 flex items-center gap-3 hover:opacity-80 transition-opacity" 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} /> <AvatarImage src={activeConv.recipient.avatarUrl} />
<AvatarFallback> <AvatarFallback>
{activeConv.recipient.username[0].toUpperCase()} {activeConv.recipient.username[0].toUpperCase()}
@@ -465,8 +494,12 @@ export default function MessagesPage() {
})} })}
</span> </span>
{msg.senderId === user?.uuid && ( {msg.senderId === user?.uuid && (
<span className="font-bold"> <span className="flex items-center">
{msg.readAt ? "• Lu" : "• Envoyé"} {msg.readAt ? (
<CheckCheck className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</span> </span>
)} )}
</div> </div>

View File

@@ -10,6 +10,7 @@ import {
Palette, Palette,
Save, Save,
Settings, Settings,
Shield,
Sun, Sun,
Trash2, Trash2,
User as UserIcon, User as UserIcon,
@@ -53,6 +54,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { UserService } from "@/services/user.service"; import { UserService } from "@/services/user.service";
@@ -60,6 +62,8 @@ import { UserService } from "@/services/user.service";
const settingsSchema = z.object({ const settingsSchema = z.object({
displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(), displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(),
bio: z.string().max(255, "La bio est trop longue").optional(), bio: z.string().max(255, "La bio est trop longue").optional(),
showOnlineStatus: z.boolean(),
showReadReceipts: z.boolean(),
}); });
type SettingsFormValues = z.infer<typeof settingsSchema>; type SettingsFormValues = z.infer<typeof settingsSchema>;
@@ -82,6 +86,8 @@ export default function SettingsPage() {
defaultValues: { defaultValues: {
displayName: "", displayName: "",
bio: "", bio: "",
showOnlineStatus: true,
showReadReceipts: true,
}, },
}); });
@@ -90,6 +96,8 @@ export default function SettingsPage() {
form.reset({ form.reset({
displayName: user.displayName || "", displayName: user.displayName || "",
bio: user.bio || "", bio: user.bio || "",
showOnlineStatus: user.showOnlineStatus ?? true,
showReadReceipts: user.showReadReceipts ?? true,
}); });
} }
}, [user, form]); }, [user, form]);
@@ -265,6 +273,73 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </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 /> <TwoFactorSetup />
<Card className="border-none shadow-sm"> <Card className="border-none shadow-sm">

View File

@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
function Avatar({ function Avatar({
className, className,
isOnline,
...props ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { }: React.ComponentProps<typeof AvatarPrimitive.Root> & { isOnline?: boolean }) {
return ( return (
<AvatarPrimitive.Root <div className="relative inline-block">
data-slot="avatar" <AvatarPrimitive.Root
className={cn( data-slot="avatar"
"relative flex size-8 shrink-0 overflow-hidden rounded-full", className={cn(
className, "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>
/>
); );
} }