From e778e8b5bd81eb0bf11a12ad00b9f76fee23df4c Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 18:26:22 +0330 Subject: [PATCH] Server-backed friends, chat, IAB scaffold + EF migrations/Postgres - Social: EF-backed friends graph + chat (SocialService/SocialModels); REST endpoints (friends add/accept/decline/remove/list/requests, chat conversations/messages/send) with real-time hub events (friendRequest/social/chat). GameManager tracks online users for presence. - Client SignalrService: friends + chat now hit the server and react to hub events (refetch + emit); no longer delegated to the mock. - IAB: /api/coins/iab/verify endpoint + IabVerifyReq for Cafe Bazaar/Myket (token verification is a documented TODO pending store accounts/SKUs). - Persistence: EF Core Design package + DesignTimeDbContextFactory (Postgres), Program auto-migrate/EnsureCreated, appsettings.Production.json.example with Supabase connection + live ZarinPal template. Verified end-to-end (two users, SQLite dev): request -> accept -> bidirectional friends, chat send with per-user fromMe, unread count + read-on-fetch. Server + client builds clean (dotnet build, tsc, next build). Co-Authored-By: Claude Opus 4.8 --- server/src/Hokm.Server/Data/AppDbContext.cs | 35 ++++ .../Data/DesignTimeDbContextFactory.cs | 22 +++ server/src/Hokm.Server/Game/GameManager.cs | 10 +- server/src/Hokm.Server/Hokm.Server.csproj | 1 + server/src/Hokm.Server/Program.cs | 58 ++++++- server/src/Hokm.Server/Social/SocialModels.cs | 34 ++++ .../src/Hokm.Server/Social/SocialService.cs | 150 ++++++++++++++++++ .../appsettings.Production.json.example | 21 +++ src/lib/online/signalr-service.ts | 67 ++++++-- 9 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 server/src/Hokm.Server/Data/DesignTimeDbContextFactory.cs create mode 100644 server/src/Hokm.Server/Social/SocialModels.cs create mode 100644 server/src/Hokm.Server/Social/SocialService.cs create mode 100644 server/src/Hokm.Server/appsettings.Production.json.example 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);