diff --git a/server/src/Hokm.Server/Data/AppDbContext.cs b/server/src/Hokm.Server/Data/AppDbContext.cs
index 9c1e3a9..6548881 100644
--- a/server/src/Hokm.Server/Data/AppDbContext.cs
+++ b/server/src/Hokm.Server/Data/AppDbContext.cs
@@ -21,17 +21,52 @@ public class LedgerRow
public DateTime CreatedAt { get; set; }
}
+/// An undirected friendship, stored as two rows (a→b and b→a).
+public class FriendEdgeRow
+{
+ public long Id { get; set; }
+ public string UserId { get; set; } = "";
+ public string FriendId { get; set; } = "";
+}
+
+public class FriendRequestRow
+{
+ public long Id { get; set; }
+ public string FromUserId { get; set; } = "";
+ public string ToUserId { get; set; } = "";
+ public DateTime CreatedAt { get; set; }
+}
+
+public class MessageRow
+{
+ public long Id { get; set; }
+ public string UserId { get; set; } = ""; // sender
+ public string PeerId { get; set; } = ""; // recipient
+ public string Text { get; set; } = "";
+ public DateTime CreatedAt { get; set; }
+ public bool ReadByPeer { get; set; }
+}
+
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions options) : base(options) { }
public DbSet Profiles => Set();
public DbSet Ledger => Set();
+ public DbSet Friends => Set();
+ public DbSet FriendRequests => Set();
+ public DbSet Messages => Set();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity().HasKey(p => p.Id);
b.Entity().HasKey(l => l.Id);
b.Entity().HasIndex(l => l.UserId);
+ b.Entity().HasKey(f => f.Id);
+ b.Entity().HasIndex(f => f.UserId);
+ b.Entity().HasKey(r => r.Id);
+ b.Entity().HasIndex(r => r.ToUserId);
+ b.Entity().HasKey(m => m.Id);
+ b.Entity().HasIndex(m => new { m.UserId, m.PeerId });
}
}
diff --git a/server/src/Hokm.Server/Data/DesignTimeDbContextFactory.cs b/server/src/Hokm.Server/Data/DesignTimeDbContextFactory.cs
new file mode 100644
index 0000000..8b5493f
--- /dev/null
+++ b/server/src/Hokm.Server/Data/DesignTimeDbContextFactory.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Hokm.Server.Data;
+
+///
+/// Used by `dotnet ef migrations add ...` to build the context at design time.
+/// Targets Postgres (prod). Set HOKM_DESIGN_CONN to override the connection.
+/// dotnet ef migrations add InitialCreate -p src/Hokm.Server
+///
+public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory
+{
+ public AppDbContext CreateDbContext(string[] args)
+ {
+ var conn = Environment.GetEnvironmentVariable("HOKM_DESIGN_CONN")
+ ?? "Host=localhost;Database=hokm;Username=postgres;Password=postgres";
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(conn)
+ .Options;
+ return new AppDbContext(options);
+ }
+}
diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs
index f6153dc..84afd17 100644
--- a/server/src/Hokm.Server/Game/GameManager.cs
+++ b/server/src/Hokm.Server/Game/GameManager.cs
@@ -141,18 +141,20 @@ public sealed class GameManager
public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit);
public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction);
- private int _online;
- public int OnlineCount => Volatile.Read(ref _online);
+ private readonly ConcurrentDictionary _onlineUsers = new();
+ public int OnlineCount => _onlineUsers.Count;
+ public bool IsOnline(string userId) => _onlineUsers.ContainsKey(userId);
public void OnConnected(string userId)
{
- Interlocked.Increment(ref _online);
+ _onlineUsers.AddOrUpdate(userId, 1, (_, n) => n + 1);
RoomOf(userId)?.SetConnected(userId, true);
}
public void OnDisconnected(string userId)
{
- if (Interlocked.Decrement(ref _online) < 0) Interlocked.Exchange(ref _online, 0);
+ if (_onlineUsers.AddOrUpdate(userId, 0, (_, n) => n - 1) <= 0)
+ _onlineUsers.TryRemove(userId, out _);
CancelMatchmaking(userId);
RoomOf(userId)?.SetConnected(userId, false);
}
diff --git a/server/src/Hokm.Server/Hokm.Server.csproj b/server/src/Hokm.Server/Hokm.Server.csproj
index db36831..df7b5b7 100644
--- a/server/src/Hokm.Server/Hokm.Server.csproj
+++ b/server/src/Hokm.Server/Hokm.Server.csproj
@@ -13,6 +13,7 @@
+
diff --git a/server/src/Hokm.Server/Program.cs b/server/src/Hokm.Server/Program.cs
index c1a2750..b8b7ba8 100644
--- a/server/src/Hokm.Server/Program.cs
+++ b/server/src/Hokm.Server/Program.cs
@@ -7,6 +7,7 @@ using Hokm.Server.Game;
using Hokm.Server.Hubs;
using Hokm.Server.Payments;
using Hokm.Server.Profiles;
+using Hokm.Server.Social;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@@ -32,6 +33,7 @@ builder.Services.AddDbContext(o =>
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
});
builder.Services.AddScoped();
+builder.Services.AddScoped();
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
var zp = builder.Configuration.GetSection("Zarinpal").Get() ?? new ZarinpalOptions();
@@ -90,7 +92,13 @@ builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
var app = builder.Build();
using (var scope = app.Services.CreateScope())
- scope.ServiceProvider.GetRequiredService().Database.EnsureCreated();
+{
+ var db = scope.ServiceProvider.GetRequiredService();
+ // Use EF migrations once any exist (prod/Postgres); otherwise create the
+ // schema directly (dev/SQLite, or before the first migration is generated).
+ if (db.Database.GetMigrations().Any()) db.Database.Migrate();
+ else db.Database.EnsureCreated();
+}
app.UseCors();
app.UseAuthentication();
@@ -172,6 +180,17 @@ app.MapGet("/api/coins/pay/callback", async (string? authority, string? status,
return Results.Redirect($"{zp.ClientReturnUrl}/?pay=failed");
});
+// Store in-app purchase (Cafe Bazaar / Myket): the native app sends the purchase
+// token; we credit the matching pack. (SKU == packId for now.)
+app.MapPost("/api/coins/iab/verify", async (ClaimsPrincipal u, ProfileService svc, IabVerifyReq req) =>
+{
+ // TODO: verify req.Token with Cafe Bazaar (Pardakht/Poolakey) or Myket dev API.
+ var (ok, p, coins) = await svc.BuyCoins(Uid(u), req.ProductId);
+ return ok
+ ? Results.Json(new { ok, profile = p, coins }, JsonOpts.Default)
+ : Results.BadRequest(new { ok = false });
+}).RequireAuthorization();
+
app.MapGet("/api/daily", async (ClaimsPrincipal u, ProfileService svc) =>
{
var (day, last, avail) = await svc.GetDaily(Uid(u));
@@ -192,6 +211,39 @@ app.MapPost("/api/shop/buy", async (ClaimsPrincipal u, ProfileService svc, ShopB
: Results.BadRequest(new { ok = false, error = err });
}).RequireAuthorization();
+// --- friends + chat (server-persisted, real-time over the hub) ---
+app.MapGet("/api/friends", async (ClaimsPrincipal u, SocialService s) =>
+ Results.Json(await s.ListFriends(Uid(u)), JsonOpts.Default)).RequireAuthorization();
+app.MapGet("/api/friends/requests", async (ClaimsPrincipal u, SocialService s) =>
+ Results.Json(await s.ListRequests(Uid(u)), JsonOpts.Default)).RequireAuthorization();
+app.MapPost("/api/friends/add", async (ClaimsPrincipal u, SocialService s, QueryReq r) =>
+{
+ var (ok, fa, en) = await s.AddFriend(Uid(u), r.Query);
+ return Results.Json(new { ok, messageFa = fa, messageEn = en }, JsonOpts.Default);
+}).RequireAuthorization();
+app.MapPost("/api/friends/accept", async (ClaimsPrincipal u, SocialService s, IdReq r) =>
+{
+ if (long.TryParse(r.Id, out var id)) await s.Accept(Uid(u), id);
+ return Results.Ok();
+}).RequireAuthorization();
+app.MapPost("/api/friends/decline", async (ClaimsPrincipal u, SocialService s, IdReq r) =>
+{
+ if (long.TryParse(r.Id, out var id)) await s.Decline(Uid(u), id);
+ return Results.Ok();
+}).RequireAuthorization();
+app.MapPost("/api/friends/remove", async (ClaimsPrincipal u, SocialService s, IdReq r) =>
+{
+ await s.Remove(Uid(u), r.Id);
+ return Results.Ok();
+}).RequireAuthorization();
+
+app.MapGet("/api/chat", async (ClaimsPrincipal u, SocialService s) =>
+ Results.Json(await s.Conversations(Uid(u)), JsonOpts.Default)).RequireAuthorization();
+app.MapGet("/api/chat/messages", async (ClaimsPrincipal u, SocialService s, string peer) =>
+ Results.Json(await s.Messages(Uid(u), peer), JsonOpts.Default)).RequireAuthorization();
+app.MapPost("/api/chat/send", async (ClaimsPrincipal u, SocialService s, SendReq r) =>
+ Results.Json(await s.Send(Uid(u), r.PeerId, r.Text), JsonOpts.Default)).RequireAuthorization();
+
app.MapHub("/hub/game");
app.Run();
@@ -201,3 +253,7 @@ record OtpVerify(string Phone, string Code, string? Name);
record EmailLogin(string Email, string Password, string? Name);
record BuyReq(string PackId);
record ShopBuyReq(string Kind, string Id, int Price);
+record IabVerifyReq(string Store, string ProductId, string Token);
+record QueryReq(string Query);
+record IdReq(string Id);
+record SendReq(string PeerId, string Text);
diff --git a/server/src/Hokm.Server/Social/SocialModels.cs b/server/src/Hokm.Server/Social/SocialModels.cs
new file mode 100644
index 0000000..ed80f54
--- /dev/null
+++ b/server/src/Hokm.Server/Social/SocialModels.cs
@@ -0,0 +1,34 @@
+namespace Hokm.Server.Social;
+
+public class FriendDto
+{
+ public string Id { get; set; } = "";
+ public string Username { get; set; } = "";
+ public string DisplayName { get; set; } = "";
+ public string Avatar { get; set; } = "a-fox";
+ public int Level { get; set; }
+ public int Rating { get; set; }
+ public string Status { get; set; } = "offline"; // online | offline
+}
+
+public class FriendRequestDto
+{
+ public string Id { get; set; } = "";
+ public FriendDto From { get; set; } = new();
+ public long CreatedAt { get; set; }
+}
+
+public class ChatMessageDto
+{
+ public string Id { get; set; } = "";
+ public bool FromMe { get; set; }
+ public string Text { get; set; } = "";
+ public long Ts { get; set; }
+}
+
+public class ConversationDto
+{
+ public FriendDto Friend { get; set; } = new();
+ public ChatMessageDto? LastMessage { get; set; }
+ public int Unread { get; set; }
+}
diff --git a/server/src/Hokm.Server/Social/SocialService.cs b/server/src/Hokm.Server/Social/SocialService.cs
new file mode 100644
index 0000000..e5d8e07
--- /dev/null
+++ b/server/src/Hokm.Server/Social/SocialService.cs
@@ -0,0 +1,150 @@
+using System.Text.Json;
+using Hokm.Server.Data;
+using Hokm.Server.Game;
+using Hokm.Server.Hubs;
+using Hokm.Server.Profiles;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Hokm.Server.Social;
+
+public class SocialService
+{
+ private readonly AppDbContext _db;
+ private readonly GameManager _mgr;
+ private readonly IHubContext _hub;
+
+ public SocialService(AppDbContext db, GameManager mgr, IHubContext hub)
+ {
+ _db = db;
+ _mgr = mgr;
+ _hub = hub;
+ }
+
+ private async Task FriendDtoFor(string userId)
+ {
+ var row = await _db.Profiles.FindAsync(userId);
+ var p = row != null ? JsonSerializer.Deserialize(row.Json, JsonOpts.Default) : null;
+ return new FriendDto
+ {
+ Id = userId,
+ Username = p?.Username ?? userId,
+ DisplayName = p?.DisplayName ?? userId,
+ Avatar = p?.Avatar ?? "a-fox",
+ Level = p?.Level ?? 1,
+ Rating = p?.Rating ?? 1000,
+ Status = _mgr.IsOnline(userId) ? "online" : "offline",
+ };
+ }
+
+ /* ----------------------------- friends ----------------------------- */
+
+ public async Task> ListFriends(string uid)
+ {
+ var ids = await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync();
+ var list = new List();
+ foreach (var id in ids) list.Add(await FriendDtoFor(id));
+ return list;
+ }
+
+ public async Task> ListRequests(string uid)
+ {
+ var reqs = await _db.FriendRequests.Where(r => r.ToUserId == uid).ToListAsync();
+ var list = new List();
+ foreach (var r in reqs)
+ list.Add(new FriendRequestDto { Id = r.Id.ToString(), From = await FriendDtoFor(r.FromUserId), CreatedAt = new DateTimeOffset(r.CreatedAt).ToUnixTimeMilliseconds() });
+ return list;
+ }
+
+ public async Task<(bool ok, string messageFa, string messageEn)> AddFriend(string uid, string query)
+ {
+ var digits = new string(query.Where(char.IsDigit).ToArray());
+ var targetId = query.Contains(':') ? query.Trim() : (digits.Length >= 4 ? "phone:" + digits : query.Trim());
+
+ var target = await _db.Profiles.FindAsync(targetId);
+ if (target == null || targetId == uid)
+ return (false, "کاربر پیدا نشد", "User not found");
+ if (await _db.Friends.AnyAsync(f => f.UserId == uid && f.FriendId == targetId))
+ return (false, "از قبل دوست هستید", "Already friends");
+ if (!await _db.FriendRequests.AnyAsync(r => r.FromUserId == uid && r.ToUserId == targetId))
+ {
+ _db.FriendRequests.Add(new FriendRequestRow { FromUserId = uid, ToUserId = targetId, CreatedAt = DateTime.UtcNow });
+ await _db.SaveChangesAsync();
+ await _hub.Clients.User(targetId).SendAsync("friendRequest", await FriendDtoFor(uid));
+ }
+ return (true, "درخواست دوستی ارسال شد", "Friend request sent");
+ }
+
+ public async Task Accept(string uid, long requestId)
+ {
+ var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid);
+ if (req == null) return;
+ _db.Friends.Add(new FriendEdgeRow { UserId = uid, FriendId = req.FromUserId });
+ _db.Friends.Add(new FriendEdgeRow { UserId = req.FromUserId, FriendId = uid });
+ _db.FriendRequests.Remove(req);
+ await _db.SaveChangesAsync();
+ await _hub.Clients.User(req.FromUserId).SendAsync("social", "friend-added");
+ }
+
+ public async Task Decline(string uid, long requestId)
+ {
+ var req = await _db.FriendRequests.FirstOrDefaultAsync(r => r.Id == requestId && r.ToUserId == uid);
+ if (req != null) { _db.FriendRequests.Remove(req); await _db.SaveChangesAsync(); }
+ }
+
+ public async Task Remove(string uid, string friendId)
+ {
+ var edges = await _db.Friends.Where(f =>
+ (f.UserId == uid && f.FriendId == friendId) || (f.UserId == friendId && f.FriendId == uid)).ToListAsync();
+ _db.Friends.RemoveRange(edges);
+ await _db.SaveChangesAsync();
+ await _hub.Clients.User(friendId).SendAsync("social", "friend-removed");
+ }
+
+ /* ------------------------------- chat ------------------------------ */
+
+ public async Task> Conversations(string uid)
+ {
+ var msgs = await _db.Messages.Where(m => m.UserId == uid || m.PeerId == uid).ToListAsync();
+ var byPartner = msgs.GroupBy(m => m.UserId == uid ? m.PeerId : m.UserId);
+ var convs = new List();
+ foreach (var g in byPartner)
+ {
+ var last = g.OrderByDescending(m => m.CreatedAt).First();
+ convs.Add(new ConversationDto
+ {
+ Friend = await FriendDtoFor(g.Key),
+ LastMessage = ToDto(last, uid),
+ Unread = g.Count(m => m.PeerId == uid && !m.ReadByPeer),
+ });
+ }
+ return convs.OrderByDescending(c => c.LastMessage?.Ts ?? 0).ToList();
+ }
+
+ public async Task> Messages(string uid, string peerId)
+ {
+ var msgs = await _db.Messages
+ .Where(m => (m.UserId == uid && m.PeerId == peerId) || (m.UserId == peerId && m.PeerId == uid))
+ .OrderBy(m => m.CreatedAt).ToListAsync();
+ var unread = msgs.Where(m => m.UserId == peerId && m.PeerId == uid && !m.ReadByPeer).ToList();
+ if (unread.Count > 0) { unread.ForEach(m => m.ReadByPeer = true); await _db.SaveChangesAsync(); }
+ return msgs.Select(m => ToDto(m, uid)).ToList();
+ }
+
+ public async Task Send(string uid, string peerId, string text)
+ {
+ var m = new MessageRow { UserId = uid, PeerId = peerId, Text = text.Trim(), CreatedAt = DateTime.UtcNow };
+ _db.Messages.Add(m);
+ await _db.SaveChangesAsync();
+ await _hub.Clients.User(peerId).SendAsync("chat", new { peerId = uid });
+ return ToDto(m, uid);
+ }
+
+ private static ChatMessageDto ToDto(MessageRow m, string uid) => new()
+ {
+ Id = m.Id.ToString(),
+ FromMe = m.UserId == uid,
+ Text = m.Text,
+ Ts = new DateTimeOffset(m.CreatedAt).ToUnixTimeMilliseconds(),
+ };
+}
diff --git a/server/src/Hokm.Server/appsettings.Production.json.example b/server/src/Hokm.Server/appsettings.Production.json.example
new file mode 100644
index 0000000..cea3bb9
--- /dev/null
+++ b/server/src/Hokm.Server/appsettings.Production.json.example
@@ -0,0 +1,21 @@
+{
+ "// note": "Copy to appsettings.Production.json and fill secrets. Run with ASPNETCORE_ENVIRONMENT=Production.",
+ "Jwt": {
+ "Key": "CHANGE-ME-to-a-long-random-secret-32+chars",
+ "Issuer": "hokm",
+ "Audience": "hokm-clients"
+ },
+ "Database": {
+ "Provider": "postgres"
+ },
+ "ConnectionStrings": {
+ "// Supabase": "Project Settings → Database → Connection string (use the pooled 6543 or direct 5432)",
+ "Default": "Host=db..supabase.co;Port=5432;Database=postgres;Username=postgres;Password=;SSL Mode=Require;Trust Server Certificate=true"
+ },
+ "Zarinpal": {
+ "MerchantId": "",
+ "Sandbox": false,
+ "CallbackUrl": "https://api.yourdomain.com/api/coins/pay/callback",
+ "ClientReturnUrl": "https://yourdomain.com"
+ }
+}
diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts
index 00632d6..165f8c8 100644
--- a/src/lib/online/signalr-service.ts
+++ b/src/lib/online/signalr-service.ts
@@ -53,6 +53,8 @@ export class SignalrService implements OnlineService {
private notifCbs = new Set<(n: AppNotification) => void>();
private profileCbs = new Set<(p: UserProfile) => void>();
private rewardCbs = new Set<(r: RewardResult) => void>();
+ private friendCbs = new Set<(f: Friend[]) => void>();
+ private chatCbs = new Set<(id: string, m: ChatMessage[]) => void>();
private cachedProfile: UserProfile | null = null;
private mockNotifUnsub?: () => void;
@@ -122,6 +124,23 @@ export class SignalrService implements OnlineService {
this.profileCbs.forEach((cb) => cb(p));
});
conn.on("reward", (r: RewardResult) => this.rewardCbs.forEach((cb) => cb(r)));
+ conn.on("friendRequest", (from: Friend) => {
+ this.notifCbs.forEach((cb) =>
+ cb({
+ id: `n_fr_${from.id}`,
+ kind: "friend_request",
+ titleFa: "درخواست دوستی جدید",
+ titleEn: "New friend request",
+ bodyFa: `${from.displayName} میخواهد با شما دوست شود`,
+ bodyEn: `${from.displayName} wants to be your friend`,
+ icon: "👥",
+ ts: Date.now(),
+ read: false,
+ } as AppNotification));
+ void this.refreshFriends();
+ });
+ conn.on("social", () => void this.refreshFriends());
+ conn.on("chat", (m: { peerId: string }) => void this.emitChat(m.peerId));
this.conn = conn;
try {
@@ -306,13 +325,33 @@ export class SignalrService implements OnlineService {
return p;
}
- listFriends() { return this.mock.listFriends(); }
- listRequests() { return this.mock.listRequests(); }
- addFriend(q: string) { return this.mock.addFriend(q); }
- acceptRequest(id: string) { return this.mock.acceptRequest(id); }
- declineRequest(id: string) { return this.mock.declineRequest(id); }
- removeFriend(id: string) { return this.mock.removeFriend(id); }
- onFriends(cb: (f: Friend[]) => void) { return this.mock.onFriends(cb); }
+ private async refreshFriends() {
+ try {
+ const f = await this.listFriends();
+ this.friendCbs.forEach((cb) => cb(f));
+ } catch {
+ /* ignore */
+ }
+ }
+ private async emitChat(peerId: string) {
+ try {
+ const m = await this.getMessages(peerId);
+ this.chatCbs.forEach((cb) => cb(peerId, m));
+ } catch {
+ /* ignore */
+ }
+ }
+
+ listFriends() { return this.getJson("/api/friends"); }
+ listRequests() { return this.getJson("/api/friends/requests"); }
+ addFriend(q: string) {
+ return this.send<{ ok: boolean; messageFa: string; messageEn: string }>(
+ "POST", "/api/friends/add", { query: q });
+ }
+ async acceptRequest(id: string) { await this.send("POST", "/api/friends/accept", { id }); }
+ async declineRequest(id: string) { await this.send("POST", "/api/friends/decline", { id }); }
+ async removeFriend(id: string) { await this.send("POST", "/api/friends/remove", { id }); }
+ onFriends(cb: (f: Friend[]) => void) { this.friendCbs.add(cb); return () => this.friendCbs.delete(cb); }
createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); }
setPartner(roomId: string, friendId: string | null) { return this.mock.setPartner(roomId, friendId); }
@@ -323,11 +362,15 @@ export class SignalrService implements OnlineService {
leaveRoom(roomId: string) { return this.mock.leaveRoom(roomId); }
onRoom(cb: (r: Room) => void) { return this.mock.onRoom(cb); }
- listConversations(): Promise { return this.mock.listConversations(); }
- getMessages(id: string): Promise { return this.mock.getMessages(id); }
- sendMessage(id: string, text: string) { return this.mock.sendMessage(id, text); }
- markRead(id: string) { return this.mock.markRead(id); }
- onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); }
+ listConversations(): Promise { return this.getJson("/api/chat"); }
+ getMessages(id: string): Promise {
+ return this.getJson(`/api/chat/messages?peer=${encodeURIComponent(id)}`);
+ }
+ sendMessage(id: string, text: string) {
+ return this.send("POST", "/api/chat/send", { peerId: id, text });
+ }
+ async markRead() { /* server marks read when messages are fetched */ }
+ onChat(cb: (id: string, m: ChatMessage[]) => void) { this.chatCbs.add(cb); return () => this.chatCbs.delete(cb); }
onNotification(cb: (n: AppNotification) => void): Unsubscribe {
this.notifCbs.add(cb);