feat: release v0.2.0 profiles, chat, freeze, and bots

Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
This commit is contained in:
ahp
2026-10-10 16:42:35 +03:30
parent 8586bbafcc
commit 22d92c133d
85 changed files with 10510 additions and 1341 deletions
+30 -20
View File
@@ -6,6 +6,29 @@ import { useAuth } from "@/lib/auth-store";
import { http } from "@/lib/api";
import type { User } from "@/lib/types";
function sameLiveUser(a: User, b: User): boolean {
return (
a.id === b.id &&
a.name === b.name &&
a.email === b.email &&
a.role === b.role &&
a.xp === b.xp &&
a.hearts === b.hearts &&
a.streakCurrent === b.streakCurrent &&
a.streakLongest === b.streakLongest &&
a.isLocked === b.isLocked &&
a.isFrozen === b.isFrozen &&
a.isClosed === b.isClosed &&
a.frozenAt === b.frozenAt &&
a.closedAt === b.closedAt &&
a.closesAt === b.closesAt &&
a.isActive === b.isActive &&
a.avatarVariant === b.avatarVariant &&
a.avatarPalette === b.avatarPalette &&
a.avatarPhoto === b.avatarPhoto
);
}
export function useLiveUser(): User | null {
const user = useAuth((s) => s.user);
const setUser = useAuth((s) => s.setUser);
@@ -19,26 +42,13 @@ export function useLiveUser(): User | null {
useEffect(() => {
const fresh = data?.user;
if (!fresh || !user) return;
if (
fresh.id === user.id &&
fresh.name === user.name &&
fresh.email === user.email &&
fresh.role === user.role &&
fresh.xp === user.xp &&
fresh.hearts === user.hearts &&
fresh.streakCurrent === user.streakCurrent &&
fresh.streakLongest === user.streakLongest &&
fresh.isLocked === user.isLocked &&
fresh.isActive === user.isActive &&
fresh.avatarVariant === user.avatarVariant &&
fresh.avatarPalette === user.avatarPalette &&
fresh.avatarPhoto === user.avatarPhoto
) {
return;
}
if (!fresh) return;
const current = useAuth.getState().user;
if (current && sameLiveUser(fresh, current)) return;
setUser(fresh);
}, [data, user, setUser]);
}, [data, setUser]);
return data?.user ?? user;
// Prefer the auth store. A just-saved avatar must show before the ["me"]
// cache catches up; applying that stale cache used to put the old photo back.
return user ?? data?.user ?? null;
}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth-store";
import { getWsUrl } from "@/lib/api";
@@ -24,6 +25,7 @@ export function useRealtimeGateway() {
const accessToken = useAuth((s) => s.accessToken);
const user = useAuth((s) => s.user);
const client = useQueryClient();
const router = useRouter();
const lastEvent = useRef<Record<string, number>>({});
useEffect(() => {
@@ -55,7 +57,31 @@ export function useRealtimeGateway() {
if (stamp - (lastEvent.current[dedupeKey] ?? 0) < 2000) return;
lastEvent.current[dedupeKey] = stamp;
toast.info(`${n.title}${n.body ? ` — ${n.body}` : ""}`, { duration: 5000 });
const title = n.title?.trim() || "اعلان";
const body = n.body?.trim();
toast.custom(
(id) => (
<button
type="button"
dir="rtl"
onClick={() => {
toast.dismiss(id);
if (n.route) router.push(n.route);
}}
className="w-[min(22rem,calc(100vw-1.5rem))] rounded-xl border border-primary/40 bg-gradient-to-br from-primary/25 via-primary/12 to-accent/20 px-3 py-2.5 text-start shadow-[0_16px_40px_-16px_color-mix(in_srgb,var(--color-primary)_75%,transparent)] backdrop-blur-md"
>
<span dir="auto" className="block truncate text-sm font-semibold text-foreground [unicode-bidi:plaintext]">
{title}
</span>
{body ? (
<span dir="auto" className="mt-0.5 block line-clamp-2 text-xs text-foreground/75 [unicode-bidi:plaintext]">
{body}
</span>
) : null}
</button>
),
{ duration: 5000 },
);
client.invalidateQueries({ queryKey: ["notifications"] });
client.invalidateQueries({ queryKey: ["me"] });
}
@@ -76,7 +102,7 @@ export function useRealtimeGateway() {
closed = true;
ws?.close();
};
}, [accessToken, user, client]);
}, [accessToken, user, client, router]);
return null;
}