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
+159
View File
@@ -0,0 +1,159 @@
/** Wide profile-header artwork. Palettes come from the avatar studio; these fills stretch them across a 3:1 banner. */
export const BANNER_WIDTH = 1200;
export const BANNER_HEIGHT = 400;
export type BannerFill = "gradient" | "stripes" | "dots" | "diamonds" | "pixel" | "waves";
export const BANNER_FILLS: { id: BannerFill; label: string }[] = [
{ id: "gradient", label: "گرادیان" },
{ id: "stripes", label: "راه‌راه" },
{ id: "dots", label: "نقطه‌ای" },
{ id: "diamonds", label: "لوزی" },
{ id: "pixel", label: "پیکسلی" },
{ id: "waves", label: "موج" },
];
function hash(seed: string): number {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function mulberry32(seed: number) {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function palette(colors: string[]): string[] {
return colors.length >= 2 ? colors : ["#7d6df2", "#0f9b8e"];
}
export function drawBannerFill(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
colors: string[],
fill: BannerFill,
seedKey: string,
) {
const c = palette(colors);
ctx.clearRect(0, 0, w, h);
ctx.globalAlpha = 1;
if (fill === "gradient") {
const g = ctx.createLinearGradient(0, h, w, 0);
c.forEach((color, i) => g.addColorStop(i / (c.length - 1), color));
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
const glow = ctx.createRadialGradient(w * 0.5, h * 0.42, h * 0.04, w * 0.5, h * 0.42, w * 0.46);
glow.addColorStop(0, "rgba(255,255,255,0.22)");
glow.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
return;
}
ctx.fillStyle = c[0];
ctx.fillRect(0, 0, w, h);
if (fill === "stripes") {
const stripe = Math.max(14, w / 26);
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.rotate(-Math.PI / 7);
const span = w + h;
let i = 0;
for (let x = -span; x < span; x += stripe) {
ctx.globalAlpha = 0.88;
ctx.fillStyle = c[i % c.length];
ctx.fillRect(x, -span, stripe * 0.58, span * 2);
i++;
}
ctx.restore();
return;
}
if (fill === "dots") {
const gap = Math.max(22, w / 28);
const radius = gap * 0.22;
let row = 0;
for (let y = gap * 0.55; y < h + gap; y += gap * 0.86) {
const offset = row % 2 ? gap / 2 : 0;
let col = 0;
for (let x = offset; x < w + gap; x += gap) {
ctx.beginPath();
ctx.fillStyle = c[(row + col) % c.length];
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
col++;
}
row++;
}
return;
}
if (fill === "diamonds") {
const gap = Math.max(28, w / 18);
const s = gap * 0.42;
let row = 0;
for (let y = gap * 0.2; y < h + gap; y += gap * 0.72) {
const offset = row % 2 ? gap / 2 : 0;
let col = 0;
for (let x = offset; x < w + gap; x += gap) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(Math.PI / 4);
ctx.fillStyle = c[(row + col) % c.length];
ctx.fillRect(-s / 2, -s / 2, s, s);
ctx.restore();
col++;
}
row++;
}
return;
}
if (fill === "pixel") {
const cols = 18;
const rows = 6;
const cw = w / cols;
const ch = h / rows;
const rand = mulberry32(hash(`${seedKey}:pixel`));
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
ctx.fillStyle = c[Math.floor(rand() * c.length)];
ctx.fillRect(Math.floor(x * cw), Math.floor(y * ch), Math.ceil(cw) + 1, Math.ceil(ch) + 1);
}
}
return;
}
const bands = c.length;
for (let b = 0; b < bands; b++) {
const amp = h * (0.055 + (b % 3) * 0.012);
const freq = 1.15 + (b % 4) * 0.35;
const base = h * (0.16 + (b / bands) * 0.72);
ctx.beginPath();
ctx.moveTo(0, h);
ctx.lineTo(0, base);
for (let x = 0; x <= w; x += 8) {
const y = base + Math.sin((x / w) * Math.PI * 2 * freq + b * 0.85) * amp;
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.globalAlpha = b === 0 ? 0.45 : 0.94;
ctx.fillStyle = c[b];
ctx.fill();
}
ctx.globalAlpha = 1;
}
+2 -2
View File
@@ -2,8 +2,8 @@
import type { Role, User } from "./types";
/** Hard cap for students — no timed refill. */
export const MAX_HEARTS = 3;
/** Absolute student cap. A heart returns on the server every few hours. */
export const MAX_HEARTS = 5;
/** Infinity glyph for staff heart display. */
export const INFINITE_HEARTS_LABEL = "∞";
+104
View File
@@ -169,7 +169,111 @@ export async function saveRemoteFile(
triggerDownload(blob, filename);
}
function crc32(data: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < data.length; i++) {
c ^= data[i];
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
}
return (c ^ 0xffffffff) >>> 0;
}
function uniqueZipName(name: string, used: Map<string, number>): string {
const base = (name || "file").split(/[/\\]/).pop() || "file";
const clean = base.replace(/[^\w.\-\u0600-\u06FF ]+/g, "_").slice(0, 80) || "file";
const n = used.get(clean) ?? 0;
used.set(clean, n + 1);
if (n === 0) return clean;
const dot = clean.lastIndexOf(".");
if (dot > 0) return `${clean.slice(0, dot)}-${n + 1}${clean.slice(dot)}`;
return `${clean}-${n + 1}`;
}
/** Uncompressed zip so several chat files save as one download. */
function buildStoredZip(files: { name: string; data: Uint8Array }[]): Blob {
const enc = new TextEncoder();
const now = new Date();
const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
const locals: Uint8Array[] = [];
const central: Uint8Array[] = [];
let offset = 0;
for (const file of files) {
const name = enc.encode(file.name);
const crc = crc32(file.data);
const local = new Uint8Array(30 + name.length);
const view = new DataView(local.buffer);
view.setUint32(0, 0x04034b50, true);
view.setUint16(4, 20, true);
view.setUint16(6, 0x800, true);
view.setUint16(8, 0, true);
view.setUint16(10, dosTime, true);
view.setUint16(12, dosDate, true);
view.setUint32(14, crc, true);
view.setUint32(18, file.data.length, true);
view.setUint32(22, file.data.length, true);
view.setUint16(26, name.length, true);
local.set(name, 30);
locals.push(local, file.data);
const cen = new Uint8Array(46 + name.length);
const cenView = new DataView(cen.buffer);
cenView.setUint32(0, 0x02014b50, true);
cenView.setUint16(4, 20, true);
cenView.setUint16(6, 20, true);
cenView.setUint16(8, 0x800, true);
cenView.setUint16(10, 0, true);
cenView.setUint16(12, dosTime, true);
cenView.setUint16(14, dosDate, true);
cenView.setUint32(16, crc, true);
cenView.setUint32(20, file.data.length, true);
cenView.setUint32(24, file.data.length, true);
cenView.setUint16(28, name.length, true);
cenView.setUint32(42, offset, true);
cen.set(name, 46);
central.push(cen);
offset += local.length + file.data.length;
}
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
const end = new Uint8Array(22);
const endView = new DataView(end.buffer);
endView.setUint32(0, 0x06054b50, true);
endView.setUint16(8, files.length, true);
endView.setUint16(10, files.length, true);
endView.setUint32(12, centralSize, true);
endView.setUint32(16, offset, true);
const parts = [...locals, ...central, end].map((part) => {
const copy = new ArrayBuffer(part.byteLength);
new Uint8Array(copy).set(part);
return copy;
});
return new Blob(parts, { type: "application/zip" });
}
/** Save every file in one zip. */
export async function downloadAllAsZip(
items: { url: string; name: string }[],
archiveName: string,
onProgress?: (ratio: number) => void,
): Promise<void> {
const used = new Map<string, number>();
const files: { name: string; data: Uint8Array }[] = [];
for (let i = 0; i < items.length; i++) {
onProgress?.(i / Math.max(items.length, 1));
const hit = await readCachedMedia(items[i].url);
const blob = hit ?? (await fetchBlob(items[i].url));
if (!hit) await writeCachedMedia(items[i].url, blob);
files.push({ name: uniqueZipName(items[i].name, used), data: new Uint8Array(await blob.arrayBuffer()) });
}
onProgress?.(1);
triggerDownload(buildStoredZip(files), archiveName || "files.zip");
}
/** One network fetch per object. Later views reuse the saved bytes until the object is replaced. */
export async function clearMediaCache(): Promise<void> {
if (typeof caches === "undefined") return;
await caches.delete(mediaCacheName);
}
export async function loadCachedMedia(url: string): Promise<Blob> {
const hit = await readCachedMedia(url);
if (hit) return hit;
+29 -6
View File
@@ -21,6 +21,13 @@ export interface User {
securityQuestion?: string;
hasSecurityAnswer?: boolean;
createdAt: string;
usernameChangesRemaining?: number;
usernameCooldownUntil?: string;
frozenAt?: string;
closedAt?: string;
closesAt?: string;
isFrozen?: boolean;
isClosed?: boolean;
}
export interface Certificate {
@@ -195,7 +202,9 @@ export interface ChatMessage {
pinned?: boolean;
editedAt?: string;
attachment?: ChatAttachment;
attachments?: ChatAttachment[];
reactions?: ChatReaction[];
buttons?: { text: string }[];
}
export interface ChatReaction {
@@ -210,6 +219,7 @@ export interface Mentor {
email: string;
role: Role;
online?: boolean;
lastSeen?: string;
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
avatarPalette?: string;
avatarPhoto?: string;
@@ -222,12 +232,18 @@ export interface Conversation {
email: string;
role: Role;
online?: boolean;
lastSeen?: string;
saved?: boolean;
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
avatarPalette?: string;
avatarPhoto?: string;
};
lastMessage?: ChatMessage;
unreadCount: number;
messageCount?: number;
pinnedRank: number | null;
muted: boolean;
archived: boolean;
}
export interface LeaderboardEntry {
@@ -268,16 +284,23 @@ export interface MCQQuestion {
export interface PublicProfile {
id: number;
name: string;
username: string;
username?: string;
role: Role;
xp: number;
hearts: number;
streakCurrent: number;
streakLongest: number;
xp?: number;
hearts?: number;
streakCurrent?: number;
streakLongest?: number;
avatarVariant?: User["avatarVariant"];
avatarPalette?: string;
avatarPhoto?: string;
isLocked: boolean;
isLocked?: boolean;
isFrozen?: boolean;
isClosed?: boolean;
private?: boolean;
public?: boolean;
banner?: string;
createdAt?: string;
socialLinks?: { name: string; url: string }[];
}
export interface AdminStats {
+11
View File
@@ -22,6 +22,17 @@ export function formatStreakLabel(days: number) {
/** Quiz option letters: الف، ب، ج، د، … */
const FA_OPTION_LETTERS = ["الف", "ب", "ج", "د", "ه", "و"] as const;
/** Jalali day plus clock, for conversation rows. */
export function formatJalaliStamp(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return "";
}
const day = new Intl.DateTimeFormat("fa-IR", { day: "numeric", month: "short" }).format(d);
const time = new Intl.DateTimeFormat("fa-IR", { hour: "2-digit", minute: "2-digit" }).format(d);
return `${day} · ${time}`;
}
export function formatOptionLetter(index: number): string {
if (index >= 0 && index < FA_OPTION_LETTERS.length) {
return FA_OPTION_LETTERS[index];