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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 18:26:22 +03:30
parent cfed2950b2
commit e778e8b5bd
9 changed files with 381 additions and 17 deletions
@@ -21,17 +21,52 @@ public class LedgerRow
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
} }
/// <summary>An undirected friendship, stored as two rows (a→b and b→a).</summary>
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 class AppDbContext : DbContext
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<ProfileRow> Profiles => Set<ProfileRow>(); public DbSet<ProfileRow> Profiles => Set<ProfileRow>();
public DbSet<LedgerRow> Ledger => Set<LedgerRow>(); public DbSet<LedgerRow> Ledger => Set<LedgerRow>();
public DbSet<FriendEdgeRow> Friends => Set<FriendEdgeRow>();
public DbSet<FriendRequestRow> FriendRequests => Set<FriendRequestRow>();
public DbSet<MessageRow> Messages => Set<MessageRow>();
protected override void OnModelCreating(ModelBuilder b) protected override void OnModelCreating(ModelBuilder b)
{ {
b.Entity<ProfileRow>().HasKey(p => p.Id); b.Entity<ProfileRow>().HasKey(p => p.Id);
b.Entity<LedgerRow>().HasKey(l => l.Id); b.Entity<LedgerRow>().HasKey(l => l.Id);
b.Entity<LedgerRow>().HasIndex(l => l.UserId); b.Entity<LedgerRow>().HasIndex(l => l.UserId);
b.Entity<FriendEdgeRow>().HasKey(f => f.Id);
b.Entity<FriendEdgeRow>().HasIndex(f => f.UserId);
b.Entity<FriendRequestRow>().HasKey(r => r.Id);
b.Entity<FriendRequestRow>().HasIndex(r => r.ToUserId);
b.Entity<MessageRow>().HasKey(m => m.Id);
b.Entity<MessageRow>().HasIndex(m => new { m.UserId, m.PeerId });
} }
} }
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Hokm.Server.Data;
/// <summary>
/// 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
/// </summary>
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var conn = Environment.GetEnvironmentVariable("HOKM_DESIGN_CONN")
?? "Host=localhost;Database=hokm;Username=postgres;Password=postgres";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(conn)
.Options;
return new AppDbContext(options);
}
}
+6 -4
View File
@@ -141,18 +141,20 @@ public sealed class GameManager
public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit); public void ChooseTrump(string userId, string suit) => RoomOf(userId)?.HumanChooseTrump(userId, suit);
public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction); public void SendReaction(string userId, string reaction) => RoomOf(userId)?.Reaction(userId, reaction);
private int _online; private readonly ConcurrentDictionary<string, int> _onlineUsers = new();
public int OnlineCount => Volatile.Read(ref _online); public int OnlineCount => _onlineUsers.Count;
public bool IsOnline(string userId) => _onlineUsers.ContainsKey(userId);
public void OnConnected(string userId) public void OnConnected(string userId)
{ {
Interlocked.Increment(ref _online); _onlineUsers.AddOrUpdate(userId, 1, (_, n) => n + 1);
RoomOf(userId)?.SetConnected(userId, true); RoomOf(userId)?.SetConnected(userId, true);
} }
public void OnDisconnected(string userId) 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); CancelMatchmaking(userId);
RoomOf(userId)?.SetConnected(userId, false); RoomOf(userId)?.SetConnected(userId, false);
} }
@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup> </ItemGroup>
+57 -1
View File
@@ -7,6 +7,7 @@ using Hokm.Server.Game;
using Hokm.Server.Hubs; using Hokm.Server.Hubs;
using Hokm.Server.Payments; using Hokm.Server.Payments;
using Hokm.Server.Profiles; using Hokm.Server.Profiles;
using Hokm.Server.Social;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -32,6 +33,7 @@ builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlite(dbConn ?? "Data Source=hokm.db"); o.UseSqlite(dbConn ?? "Data Source=hokm.db");
}); });
builder.Services.AddScoped<ProfileService>(); builder.Services.AddScoped<ProfileService>();
builder.Services.AddScoped<SocialService>();
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) --- // --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions(); var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions();
@@ -90,7 +92,13 @@ builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.EnsureCreated(); {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 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.UseCors();
app.UseAuthentication(); app.UseAuthentication();
@@ -172,6 +180,17 @@ app.MapGet("/api/coins/pay/callback", async (string? authority, string? status,
return Results.Redirect($"{zp.ClientReturnUrl}/?pay=failed"); 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) => app.MapGet("/api/daily", async (ClaimsPrincipal u, ProfileService svc) =>
{ {
var (day, last, avail) = await svc.GetDaily(Uid(u)); 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 }); : Results.BadRequest(new { ok = false, error = err });
}).RequireAuthorization(); }).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<GameHub>("/hub/game"); app.MapHub<GameHub>("/hub/game");
app.Run(); app.Run();
@@ -201,3 +253,7 @@ record OtpVerify(string Phone, string Code, string? Name);
record EmailLogin(string Email, string Password, string? Name); record EmailLogin(string Email, string Password, string? Name);
record BuyReq(string PackId); record BuyReq(string PackId);
record ShopBuyReq(string Kind, string Id, int Price); 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);
@@ -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; }
}
@@ -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<GameHub> _hub;
public SocialService(AppDbContext db, GameManager mgr, IHubContext<GameHub> hub)
{
_db = db;
_mgr = mgr;
_hub = hub;
}
private async Task<FriendDto> FriendDtoFor(string userId)
{
var row = await _db.Profiles.FindAsync(userId);
var p = row != null ? JsonSerializer.Deserialize<ProfileDto>(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<List<FriendDto>> ListFriends(string uid)
{
var ids = await _db.Friends.Where(f => f.UserId == uid).Select(f => f.FriendId).ToListAsync();
var list = new List<FriendDto>();
foreach (var id in ids) list.Add(await FriendDtoFor(id));
return list;
}
public async Task<List<FriendRequestDto>> ListRequests(string uid)
{
var reqs = await _db.FriendRequests.Where(r => r.ToUserId == uid).ToListAsync();
var list = new List<FriendRequestDto>();
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<List<ConversationDto>> 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<ConversationDto>();
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<List<ChatMessageDto>> 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<ChatMessageDto> 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(),
};
}
@@ -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.<project>.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=<password>;SSL Mode=Require;Trust Server Certificate=true"
},
"Zarinpal": {
"MerchantId": "<your-live-merchant-id>",
"Sandbox": false,
"CallbackUrl": "https://api.yourdomain.com/api/coins/pay/callback",
"ClientReturnUrl": "https://yourdomain.com"
}
}
+55 -12
View File
@@ -53,6 +53,8 @@ export class SignalrService implements OnlineService {
private notifCbs = new Set<(n: AppNotification) => void>(); private notifCbs = new Set<(n: AppNotification) => void>();
private profileCbs = new Set<(p: UserProfile) => void>(); private profileCbs = new Set<(p: UserProfile) => void>();
private rewardCbs = new Set<(r: RewardResult) => 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 cachedProfile: UserProfile | null = null;
private mockNotifUnsub?: () => void; private mockNotifUnsub?: () => void;
@@ -122,6 +124,23 @@ export class SignalrService implements OnlineService {
this.profileCbs.forEach((cb) => cb(p)); this.profileCbs.forEach((cb) => cb(p));
}); });
conn.on("reward", (r: RewardResult) => this.rewardCbs.forEach((cb) => cb(r))); 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; this.conn = conn;
try { try {
@@ -306,13 +325,33 @@ export class SignalrService implements OnlineService {
return p; return p;
} }
listFriends() { return this.mock.listFriends(); } private async refreshFriends() {
listRequests() { return this.mock.listRequests(); } try {
addFriend(q: string) { return this.mock.addFriend(q); } const f = await this.listFriends();
acceptRequest(id: string) { return this.mock.acceptRequest(id); } this.friendCbs.forEach((cb) => cb(f));
declineRequest(id: string) { return this.mock.declineRequest(id); } } catch {
removeFriend(id: string) { return this.mock.removeFriend(id); } /* ignore */
onFriends(cb: (f: Friend[]) => void) { return this.mock.onFriends(cb); } }
}
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<Friend[]>("/api/friends"); }
listRequests() { return this.getJson<FriendRequest[]>("/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<unknown>("POST", "/api/friends/accept", { id }); }
async declineRequest(id: string) { await this.send<unknown>("POST", "/api/friends/decline", { id }); }
async removeFriend(id: string) { await this.send<unknown>("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); } createRoom(o: CreateRoomOptions) { return this.mock.createRoom(o); }
setPartner(roomId: string, friendId: string | null) { return this.mock.setPartner(roomId, friendId); } 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); } leaveRoom(roomId: string) { return this.mock.leaveRoom(roomId); }
onRoom(cb: (r: Room) => void) { return this.mock.onRoom(cb); } onRoom(cb: (r: Room) => void) { return this.mock.onRoom(cb); }
listConversations(): Promise<Conversation[]> { return this.mock.listConversations(); } listConversations(): Promise<Conversation[]> { return this.getJson<Conversation[]>("/api/chat"); }
getMessages(id: string): Promise<ChatMessage[]> { return this.mock.getMessages(id); } getMessages(id: string): Promise<ChatMessage[]> {
sendMessage(id: string, text: string) { return this.mock.sendMessage(id, text); } return this.getJson<ChatMessage[]>(`/api/chat/messages?peer=${encodeURIComponent(id)}`);
markRead(id: string) { return this.mock.markRead(id); } }
onChat(cb: (id: string, m: ChatMessage[]) => void) { return this.mock.onChat(cb); } sendMessage(id: string, text: string) {
return this.send<ChatMessage>("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 { onNotification(cb: (n: AppNotification) => void): Unsubscribe {
this.notifCbs.add(cb); this.notifCbs.add(cb);