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:
@@ -21,17 +21,52 @@ public class LedgerRow
|
||||
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 AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<ProfileRow> Profiles => Set<ProfileRow>();
|
||||
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)
|
||||
{
|
||||
b.Entity<ProfileRow>().HasKey(p => p.Id);
|
||||
b.Entity<LedgerRow>().HasKey(l => l.Id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, int> _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);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" 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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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<AppDbContext>(o =>
|
||||
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
|
||||
});
|
||||
builder.Services.AddScoped<ProfileService>();
|
||||
builder.Services.AddScoped<SocialService>();
|
||||
|
||||
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
|
||||
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();
|
||||
|
||||
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.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<GameHub>("/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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<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); }
|
||||
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<Conversation[]> { return this.mock.listConversations(); }
|
||||
getMessages(id: string): Promise<ChatMessage[]> { 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<Conversation[]> { return this.getJson<Conversation[]>("/api/chat"); }
|
||||
getMessages(id: string): Promise<ChatMessage[]> {
|
||||
return this.getJson<ChatMessage[]>(`/api/chat/messages?peer=${encodeURIComponent(id)}`);
|
||||
}
|
||||
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 {
|
||||
this.notifCbs.add(cb);
|
||||
|
||||
Reference in New Issue
Block a user