Files
Ping/backend/Hosts/Ping.Api/Hubs/ChatHub.cs
Admin 0623875dfa Add Ping backend
.NET 10 solution for Ping, a Discord style chat app: Domain, Application
and Infrastructure projects plus the Api host. SignalR for chat and
presence, LiveKit for call tokens, EF Core on Postgres, S3 compatible
storage for attachments, and OIDC auth shared with the Musify Zitadel
instance. Includes the Compose stack for local development.
2026-09-21 19:32:55 +02:00

94 lines
3.4 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Shared;
using Ping.Domain.ValueObjects;
namespace Ping.Api.Hubs;
[Authorize]
public sealed class ChatHub(IDatabase database, PresenceTracker presence) : Hub
{
public override async Task OnConnectedAsync()
{
if (presence.AddConnection(GetUserId()))
await BroadcastPresenceAsync(GetUserId());
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (presence.RemoveConnection(GetUserId()))
await BroadcastPresenceAsync(GetUserId());
await base.OnDisconnectedAsync(exception);
}
public async Task SetBusy(bool busy)
{
var userId = GetUserId();
presence.SetBusy(userId, busy);
await BroadcastPresenceAsync(userId);
}
private async Task BroadcastPresenceAsync(long userId)
{
var friendIds = await database.Friendships.AsNoTracking()
.Where(f => f.Status == FriendshipStatus.Accepted && (f.RequesterId == userId || f.AddresseeId == userId))
.Select(f => f.RequesterId == userId ? f.AddresseeId : f.RequesterId)
.ToListAsync(CancellationToken.None);
if (friendIds.Count == 0)
return;
await Clients.Users(friendIds.Select(id => id.ToString()))
.SendAsync("PresenceChanged", userId.ToString(), presence.GetStatus(userId).ToString());
}
public async Task JoinChannel(Guid channelId)
{
var hasAccess = await database.HasAccessAsync(channelId, GetUserId(), Context.ConnectionAborted);
if (!hasAccess)
throw new HubException("Channel not found");
await Groups.AddToGroupAsync(Context.ConnectionId, GroupName(channelId));
}
public Task LeaveChannel(Guid channelId) =>
Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(channelId));
public Task Typing(Guid channelId) =>
Clients.OthersInGroup(GroupName(channelId)).SendAsync("Typing", GetUserId(), channelId);
public async Task RingUser(Guid channelId, string calleeId)
{
var callerId = GetUserId();
if (!long.TryParse(calleeId, out var calleeUserId))
throw new HubException("Invalid user id");
var callerHasAccess = await database.HasAccessAsync(channelId, callerId, Context.ConnectionAborted);
var calleeHasAccess = await database.HasAccessAsync(channelId, calleeUserId, Context.ConnectionAborted);
if (!callerHasAccess || !calleeHasAccess)
throw new HubException("Channel not found");
await Clients.User(calleeId).SendAsync("IncomingCall", channelId, callerId.ToString());
}
public Task DeclineCall(Guid channelId, string callerId) =>
Clients.User(callerId).SendAsync("CallDeclined", channelId);
public Task CancelCall(Guid channelId, string calleeId) =>
Clients.User(calleeId).SendAsync("CallCancelled", channelId);
private long GetUserId()
{
var rawId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
return long.TryParse(rawId, out var id) ? id : throw new HubException("User Id claim is missing or invalid");
}
internal static string GroupName(Guid channelId) => $"channel:{channelId}";
}