공개: KoTalk 최신 기준선
This commit is contained in:
commit
debf62f76e
572 changed files with 41689 additions and 0 deletions
37
src/PhysOn.Api/Auth/ClaimsPrincipalExtensions.cs
Normal file
37
src/PhysOn.Api/Auth/ClaimsPrincipalExtensions.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using PhysOn.Application.Exceptions;
|
||||
|
||||
namespace PhysOn.Api.Auth;
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
public static Guid RequireAccountId(this ClaimsPrincipal principal)
|
||||
{
|
||||
var raw = principal.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return ParseGuid(raw, "invalid_account_claim");
|
||||
}
|
||||
|
||||
public static Guid RequireSessionId(this ClaimsPrincipal principal)
|
||||
{
|
||||
var raw = principal.FindFirstValue("sid");
|
||||
return ParseGuid(raw, "invalid_session_claim");
|
||||
}
|
||||
|
||||
public static Guid RequireDeviceId(this ClaimsPrincipal principal)
|
||||
{
|
||||
var raw = principal.FindFirstValue("did");
|
||||
return ParseGuid(raw, "invalid_device_claim");
|
||||
}
|
||||
|
||||
private static Guid ParseGuid(string? raw, string code)
|
||||
{
|
||||
if (Guid.TryParse(raw, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new AppException(code, "인증 정보가 올바르지 않습니다.", System.Net.HttpStatusCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
310
src/PhysOn.Api/Endpoints/MessengerEndpoints.cs
Normal file
310
src/PhysOn.Api/Endpoints/MessengerEndpoints.cs
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
using System.Net.WebSockets;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PhysOn.Api.Auth;
|
||||
using PhysOn.Application.Abstractions;
|
||||
using PhysOn.Application.Exceptions;
|
||||
using PhysOn.Application.Services;
|
||||
using PhysOn.Contracts.Auth;
|
||||
using PhysOn.Contracts.Common;
|
||||
using PhysOn.Contracts.Conversations;
|
||||
using PhysOn.Infrastructure.Realtime;
|
||||
|
||||
namespace PhysOn.Api.Endpoints;
|
||||
|
||||
public static class MessengerEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPhysOnEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapPost(
|
||||
"/v1/auth/register/alpha-quick",
|
||||
async (
|
||||
RegisterAlphaQuickRequest request,
|
||||
MessengerApplicationService service,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoStoreHeaders(httpContext.Response);
|
||||
var wsUrl = BuildWsUrl(httpContext);
|
||||
var response = await service.RegisterAlphaQuickAsync(request, wsUrl, cancellationToken);
|
||||
return Results.Ok(new ApiEnvelope<RegisterAlphaQuickResponse>(response));
|
||||
})
|
||||
.RequireRateLimiting("auth");
|
||||
|
||||
endpoints.MapPost(
|
||||
"/v1/auth/token/refresh",
|
||||
async (
|
||||
RefreshTokenRequest request,
|
||||
HttpContext httpContext,
|
||||
IAppDbContext db,
|
||||
IClock clock,
|
||||
ITokenService tokenService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoStoreHeaders(httpContext.Response);
|
||||
var refreshToken = request.RefreshToken?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
{
|
||||
throw SessionExpired();
|
||||
}
|
||||
|
||||
var now = clock.UtcNow;
|
||||
var refreshTokenHash = HashRefreshToken(refreshToken);
|
||||
var session = await db.Sessions
|
||||
.Include(x => x.Account)
|
||||
.Include(x => x.Device)
|
||||
.FirstOrDefaultAsync(x => x.RefreshTokenHash == refreshTokenHash, cancellationToken);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
throw SessionExpired();
|
||||
}
|
||||
|
||||
if (session.RevokedAt is not null)
|
||||
{
|
||||
throw SessionRevoked();
|
||||
}
|
||||
|
||||
if (!session.IsActive(now))
|
||||
{
|
||||
session.RevokedAt = now;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
throw SessionExpired();
|
||||
}
|
||||
|
||||
if (session.Account is null || session.Device is null)
|
||||
{
|
||||
session.RevokedAt = now;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
throw SessionRevoked();
|
||||
}
|
||||
|
||||
var issuedTokens = tokenService.IssueTokens(session.Account, session, session.Device, now);
|
||||
session.RefreshTokenHash = issuedTokens.RefreshTokenHash;
|
||||
session.ExpiresAt = issuedTokens.RefreshTokenExpiresAt;
|
||||
session.LastSeenAt = now;
|
||||
session.Device.LastSeenAt = now;
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Results.Ok(
|
||||
new ApiEnvelope<RefreshTokenResponse>(
|
||||
new RefreshTokenResponse(ToTokenPairDto(issuedTokens))));
|
||||
})
|
||||
.RequireRateLimiting("auth");
|
||||
|
||||
var authorized = endpoints.MapGroup("/v1").RequireAuthorization();
|
||||
|
||||
authorized.MapGet(
|
||||
"/me",
|
||||
async (
|
||||
ClaimsPrincipal user,
|
||||
HttpContext httpContext,
|
||||
MessengerApplicationService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoStoreHeaders(httpContext.Response);
|
||||
var response = await service.GetMeAsync(user.RequireAccountId(), cancellationToken);
|
||||
return Results.Ok(new ApiEnvelope<MeDto>(response));
|
||||
});
|
||||
|
||||
authorized.MapGet(
|
||||
"/bootstrap",
|
||||
async (
|
||||
ClaimsPrincipal user,
|
||||
MessengerApplicationService service,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoStoreHeaders(httpContext.Response);
|
||||
var response = await service.GetBootstrapAsync(
|
||||
user.RequireAccountId(),
|
||||
user.RequireSessionId(),
|
||||
BuildWsUrl(httpContext),
|
||||
cancellationToken);
|
||||
return Results.Ok(new ApiEnvelope<BootstrapResponse>(response));
|
||||
});
|
||||
|
||||
authorized.MapGet(
|
||||
"/conversations",
|
||||
async (
|
||||
ClaimsPrincipal user,
|
||||
HttpContext httpContext,
|
||||
MessengerApplicationService service,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoStoreHeaders(httpContext.Response);
|
||||
var response = await service.ListConversationsAsync(user.RequireAccountId(), limit ?? 50, cancellationToken);
|
||||
return Results.Ok(new ApiEnvelope<ListEnvelope<ConversationSummaryDto>>(response));
|
||||
});
|
||||
|
||||
authorized.MapGet(
|
||||
"/conversations/{conversationId:guid}/messages",
|
||||
async (
|
||||
Guid conversationId,
|
||||
long? beforeSequence,
|
||||
int? limit,
|
||||
ClaimsPrincipal user,
|
||||
HttpContext httpContext,
|
||||
MessengerApplicationService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoStoreHeaders(httpContext.Response);
|
||||
var response = await service.ListMessagesAsync(
|
||||
user.RequireAccountId(),
|
||||
conversationId,
|
||||
beforeSequence,
|
||||
limit ?? 50,
|
||||
cancellationToken);
|
||||
return Results.Ok(new ApiEnvelope<ListEnvelope<MessageItemDto>>(response));
|
||||
});
|
||||
|
||||
authorized.MapPost(
|
||||
"/conversations/{conversationId:guid}/messages",
|
||||
async (
|
||||
Guid conversationId,
|
||||
PostTextMessageRequest request,
|
||||
ClaimsPrincipal user,
|
||||
MessengerApplicationService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var response = await service.PostTextMessageAsync(user.RequireAccountId(), conversationId, request, cancellationToken);
|
||||
return Results.Ok(new ApiEnvelope<MessageItemDto>(response));
|
||||
});
|
||||
|
||||
authorized.MapPost(
|
||||
"/conversations/{conversationId:guid}/read-cursor",
|
||||
async (
|
||||
Guid conversationId,
|
||||
UpdateReadCursorRequest request,
|
||||
ClaimsPrincipal user,
|
||||
MessengerApplicationService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var response = await service.UpdateReadCursorAsync(user.RequireAccountId(), conversationId, request, cancellationToken);
|
||||
return Results.Ok(new ApiEnvelope<ReadCursorUpdatedDto>(response));
|
||||
});
|
||||
|
||||
endpoints.MapGet(
|
||||
"/v1/realtime/ws",
|
||||
async (
|
||||
HttpContext httpContext,
|
||||
IAppDbContext db,
|
||||
IClock clock,
|
||||
ITokenService tokenService,
|
||||
WebSocketConnectionHub connectionHub,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!httpContext.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
return Results.BadRequest(new ApiErrorEnvelope(new ApiError("websocket_required", "WebSocket 연결이 필요합니다.")));
|
||||
}
|
||||
|
||||
var (bearerToken, fromQueryString) = ReadBearerToken(httpContext);
|
||||
var principal = fromQueryString
|
||||
? tokenService.TryReadRealtimePrincipal(bearerToken)
|
||||
: tokenService.TryReadPrincipal(bearerToken);
|
||||
if (principal is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var accountId = principal.RequireAccountId();
|
||||
var sessionId = principal.RequireSessionId();
|
||||
var session = await db.Sessions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == sessionId && x.AccountId == accountId,
|
||||
cancellationToken);
|
||||
|
||||
if (session is null || !session.IsActive(clock.UtcNow))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
using var socket = await httpContext.WebSockets.AcceptWebSocketAsync();
|
||||
await connectionHub.AcceptConnectionAsync(accountId, sessionId, socket, cancellationToken);
|
||||
return Results.Empty;
|
||||
})
|
||||
.RequireRateLimiting("realtime");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static (string Token, bool FromQueryString) ReadBearerToken(HttpContext httpContext)
|
||||
{
|
||||
const string prefix = "Bearer ";
|
||||
var authorizationHeader = httpContext.Request.Headers.Authorization.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(authorizationHeader) &&
|
||||
authorizationHeader.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (authorizationHeader[prefix.Length..].Trim(), false);
|
||||
}
|
||||
|
||||
var queryToken = httpContext.Request.Query["access_token"].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(queryToken))
|
||||
{
|
||||
return (queryToken.Trim(), true);
|
||||
}
|
||||
|
||||
throw new AppException(
|
||||
"unauthorized",
|
||||
"인증 토큰이 필요합니다.",
|
||||
System.Net.HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
private static string BuildWsUrl(HttpContext httpContext)
|
||||
{
|
||||
var configuration = httpContext.RequestServices.GetRequiredService<IConfiguration>();
|
||||
var configuredOrigin = configuration["ClientFacing:PublicOrigin"]?.Trim();
|
||||
|
||||
if (Uri.TryCreate(configuredOrigin, UriKind.Absolute, out var publicOrigin))
|
||||
{
|
||||
var publicScheme = string.Equals(publicOrigin.Scheme, "https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws";
|
||||
return $"{publicScheme}://{publicOrigin.Authority}/v1/realtime/ws";
|
||||
}
|
||||
|
||||
var scheme = string.Equals(httpContext.Request.Scheme, "https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws";
|
||||
return $"{scheme}://{httpContext.Request.Host}/v1/realtime/ws";
|
||||
}
|
||||
|
||||
private static void ApplyNoStoreHeaders(HttpResponse response)
|
||||
{
|
||||
response.Headers["Cache-Control"] = "no-store, no-cache, max-age=0";
|
||||
response.Headers["Pragma"] = "no-cache";
|
||||
response.Headers["Expires"] = "0";
|
||||
}
|
||||
|
||||
private static TokenPairDto ToTokenPairDto(IssuedTokenSet issuedTokens)
|
||||
{
|
||||
return new TokenPairDto(
|
||||
issuedTokens.AccessToken,
|
||||
issuedTokens.AccessTokenExpiresAt,
|
||||
issuedTokens.RefreshToken,
|
||||
issuedTokens.RefreshTokenExpiresAt);
|
||||
}
|
||||
|
||||
private static string HashRefreshToken(string refreshToken)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)));
|
||||
}
|
||||
|
||||
private static AppException SessionExpired()
|
||||
{
|
||||
return new AppException(
|
||||
"session_expired",
|
||||
"세션이 만료되었습니다. 다시 로그인해 주세요.",
|
||||
System.Net.HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
private static AppException SessionRevoked()
|
||||
{
|
||||
return new AppException(
|
||||
"session_revoked",
|
||||
"이 세션은 더 이상 사용할 수 없습니다. 다시 로그인해 주세요.",
|
||||
System.Net.HttpStatusCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
using System.Text.Json;
|
||||
using PhysOn.Application.Exceptions;
|
||||
using PhysOn.Contracts.Common;
|
||||
|
||||
namespace PhysOn.Api.Infrastructure;
|
||||
|
||||
public static class ApplicationExceptionMiddlewareExtensions
|
||||
{
|
||||
public static IApplicationBuilder UsePhysOnExceptionHandling(this IApplicationBuilder app) =>
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (AppException exception)
|
||||
{
|
||||
context.Response.StatusCode = (int)exception.StatusCode;
|
||||
context.Response.ContentType = "application/json";
|
||||
var payload = new ApiErrorEnvelope(new ApiError(
|
||||
exception.Code,
|
||||
exception.Message,
|
||||
exception.Retryable,
|
||||
exception.FieldErrors));
|
||||
await JsonSerializer.SerializeAsync(context.Response.Body, payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
20
src/PhysOn.Api/PhysOn.Api.csproj
Normal file
20
src/PhysOn.Api/PhysOn.Api.csproj
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PhysOn.Application\PhysOn.Application.csproj" />
|
||||
<ProjectReference Include="..\PhysOn.Contracts\PhysOn.Contracts.csproj" />
|
||||
<ProjectReference Include="..\PhysOn.Infrastructure\PhysOn.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
79
src/PhysOn.Api/Program.cs
Normal file
79
src/PhysOn.Api/Program.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System.Net;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using PhysOn.Api.Endpoints;
|
||||
using PhysOn.Api.Infrastructure;
|
||||
using PhysOn.Application.Services;
|
||||
using PhysOn.Infrastructure;
|
||||
using PhysOn.Infrastructure.Persistence;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders =
|
||||
ForwardedHeaders.XForwardedFor |
|
||||
ForwardedHeaders.XForwardedHost |
|
||||
ForwardedHeaders.XForwardedProto;
|
||||
|
||||
if (builder.Environment.IsDevelopment() || builder.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
options.KnownNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var proxy in builder.Configuration.GetSection("Network:TrustedProxies").Get<string[]>() ?? [])
|
||||
{
|
||||
if (IPAddress.TryParse(proxy, out var address))
|
||||
{
|
||||
options.KnownProxies.Add(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddFixedWindowLimiter("auth", limiter =>
|
||||
{
|
||||
limiter.PermitLimit = 12;
|
||||
limiter.Window = TimeSpan.FromMinutes(1);
|
||||
limiter.QueueLimit = 0;
|
||||
});
|
||||
options.AddFixedWindowLimiter("realtime", limiter =>
|
||||
{
|
||||
limiter.PermitLimit = 30;
|
||||
limiter.Window = TimeSpan.FromMinutes(1);
|
||||
limiter.QueueLimit = 0;
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddPhysOnInfrastructure(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddScoped<MessengerApplicationService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UsePhysOnExceptionHandling();
|
||||
app.UseRateLimiter();
|
||||
app.UseWebSockets();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var initializer = scope.ServiceProvider.GetRequiredService<DatabaseInitializer>();
|
||||
await initializer.InitializeAsync();
|
||||
}
|
||||
|
||||
app.MapGet("/", () => Results.Ok(new { name = "PhysOn.Api", status = "ok" }));
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
app.MapPhysOnEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
38
src/PhysOn.Api/Properties/launchSettings.json
Normal file
38
src/PhysOn.Api/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:58187",
|
||||
"sslPort": 44338
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5117",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7212;http://localhost:5117",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/PhysOn.Api/appsettings.Development.json
Normal file
17
src/PhysOn.Api/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"ConnectionStrings": {
|
||||
"Main": "Data Source=vs-messenger.dev.db"
|
||||
},
|
||||
"Bootstrap": {
|
||||
"SeedDefaultInviteCodes": true,
|
||||
"InviteCodes": [
|
||||
"ALPHA-OPEN-2026"
|
||||
]
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/PhysOn.Api/appsettings.json
Normal file
32
src/PhysOn.Api/appsettings.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"ConnectionStrings": {
|
||||
"Main": "Data Source=vs-messenger.db"
|
||||
},
|
||||
"Auth": {
|
||||
"Jwt": {
|
||||
"Issuer": "PhysOn",
|
||||
"Audience": "PhysOn.Desktop",
|
||||
"SigningKey": "vsmessenger-dev-signing-key-change-me-2026",
|
||||
"AccessTokenMinutes": 15,
|
||||
"RefreshTokenDays": 30,
|
||||
"RealtimeTicketMinutes": 15
|
||||
}
|
||||
},
|
||||
"Bootstrap": {
|
||||
"SeedDefaultInviteCodes": false,
|
||||
"InviteCodes": []
|
||||
},
|
||||
"ClientFacing": {
|
||||
"PublicOrigin": ""
|
||||
},
|
||||
"Network": {
|
||||
"TrustedProxies": []
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "localhost;127.0.0.1;[::1]"
|
||||
}
|
||||
22
src/PhysOn.Application/Abstractions/IAppDbContext.cs
Normal file
22
src/PhysOn.Application/Abstractions/IAppDbContext.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using PhysOn.Domain.Accounts;
|
||||
using PhysOn.Domain.Conversations;
|
||||
using PhysOn.Domain.Invites;
|
||||
using PhysOn.Domain.Messages;
|
||||
|
||||
namespace PhysOn.Application.Abstractions;
|
||||
|
||||
public interface IAppDbContext
|
||||
{
|
||||
DbSet<Account> Accounts { get; }
|
||||
DbSet<Device> Devices { get; }
|
||||
DbSet<Session> Sessions { get; }
|
||||
DbSet<Invite> Invites { get; }
|
||||
DbSet<Conversation> Conversations { get; }
|
||||
DbSet<ConversationMember> ConversationMembers { get; }
|
||||
DbSet<Message> Messages { get; }
|
||||
DatabaseFacade Database { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
6
src/PhysOn.Application/Abstractions/IClock.cs
Normal file
6
src/PhysOn.Application/Abstractions/IClock.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace PhysOn.Application.Abstractions;
|
||||
|
||||
public interface IClock
|
||||
{
|
||||
DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
10
src/PhysOn.Application/Abstractions/IRealtimeNotifier.cs
Normal file
10
src/PhysOn.Application/Abstractions/IRealtimeNotifier.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace PhysOn.Application.Abstractions;
|
||||
|
||||
public interface IRealtimeNotifier
|
||||
{
|
||||
Task PublishToAccountsAsync(
|
||||
IEnumerable<Guid> accountIds,
|
||||
string eventName,
|
||||
object payload,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
23
src/PhysOn.Application/Abstractions/ITokenService.cs
Normal file
23
src/PhysOn.Application/Abstractions/ITokenService.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using System.Security.Claims;
|
||||
using PhysOn.Domain.Accounts;
|
||||
|
||||
namespace PhysOn.Application.Abstractions;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
IssuedTokenSet IssueTokens(Account account, Session session, Device device, DateTimeOffset now);
|
||||
IssuedRealtimeTicket IssueRealtimeTicket(Account account, Session session, Device device, DateTimeOffset now);
|
||||
ClaimsPrincipal? TryReadPrincipal(string accessToken);
|
||||
ClaimsPrincipal? TryReadRealtimePrincipal(string accessToken);
|
||||
}
|
||||
|
||||
public sealed record IssuedTokenSet(
|
||||
string AccessToken,
|
||||
DateTimeOffset AccessTokenExpiresAt,
|
||||
string RefreshToken,
|
||||
string RefreshTokenHash,
|
||||
DateTimeOffset RefreshTokenExpiresAt);
|
||||
|
||||
public sealed record IssuedRealtimeTicket(
|
||||
string Token,
|
||||
DateTimeOffset ExpiresAt);
|
||||
25
src/PhysOn.Application/Exceptions/AppException.cs
Normal file
25
src/PhysOn.Application/Exceptions/AppException.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using System.Net;
|
||||
|
||||
namespace PhysOn.Application.Exceptions;
|
||||
|
||||
public sealed class AppException : Exception
|
||||
{
|
||||
public AppException(
|
||||
string code,
|
||||
string message,
|
||||
HttpStatusCode statusCode = HttpStatusCode.BadRequest,
|
||||
bool retryable = false,
|
||||
IReadOnlyDictionary<string, string>? fieldErrors = null)
|
||||
: base(message)
|
||||
{
|
||||
Code = code;
|
||||
StatusCode = statusCode;
|
||||
Retryable = retryable;
|
||||
FieldErrors = fieldErrors;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
public bool Retryable { get; }
|
||||
public IReadOnlyDictionary<string, string>? FieldErrors { get; }
|
||||
}
|
||||
18
src/PhysOn.Application/PhysOn.Application.csproj
Normal file
18
src/PhysOn.Application/PhysOn.Application.csproj
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PhysOn.Domain\PhysOn.Domain.csproj" />
|
||||
<ProjectReference Include="..\PhysOn.Contracts\PhysOn.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
560
src/PhysOn.Application/Services/MessengerApplicationService.cs
Normal file
560
src/PhysOn.Application/Services/MessengerApplicationService.cs
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PhysOn.Application.Abstractions;
|
||||
using PhysOn.Application.Exceptions;
|
||||
using PhysOn.Contracts.Auth;
|
||||
using PhysOn.Contracts.Common;
|
||||
using PhysOn.Contracts.Conversations;
|
||||
using PhysOn.Domain.Accounts;
|
||||
using PhysOn.Domain.Conversations;
|
||||
using PhysOn.Domain.Invites;
|
||||
using PhysOn.Domain.Messages;
|
||||
|
||||
namespace PhysOn.Application.Services;
|
||||
|
||||
public sealed class MessengerApplicationService
|
||||
{
|
||||
private const int DefaultConversationPageSize = 50;
|
||||
private const int DefaultMessagePageSize = 50;
|
||||
|
||||
private readonly IAppDbContext _db;
|
||||
private readonly IClock _clock;
|
||||
private readonly ITokenService _tokenService;
|
||||
private readonly IRealtimeNotifier _realtimeNotifier;
|
||||
|
||||
public MessengerApplicationService(
|
||||
IAppDbContext db,
|
||||
IClock clock,
|
||||
ITokenService tokenService,
|
||||
IRealtimeNotifier realtimeNotifier)
|
||||
{
|
||||
_db = db;
|
||||
_clock = clock;
|
||||
_tokenService = tokenService;
|
||||
_realtimeNotifier = realtimeNotifier;
|
||||
}
|
||||
|
||||
public async Task<RegisterAlphaQuickResponse> RegisterAlphaQuickAsync(
|
||||
RegisterAlphaQuickRequest request,
|
||||
string wsUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _clock.UtcNow;
|
||||
var normalizedDisplayName = NormalizeDisplayName(request.DisplayName);
|
||||
var inviteCode = NormalizeInviteCode(request.InviteCode);
|
||||
|
||||
var invite = await _db.Invites
|
||||
.FirstOrDefaultAsync(x => x.CodeHash == HashInviteCode(inviteCode), cancellationToken)
|
||||
?? throw InvalidInviteException();
|
||||
|
||||
if (!invite.CanUse(now))
|
||||
{
|
||||
throw InvalidInviteException();
|
||||
}
|
||||
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DisplayName = normalizedDisplayName,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
var device = new Device
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = account.Id,
|
||||
InstallId = NormalizeInstallId(request.Device.InstallId),
|
||||
Platform = string.IsNullOrWhiteSpace(request.Device.Platform) ? "windows" : request.Device.Platform.Trim().ToLowerInvariant(),
|
||||
DeviceName = string.IsNullOrWhiteSpace(request.Device.DeviceName) ? "Windows PC" : request.Device.DeviceName.Trim(),
|
||||
AppVersion = string.IsNullOrWhiteSpace(request.Device.AppVersion) ? "0.1.0" : request.Device.AppVersion.Trim(),
|
||||
CreatedAt = now,
|
||||
LastSeenAt = now
|
||||
};
|
||||
|
||||
var session = new Session
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = account.Id,
|
||||
DeviceId = device.Id,
|
||||
TokenFamilyId = Guid.NewGuid(),
|
||||
CreatedAt = now,
|
||||
LastSeenAt = now
|
||||
};
|
||||
|
||||
var selfConversation = new Conversation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ConversationType.Self,
|
||||
CreatedByAccountId = account.Id,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
var selfMembership = new ConversationMember
|
||||
{
|
||||
ConversationId = selfConversation.Id,
|
||||
AccountId = account.Id,
|
||||
Role = ConversationRole.Owner,
|
||||
JoinedAt = now,
|
||||
PinOrder = 0
|
||||
};
|
||||
|
||||
Conversation? inviterConversation = null;
|
||||
List<ConversationMember> inviterMembers = [];
|
||||
|
||||
if (invite.IssuedByAccountId is Guid inviterAccountId && inviterAccountId != account.Id)
|
||||
{
|
||||
inviterConversation = new Conversation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = ConversationType.Direct,
|
||||
CreatedByAccountId = inviterAccountId,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
inviterMembers =
|
||||
[
|
||||
new ConversationMember
|
||||
{
|
||||
ConversationId = inviterConversation.Id,
|
||||
AccountId = inviterAccountId,
|
||||
Role = ConversationRole.Owner,
|
||||
JoinedAt = now
|
||||
},
|
||||
new ConversationMember
|
||||
{
|
||||
ConversationId = inviterConversation.Id,
|
||||
AccountId = account.Id,
|
||||
Role = ConversationRole.Member,
|
||||
JoinedAt = now
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
var issuedTokens = _tokenService.IssueTokens(account, session, device, now);
|
||||
session.RefreshTokenHash = issuedTokens.RefreshTokenHash;
|
||||
session.ExpiresAt = issuedTokens.RefreshTokenExpiresAt;
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
_db.Accounts.Add(account);
|
||||
_db.Devices.Add(device);
|
||||
_db.Sessions.Add(session);
|
||||
_db.Conversations.Add(selfConversation);
|
||||
_db.ConversationMembers.Add(selfMembership);
|
||||
|
||||
if (inviterConversation is not null)
|
||||
{
|
||||
_db.Conversations.Add(inviterConversation);
|
||||
_db.ConversationMembers.AddRange(inviterMembers);
|
||||
}
|
||||
|
||||
invite.UsedCount += 1;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
var bootstrap = await GetBootstrapAsync(account.Id, session.Id, wsUrl, cancellationToken);
|
||||
return new RegisterAlphaQuickResponse(
|
||||
bootstrap.Me,
|
||||
bootstrap.Session,
|
||||
new TokenPairDto(
|
||||
issuedTokens.AccessToken,
|
||||
issuedTokens.AccessTokenExpiresAt,
|
||||
issuedTokens.RefreshToken,
|
||||
issuedTokens.RefreshTokenExpiresAt),
|
||||
bootstrap);
|
||||
}
|
||||
|
||||
public async Task<BootstrapResponse> GetBootstrapAsync(
|
||||
Guid accountId,
|
||||
Guid sessionId,
|
||||
string wsUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await _db.Accounts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == accountId, cancellationToken)
|
||||
?? throw NotFound("account_not_found", "사용자 정보를 찾을 수 없습니다.");
|
||||
|
||||
var session = await _db.Sessions
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Device)
|
||||
.FirstOrDefaultAsync(x => x.Id == sessionId && x.AccountId == accountId, cancellationToken)
|
||||
?? throw NotFound("session_not_found", "세션 정보를 찾을 수 없습니다.");
|
||||
|
||||
var conversations = await ListConversationsAsync(accountId, DefaultConversationPageSize, cancellationToken);
|
||||
|
||||
return new BootstrapResponse(
|
||||
ToMeDto(account),
|
||||
new SessionDto(session.Id.ToString(), session.DeviceId.ToString(), session.Device?.DeviceName ?? "Windows PC", session.CreatedAt),
|
||||
BuildBootstrapWsDto(account, session, session.Device, wsUrl),
|
||||
conversations);
|
||||
}
|
||||
|
||||
public async Task<MeDto> GetMeAsync(Guid accountId, CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await _db.Accounts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == accountId, cancellationToken)
|
||||
?? throw NotFound("account_not_found", "사용자 정보를 찾을 수 없습니다.");
|
||||
|
||||
return ToMeDto(account);
|
||||
}
|
||||
|
||||
private BootstrapWsDto BuildBootstrapWsDto(Account account, Session session, Device? device, string wsUrl)
|
||||
{
|
||||
var resolvedDevice = device ?? session.Device ?? throw new AppException(
|
||||
"device_not_found",
|
||||
"세션 기기 정보를 찾을 수 없습니다.",
|
||||
System.Net.HttpStatusCode.Unauthorized);
|
||||
|
||||
var issuedRealtimeTicket = _tokenService.IssueRealtimeTicket(account, session, resolvedDevice, _clock.UtcNow);
|
||||
return new BootstrapWsDto(wsUrl, issuedRealtimeTicket.Token, issuedRealtimeTicket.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<ListEnvelope<ConversationSummaryDto>> ListConversationsAsync(
|
||||
Guid accountId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pageSize = limit <= 0 ? DefaultConversationPageSize : Math.Min(limit, 100);
|
||||
var memberships = await _db.ConversationMembers
|
||||
.AsNoTracking()
|
||||
.Where(x => x.AccountId == accountId)
|
||||
.OrderBy(x => x.PinOrder.HasValue ? 0 : 1)
|
||||
.ThenBy(x => x.PinOrder)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (memberships.Count == 0)
|
||||
{
|
||||
return new ListEnvelope<ConversationSummaryDto>([], null);
|
||||
}
|
||||
|
||||
var conversationIds = memberships.Select(x => x.ConversationId).ToArray();
|
||||
var conversations = await _db.Conversations
|
||||
.AsNoTracking()
|
||||
.Where(x => conversationIds.Contains(x.Id))
|
||||
.ToDictionaryAsync(x => x.Id, cancellationToken);
|
||||
|
||||
var allMembers = await _db.ConversationMembers
|
||||
.AsNoTracking()
|
||||
.Where(x => conversationIds.Contains(x.ConversationId))
|
||||
.Include(x => x.Account)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var messages = await _db.Messages
|
||||
.AsNoTracking()
|
||||
.Where(x => conversationIds.Contains(x.ConversationId))
|
||||
.OrderByDescending(x => x.ServerSequence)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var lastMessages = messages
|
||||
.GroupBy(x => x.ConversationId)
|
||||
.ToDictionary(x => x.Key, x => x.First());
|
||||
|
||||
var summaries = memberships
|
||||
.Select(membership =>
|
||||
{
|
||||
var conversation = conversations[membership.ConversationId];
|
||||
var memberSet = allMembers.Where(x => x.ConversationId == membership.ConversationId).ToList();
|
||||
lastMessages.TryGetValue(membership.ConversationId, out var lastMessage);
|
||||
return ToConversationSummaryDto(accountId, membership, conversation, memberSet, lastMessage);
|
||||
})
|
||||
.OrderByDescending(x => x.IsPinned)
|
||||
.ThenBy(x => x.IsPinned ? 0 : 1)
|
||||
.ThenByDescending(x => x.SortKey)
|
||||
.ToList();
|
||||
|
||||
return new ListEnvelope<ConversationSummaryDto>(summaries, null);
|
||||
}
|
||||
|
||||
public async Task<ListEnvelope<MessageItemDto>> ListMessagesAsync(
|
||||
Guid accountId,
|
||||
Guid conversationId,
|
||||
long? beforeSequence,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureConversationMemberAsync(accountId, conversationId, cancellationToken);
|
||||
|
||||
var pageSize = limit <= 0 ? DefaultMessagePageSize : Math.Min(limit, 100);
|
||||
var query = _db.Messages
|
||||
.AsNoTracking()
|
||||
.Where(x => x.ConversationId == conversationId);
|
||||
|
||||
if (beforeSequence.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.ServerSequence < beforeSequence.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.ServerSequence)
|
||||
.Take(pageSize)
|
||||
.Include(x => x.SenderAccount)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
items.Reverse();
|
||||
|
||||
var nextCursor = items.Count == pageSize ? items[0].ServerSequence.ToString() : null;
|
||||
var dtos = items.Select(x => ToMessageItemDto(x, accountId)).ToList();
|
||||
return new ListEnvelope<MessageItemDto>(dtos, nextCursor);
|
||||
}
|
||||
|
||||
public async Task<MessageItemDto> PostTextMessageAsync(
|
||||
Guid accountId,
|
||||
Guid conversationId,
|
||||
PostTextMessageRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedBody = NormalizeMessageBody(request.Body);
|
||||
var membership = await EnsureConversationMemberAsync(accountId, conversationId, cancellationToken);
|
||||
var conversation = await _db.Conversations
|
||||
.FirstOrDefaultAsync(x => x.Id == conversationId, cancellationToken)
|
||||
?? throw NotFound("conversation_not_found", "대화방을 찾을 수 없습니다.");
|
||||
|
||||
var existing = await _db.Messages
|
||||
.AsNoTracking()
|
||||
.Include(x => x.SenderAccount)
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.ConversationId == conversationId && x.ClientRequestId == request.ClientRequestId,
|
||||
cancellationToken);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return ToMessageItemDto(existing, accountId);
|
||||
}
|
||||
|
||||
var now = _clock.UtcNow;
|
||||
conversation.LastMessageSequence += 1;
|
||||
membership.LastReadSequence = conversation.LastMessageSequence;
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ConversationId = conversationId,
|
||||
SenderAccountId = accountId,
|
||||
ClientRequestId = request.ClientRequestId,
|
||||
ServerSequence = conversation.LastMessageSequence,
|
||||
MessageType = MessageType.Text,
|
||||
BodyText = normalizedBody,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_db.Messages.Add(message);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
message = await _db.Messages
|
||||
.AsNoTracking()
|
||||
.Include(x => x.SenderAccount)
|
||||
.FirstAsync(x => x.Id == message.Id, cancellationToken);
|
||||
|
||||
var memberIds = await _db.ConversationMembers
|
||||
.AsNoTracking()
|
||||
.Where(x => x.ConversationId == conversationId)
|
||||
.Select(x => x.AccountId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var dto = ToMessageItemDto(message, accountId);
|
||||
await _realtimeNotifier.PublishToAccountsAsync(memberIds, "message.created", dto, cancellationToken);
|
||||
return dto;
|
||||
}
|
||||
|
||||
public async Task<ReadCursorUpdatedDto> UpdateReadCursorAsync(
|
||||
Guid accountId,
|
||||
Guid conversationId,
|
||||
UpdateReadCursorRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await EnsureConversationMemberAsync(accountId, conversationId, cancellationToken);
|
||||
var conversation = await _db.Conversations
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == conversationId, cancellationToken)
|
||||
?? throw NotFound("conversation_not_found", "대화방을 찾을 수 없습니다.");
|
||||
|
||||
membership.LastReadSequence = Math.Max(membership.LastReadSequence, Math.Min(request.LastReadSequence, conversation.LastMessageSequence));
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var payload = new ReadCursorUpdatedDto(
|
||||
conversationId.ToString(),
|
||||
accountId.ToString(),
|
||||
membership.LastReadSequence,
|
||||
_clock.UtcNow);
|
||||
|
||||
var memberIds = await _db.ConversationMembers
|
||||
.AsNoTracking()
|
||||
.Where(x => x.ConversationId == conversationId)
|
||||
.Select(x => x.AccountId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
await _realtimeNotifier.PublishToAccountsAsync(memberIds, "read_cursor.updated", payload, cancellationToken);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async Task<ConversationMember> EnsureConversationMemberAsync(
|
||||
Guid accountId,
|
||||
Guid conversationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await _db.ConversationMembers
|
||||
.FirstOrDefaultAsync(x => x.AccountId == accountId && x.ConversationId == conversationId, cancellationToken);
|
||||
|
||||
if (membership is null)
|
||||
{
|
||||
throw NotFound("conversation_not_found", "대화방을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
private static ConversationSummaryDto ToConversationSummaryDto(
|
||||
Guid viewerAccountId,
|
||||
ConversationMember membership,
|
||||
Conversation conversation,
|
||||
IReadOnlyCollection<ConversationMember> members,
|
||||
Message? lastMessage)
|
||||
{
|
||||
var otherMember = members.FirstOrDefault(x => x.AccountId != viewerAccountId)?.Account;
|
||||
var title = conversation.Type switch
|
||||
{
|
||||
ConversationType.Self => "나에게 메시지",
|
||||
ConversationType.Direct => otherMember?.DisplayName ?? "새 대화",
|
||||
_ => "새 그룹"
|
||||
};
|
||||
|
||||
var subtitle = conversation.Type switch
|
||||
{
|
||||
ConversationType.Self => "메모와 파일을 나에게 보관해 보세요.",
|
||||
ConversationType.Direct => otherMember?.StatusMessage ?? "대화를 시작해 보세요.",
|
||||
_ => "대화를 시작해 보세요."
|
||||
};
|
||||
|
||||
MessagePreviewDto? lastMessageDto = null;
|
||||
if (lastMessage is not null)
|
||||
{
|
||||
var senderId = lastMessage.SenderAccountId;
|
||||
lastMessageDto = new MessagePreviewDto(
|
||||
lastMessage.Id.ToString(),
|
||||
lastMessage.BodyText,
|
||||
lastMessage.CreatedAt,
|
||||
senderId.ToString());
|
||||
}
|
||||
|
||||
var unreadCount = Math.Max(0, (int)Math.Min(int.MaxValue, conversation.LastMessageSequence - membership.LastReadSequence));
|
||||
|
||||
return new ConversationSummaryDto(
|
||||
conversation.Id.ToString(),
|
||||
conversation.Type.ToString().ToLowerInvariant(),
|
||||
title,
|
||||
null,
|
||||
subtitle,
|
||||
members.Count,
|
||||
membership.IsMuted,
|
||||
membership.PinOrder.HasValue,
|
||||
lastMessage?.CreatedAt ?? conversation.CreatedAt,
|
||||
unreadCount,
|
||||
membership.LastReadSequence,
|
||||
lastMessageDto);
|
||||
}
|
||||
|
||||
private static MessageItemDto ToMessageItemDto(Message message, Guid viewerAccountId)
|
||||
{
|
||||
var sender = message.SenderAccount ?? throw new InvalidOperationException("SenderAccount must be loaded.");
|
||||
return new MessageItemDto(
|
||||
message.Id.ToString(),
|
||||
message.ConversationId.ToString(),
|
||||
message.ClientRequestId,
|
||||
message.MessageType.ToString().ToLowerInvariant(),
|
||||
message.BodyText,
|
||||
message.CreatedAt,
|
||||
message.EditedAt,
|
||||
new MessageSenderDto(sender.Id.ToString(), sender.DisplayName, null),
|
||||
sender.Id == viewerAccountId,
|
||||
message.ServerSequence);
|
||||
}
|
||||
|
||||
private static MeDto ToMeDto(Account account) =>
|
||||
new(account.Id.ToString(), account.DisplayName, null, account.StatusMessage);
|
||||
|
||||
private static string NormalizeDisplayName(string value)
|
||||
{
|
||||
var normalized = value?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new AppException(
|
||||
"display_name_required",
|
||||
"이름을 입력해 주세요.",
|
||||
fieldErrors: new Dictionary<string, string> { ["displayName"] = "이름을 입력해 주세요." });
|
||||
}
|
||||
|
||||
if (normalized.Length > 40)
|
||||
{
|
||||
throw new AppException(
|
||||
"display_name_too_long",
|
||||
"이름은 40자 이하로 입력해 주세요.",
|
||||
fieldErrors: new Dictionary<string, string> { ["displayName"] = "이름은 40자 이하로 입력해 주세요." });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeInviteCode(string value)
|
||||
{
|
||||
var normalized = value?.Trim().ToUpperInvariant() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw InvalidInviteException();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeInstallId(string value)
|
||||
{
|
||||
var normalized = value?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeMessageBody(string value)
|
||||
{
|
||||
var normalized = value?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new AppException(
|
||||
"message_body_required",
|
||||
"메시지 내용을 입력해 주세요.",
|
||||
fieldErrors: new Dictionary<string, string> { ["body"] = "메시지 내용을 입력해 주세요." });
|
||||
}
|
||||
|
||||
if (normalized.Length > 4000)
|
||||
{
|
||||
throw new AppException(
|
||||
"message_body_too_long",
|
||||
"메시지는 4000자 이하로 입력해 주세요.",
|
||||
fieldErrors: new Dictionary<string, string> { ["body"] = "메시지는 4000자 이하로 입력해 주세요." });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string HashInviteCode(string inviteCode)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(inviteCode));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
private static AppException InvalidInviteException() =>
|
||||
new(
|
||||
"invite_invalid",
|
||||
"초대코드가 유효하지 않습니다.",
|
||||
fieldErrors: new Dictionary<string, string> { ["inviteCode"] = "초대코드를 다시 확인해 주세요." });
|
||||
|
||||
private static AppException NotFound(string code, string message) =>
|
||||
new(code, message, System.Net.HttpStatusCode.NotFound);
|
||||
}
|
||||
42
src/PhysOn.Contracts/Auth/AuthContracts.cs
Normal file
42
src/PhysOn.Contracts/Auth/AuthContracts.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using PhysOn.Contracts.Common;
|
||||
using PhysOn.Contracts.Conversations;
|
||||
|
||||
namespace PhysOn.Contracts.Auth;
|
||||
|
||||
public sealed record DeviceRegistrationDto(
|
||||
string InstallId,
|
||||
string Platform,
|
||||
string DeviceName,
|
||||
string AppVersion);
|
||||
|
||||
public sealed record RegisterAlphaQuickRequest(
|
||||
string DisplayName,
|
||||
string InviteCode,
|
||||
DeviceRegistrationDto Device);
|
||||
|
||||
public sealed record RefreshTokenRequest(string RefreshToken);
|
||||
|
||||
public sealed record TokenPairDto(
|
||||
string AccessToken,
|
||||
DateTimeOffset AccessTokenExpiresAt,
|
||||
string RefreshToken,
|
||||
DateTimeOffset RefreshTokenExpiresAt);
|
||||
|
||||
public sealed record BootstrapWsDto(
|
||||
string Url,
|
||||
string Ticket,
|
||||
DateTimeOffset TicketExpiresAt);
|
||||
|
||||
public sealed record BootstrapResponse(
|
||||
MeDto Me,
|
||||
SessionDto Session,
|
||||
BootstrapWsDto Ws,
|
||||
ListEnvelope<ConversationSummaryDto> Conversations);
|
||||
|
||||
public sealed record RegisterAlphaQuickResponse(
|
||||
MeDto Account,
|
||||
SessionDto Session,
|
||||
TokenPairDto Tokens,
|
||||
BootstrapResponse Bootstrap);
|
||||
|
||||
public sealed record RefreshTokenResponse(TokenPairDto Tokens);
|
||||
13
src/PhysOn.Contracts/Common/ApiEnvelope.cs
Normal file
13
src/PhysOn.Contracts/Common/ApiEnvelope.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace PhysOn.Contracts.Common;
|
||||
|
||||
public sealed record ApiEnvelope<T>(T Data);
|
||||
|
||||
public sealed record ListEnvelope<T>(IReadOnlyList<T> Items, string? NextCursor);
|
||||
|
||||
public sealed record ApiErrorEnvelope(ApiError Error);
|
||||
|
||||
public sealed record ApiError(
|
||||
string Code,
|
||||
string Message,
|
||||
bool Retryable = false,
|
||||
IReadOnlyDictionary<string, string>? FieldErrors = null);
|
||||
13
src/PhysOn.Contracts/Common/IdentityDtos.cs
Normal file
13
src/PhysOn.Contracts/Common/IdentityDtos.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace PhysOn.Contracts.Common;
|
||||
|
||||
public sealed record MeDto(
|
||||
string UserId,
|
||||
string DisplayName,
|
||||
string? ProfileImageUrl,
|
||||
string? StatusMessage);
|
||||
|
||||
public sealed record SessionDto(
|
||||
string SessionId,
|
||||
string DeviceId,
|
||||
string DeviceName,
|
||||
DateTimeOffset CreatedAt);
|
||||
50
src/PhysOn.Contracts/Conversations/ConversationContracts.cs
Normal file
50
src/PhysOn.Contracts/Conversations/ConversationContracts.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
namespace PhysOn.Contracts.Conversations;
|
||||
|
||||
public sealed record MessagePreviewDto(
|
||||
string MessageId,
|
||||
string Text,
|
||||
DateTimeOffset CreatedAt,
|
||||
string SenderUserId);
|
||||
|
||||
public sealed record ConversationSummaryDto(
|
||||
string ConversationId,
|
||||
string Type,
|
||||
string Title,
|
||||
string? AvatarUrl,
|
||||
string Subtitle,
|
||||
int MemberCount,
|
||||
bool IsMuted,
|
||||
bool IsPinned,
|
||||
DateTimeOffset SortKey,
|
||||
int UnreadCount,
|
||||
long LastReadSequence,
|
||||
MessagePreviewDto? LastMessage);
|
||||
|
||||
public sealed record MessageSenderDto(
|
||||
string UserId,
|
||||
string DisplayName,
|
||||
string? ProfileImageUrl);
|
||||
|
||||
public sealed record MessageItemDto(
|
||||
string MessageId,
|
||||
string ConversationId,
|
||||
Guid ClientMessageId,
|
||||
string Kind,
|
||||
string Text,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? EditedAt,
|
||||
MessageSenderDto Sender,
|
||||
bool IsMine,
|
||||
long ServerSequence);
|
||||
|
||||
public sealed record PostTextMessageRequest(
|
||||
Guid ClientRequestId,
|
||||
string Body);
|
||||
|
||||
public sealed record UpdateReadCursorRequest(long LastReadSequence);
|
||||
|
||||
public sealed record ReadCursorUpdatedDto(
|
||||
string ConversationId,
|
||||
string AccountId,
|
||||
long LastReadSequence,
|
||||
DateTimeOffset UpdatedAt);
|
||||
9
src/PhysOn.Contracts/PhysOn.Contracts.csproj
Normal file
9
src/PhysOn.Contracts/PhysOn.Contracts.csproj
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
9
src/PhysOn.Contracts/Realtime/RealtimeContracts.cs
Normal file
9
src/PhysOn.Contracts/Realtime/RealtimeContracts.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace PhysOn.Contracts.Realtime;
|
||||
|
||||
public sealed record RealtimeEventEnvelope(
|
||||
string Event,
|
||||
string EventId,
|
||||
DateTimeOffset OccurredAt,
|
||||
object Data);
|
||||
|
||||
public sealed record SessionConnectedDto(string SessionId);
|
||||
16
src/PhysOn.Desktop/App.axaml
Normal file
16
src/PhysOn.Desktop/App.axaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PhysOn.Desktop.App"
|
||||
xmlns:local="using:PhysOn.Desktop"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<Style Selector="Application">
|
||||
<Setter Property="TextElement.FontFamily" Value="Pretendard Variable, SUIT Variable, Malgun Gothic, Segoe UI, sans-serif" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
34
src/PhysOn.Desktop/App.axaml.cs
Normal file
34
src/PhysOn.Desktop/App.axaml.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PhysOn.Desktop.Services;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
using PhysOn.Desktop.Views;
|
||||
|
||||
namespace PhysOn.Desktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var conversationWindowManager = new ConversationWindowManager();
|
||||
var workspaceLayoutStore = new WorkspaceLayoutStore();
|
||||
var viewModel = new MainWindowViewModel(conversationWindowManager, workspaceLayoutStore);
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = viewModel,
|
||||
};
|
||||
|
||||
_ = viewModel.InitializeAsync();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
BIN
src/PhysOn.Desktop/Assets/avalonia-logo.ico
Normal file
BIN
src/PhysOn.Desktop/Assets/avalonia-logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
8
src/PhysOn.Desktop/Models/DesktopSession.cs
Normal file
8
src/PhysOn.Desktop/Models/DesktopSession.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace PhysOn.Desktop.Models;
|
||||
|
||||
public sealed record DesktopSession(
|
||||
string ApiBaseUrl,
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
string DisplayName,
|
||||
string? LastConversationId);
|
||||
6
src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs
Normal file
6
src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace PhysOn.Desktop.Models;
|
||||
|
||||
public sealed record DesktopWorkspaceLayout(
|
||||
bool IsCompactDensity,
|
||||
bool IsInspectorVisible,
|
||||
bool IsConversationPaneCollapsed);
|
||||
31
src/PhysOn.Desktop/PhysOn.Desktop.csproj
Normal file
31
src/PhysOn.Desktop/PhysOn.Desktop.csproj
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.1" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PhysOn.Contracts\PhysOn.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
23
src/PhysOn.Desktop/Program.cs
Normal file
23
src/PhysOn.Desktop/Program.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace PhysOn.Desktop;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
#if DEBUG
|
||||
.WithDeveloperTools()
|
||||
#endif
|
||||
.LogToTrace();
|
||||
}
|
||||
47
src/PhysOn.Desktop/Services/ConversationWindowManager.cs
Normal file
47
src/PhysOn.Desktop/Services/ConversationWindowManager.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using Avalonia.Controls;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
using PhysOn.Desktop.Views;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public sealed class ConversationWindowManager : IConversationWindowManager
|
||||
{
|
||||
private readonly Dictionary<string, ConversationWindow> _openWindows = new(StringComparer.Ordinal);
|
||||
|
||||
public event Action<int>? WindowCountChanged;
|
||||
|
||||
public Task ShowOrFocusAsync(ConversationWindowLaunch launchContext, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_openWindows.TryGetValue(launchContext.ConversationId, out var existingWindow))
|
||||
{
|
||||
existingWindow.WindowState = WindowState.Normal;
|
||||
existingWindow.Activate();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var viewModel = new ConversationWindowViewModel(launchContext);
|
||||
var window = new ConversationWindow
|
||||
{
|
||||
DataContext = viewModel,
|
||||
Title = launchContext.ConversationTitle
|
||||
};
|
||||
|
||||
window.Closed += (_, _) => _ = HandleWindowClosedAsync(launchContext.ConversationId, viewModel);
|
||||
|
||||
_openWindows[launchContext.ConversationId] = window;
|
||||
WindowCountChanged?.Invoke(_openWindows.Count);
|
||||
|
||||
window.Show();
|
||||
_ = viewModel.InitializeAsync();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleWindowClosedAsync(string conversationId, ConversationWindowViewModel viewModel)
|
||||
{
|
||||
_openWindows.Remove(conversationId);
|
||||
WindowCountChanged?.Invoke(_openWindows.Count);
|
||||
await viewModel.DisposeAsync();
|
||||
}
|
||||
}
|
||||
16
src/PhysOn.Desktop/Services/IConversationWindowManager.cs
Normal file
16
src/PhysOn.Desktop/Services/IConversationWindowManager.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public interface IConversationWindowManager
|
||||
{
|
||||
event Action<int>? WindowCountChanged;
|
||||
|
||||
Task ShowOrFocusAsync(ConversationWindowLaunch launchContext, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record ConversationWindowLaunch(
|
||||
string ApiBaseUrl,
|
||||
string AccessToken,
|
||||
string DisplayName,
|
||||
string ConversationId,
|
||||
string ConversationTitle,
|
||||
string ConversationSubtitle);
|
||||
127
src/PhysOn.Desktop/Services/PhysOnApiClient.cs
Normal file
127
src/PhysOn.Desktop/Services/PhysOnApiClient.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using PhysOn.Contracts.Auth;
|
||||
using PhysOn.Contracts.Common;
|
||||
using PhysOn.Contracts.Conversations;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public sealed class PhysOnApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public Task<RegisterAlphaQuickResponse> RegisterAlphaQuickAsync(
|
||||
string apiBaseUrl,
|
||||
RegisterAlphaQuickRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<RegisterAlphaQuickResponse>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Post,
|
||||
"/v1/auth/register/alpha-quick",
|
||||
null,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<BootstrapResponse> GetBootstrapAsync(
|
||||
string apiBaseUrl,
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<BootstrapResponse>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Get,
|
||||
"/v1/bootstrap",
|
||||
accessToken,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
public Task<ListEnvelope<MessageItemDto>> GetMessagesAsync(
|
||||
string apiBaseUrl,
|
||||
string accessToken,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<ListEnvelope<MessageItemDto>>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Get,
|
||||
$"/v1/conversations/{conversationId}/messages",
|
||||
accessToken,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
public Task<MessageItemDto> SendTextMessageAsync(
|
||||
string apiBaseUrl,
|
||||
string accessToken,
|
||||
string conversationId,
|
||||
PostTextMessageRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<MessageItemDto>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Post,
|
||||
$"/v1/conversations/{conversationId}/messages",
|
||||
accessToken,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<ReadCursorUpdatedDto> UpdateReadCursorAsync(
|
||||
string apiBaseUrl,
|
||||
string accessToken,
|
||||
string conversationId,
|
||||
UpdateReadCursorRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<ReadCursorUpdatedDto>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Post,
|
||||
$"/v1/conversations/{conversationId}/read-cursor",
|
||||
accessToken,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
private static async Task<T> SendAsync<T>(
|
||||
string apiBaseUrl,
|
||||
HttpMethod method,
|
||||
string path,
|
||||
string? accessToken,
|
||||
object? body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(EnsureTrailingSlash(apiBaseUrl)),
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = JsonSerializer.Deserialize<ApiErrorEnvelope>(payload, JsonOptions);
|
||||
throw new InvalidOperationException(error?.Error.Message ?? $"요청이 실패했습니다. ({response.StatusCode})");
|
||||
}
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<ApiEnvelope<T>>(payload, JsonOptions);
|
||||
if (envelope is null)
|
||||
{
|
||||
throw new InvalidOperationException("서버 응답을 읽지 못했습니다.");
|
||||
}
|
||||
|
||||
return envelope.Data;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSlash(string apiBaseUrl) =>
|
||||
apiBaseUrl.EndsWith("/", StringComparison.Ordinal) ? apiBaseUrl : $"{apiBaseUrl}/";
|
||||
}
|
||||
287
src/PhysOn.Desktop/Services/PhysOnRealtimeClient.cs
Normal file
287
src/PhysOn.Desktop/Services/PhysOnRealtimeClient.cs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using PhysOn.Contracts.Conversations;
|
||||
using PhysOn.Contracts.Realtime;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public enum RealtimeConnectionState
|
||||
{
|
||||
Idle,
|
||||
Connecting,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
Disconnected
|
||||
}
|
||||
|
||||
public sealed class PhysOnRealtimeClient : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly SemaphoreSlim _lifecycleLock = new(1, 1);
|
||||
|
||||
private ClientWebSocket? _socket;
|
||||
private CancellationTokenSource? _connectionCts;
|
||||
private Task? _connectionTask;
|
||||
private bool _disposed;
|
||||
|
||||
public event Action<RealtimeConnectionState>? ConnectionStateChanged;
|
||||
public event Action<SessionConnectedDto>? SessionConnected;
|
||||
public event Action<MessageItemDto>? MessageCreated;
|
||||
public event Action<ReadCursorUpdatedDto>? ReadCursorUpdated;
|
||||
|
||||
public async Task ConnectAsync(string wsUrl, string accessToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _lifecycleLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await DisconnectCoreAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(wsUrl) || string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
NotifyStateChanged(RealtimeConnectionState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
_connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_connectionTask = RunConnectionLoopAsync(new Uri(wsUrl), accessToken, _connectionCts.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifecycleLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _lifecycleLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await DisconnectCoreAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifecycleLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
await DisconnectAsync();
|
||||
_lifecycleLock.Dispose();
|
||||
}
|
||||
|
||||
private async Task DisconnectCoreAsync()
|
||||
{
|
||||
var cts = _connectionCts;
|
||||
var socket = _socket;
|
||||
var task = _connectionTask;
|
||||
|
||||
_connectionCts = null;
|
||||
_connectionTask = null;
|
||||
_socket = null;
|
||||
|
||||
if (cts is null && socket is null && task is null)
|
||||
{
|
||||
NotifyStateChanged(RealtimeConnectionState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cts?.Cancel();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cancellation races during shutdown.
|
||||
}
|
||||
|
||||
if (socket is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "shutdown", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore close failures during shutdown.
|
||||
}
|
||||
finally
|
||||
{
|
||||
socket.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (task is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when the client is disposed or explicitly disconnected.
|
||||
}
|
||||
}
|
||||
|
||||
cts?.Dispose();
|
||||
NotifyStateChanged(RealtimeConnectionState.Idle);
|
||||
}
|
||||
|
||||
private async Task RunConnectionLoopAsync(Uri wsUri, string accessToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var reconnecting = false;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using var socket = new ClientWebSocket();
|
||||
socket.Options.SetRequestHeader("Authorization", $"Bearer {accessToken}");
|
||||
_socket = socket;
|
||||
|
||||
NotifyStateChanged(reconnecting ? RealtimeConnectionState.Reconnecting : RealtimeConnectionState.Connecting);
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(wsUri, cancellationToken);
|
||||
NotifyStateChanged(RealtimeConnectionState.Connected);
|
||||
await ReceiveLoopAsync(socket, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
NotifyStateChanged(RealtimeConnectionState.Disconnected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_socket, socket))
|
||||
{
|
||||
_socket = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore connection teardown errors.
|
||||
}
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
reconnecting = true;
|
||||
NotifyStateChanged(RealtimeConnectionState.Disconnected);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NotifyStateChanged(RealtimeConnectionState.Idle);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
WebSocketReceiveResult result;
|
||||
|
||||
do
|
||||
{
|
||||
result = await socket.ReceiveAsync(buffer, cancellationToken);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
stream.Write(buffer, 0, result.Count);
|
||||
}
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType != WebSocketMessageType.Text || stream.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
DispatchIncomingEvent(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchIncomingEvent(Stream payloadStream)
|
||||
{
|
||||
using var document = JsonDocument.Parse(payloadStream);
|
||||
if (!document.RootElement.TryGetProperty("event", out var eventProperty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.RootElement.TryGetProperty("data", out var dataProperty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var eventName = eventProperty.GetString();
|
||||
switch (eventName)
|
||||
{
|
||||
case "session.connected":
|
||||
var sessionConnected = dataProperty.Deserialize<SessionConnectedDto>(JsonOptions);
|
||||
if (sessionConnected is not null)
|
||||
{
|
||||
SessionConnected?.Invoke(sessionConnected);
|
||||
}
|
||||
break;
|
||||
|
||||
case "message.created":
|
||||
var messageCreated = dataProperty.Deserialize<MessageItemDto>(JsonOptions);
|
||||
if (messageCreated is not null)
|
||||
{
|
||||
MessageCreated?.Invoke(messageCreated);
|
||||
}
|
||||
break;
|
||||
|
||||
case "read_cursor.updated":
|
||||
var readCursorUpdated = dataProperty.Deserialize<ReadCursorUpdatedDto>(JsonOptions);
|
||||
if (readCursorUpdated is not null)
|
||||
{
|
||||
ReadCursorUpdated?.Invoke(readCursorUpdated);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyStateChanged(RealtimeConnectionState state) => ConnectionStateChanged?.Invoke(state);
|
||||
}
|
||||
136
src/PhysOn.Desktop/Services/SessionStore.cs
Normal file
136
src/PhysOn.Desktop/Services/SessionStore.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using PhysOn.Desktop.Models;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public sealed class SessionStore
|
||||
{
|
||||
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("PhysOn.Desktop.Session");
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly byte[] WindowsHeader = "VSMW1"u8.ToArray();
|
||||
|
||||
private readonly string _sessionDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PhysOn");
|
||||
private readonly string _sessionPath;
|
||||
private readonly string _legacySessionPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PhysOn",
|
||||
"session.json");
|
||||
|
||||
public SessionStore()
|
||||
{
|
||||
_sessionPath = OperatingSystem.IsWindows()
|
||||
? Path.Combine(_sessionDirectory, "session.dat")
|
||||
: Path.Combine(_sessionDirectory, "session.json");
|
||||
}
|
||||
|
||||
public async Task<DesktopSession?> LoadAsync()
|
||||
{
|
||||
if (File.Exists(_sessionPath))
|
||||
{
|
||||
return await LoadFromPathAsync(_sessionPath);
|
||||
}
|
||||
|
||||
if (File.Exists(_legacySessionPath))
|
||||
{
|
||||
return await LoadFromPathAsync(_legacySessionPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task SaveAsync(DesktopSession session)
|
||||
{
|
||||
Directory.CreateDirectory(_sessionDirectory);
|
||||
ApplyDirectoryPermissions(_sessionDirectory);
|
||||
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(session, JsonOptions);
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
payload = WindowsHeader
|
||||
.Concat(ProtectedData.Protect(payload, Entropy, DataProtectionScope.CurrentUser))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
await File.WriteAllBytesAsync(_sessionPath, payload);
|
||||
ApplyFilePermissions(_sessionPath);
|
||||
|
||||
if (File.Exists(_legacySessionPath))
|
||||
{
|
||||
File.Delete(_legacySessionPath);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ClearAsync()
|
||||
{
|
||||
if (File.Exists(_sessionPath))
|
||||
{
|
||||
File.Delete(_sessionPath);
|
||||
}
|
||||
|
||||
if (File.Exists(_legacySessionPath))
|
||||
{
|
||||
File.Delete(_legacySessionPath);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<DesktopSession?> LoadFromPathAsync(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = await File.ReadAllBytesAsync(path);
|
||||
if (payload.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows() && payload.AsSpan().StartsWith(WindowsHeader))
|
||||
{
|
||||
var encrypted = payload.AsSpan(WindowsHeader.Length).ToArray();
|
||||
var decrypted = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
||||
return JsonSerializer.Deserialize<DesktopSession>(decrypted, JsonOptions);
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<DesktopSession>(payload, JsonOptions);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyDirectoryPermissions(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetAttributes(path, FileAttributes.Directory | FileAttributes.Hidden);
|
||||
return;
|
||||
}
|
||||
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
}
|
||||
|
||||
private static void ApplyFilePermissions(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetAttributes(path, FileAttributes.Hidden);
|
||||
return;
|
||||
}
|
||||
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
51
src/PhysOn.Desktop/Services/WorkspaceLayoutStore.cs
Normal file
51
src/PhysOn.Desktop/Services/WorkspaceLayoutStore.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using System.Text.Json;
|
||||
using PhysOn.Desktop.Models;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public sealed class WorkspaceLayoutStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _directoryPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PhysOn");
|
||||
private readonly string _layoutPath;
|
||||
|
||||
public WorkspaceLayoutStore()
|
||||
{
|
||||
_layoutPath = Path.Combine(_directoryPath, "workspace-layout.json");
|
||||
}
|
||||
|
||||
public async Task<DesktopWorkspaceLayout?> LoadAsync()
|
||||
{
|
||||
if (!File.Exists(_layoutPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = await File.ReadAllTextAsync(_layoutPath);
|
||||
return JsonSerializer.Deserialize<DesktopWorkspaceLayout>(payload, JsonOptions);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync(DesktopWorkspaceLayout layout)
|
||||
{
|
||||
Directory.CreateDirectory(_directoryPath);
|
||||
var payload = JsonSerializer.Serialize(layout, JsonOptions);
|
||||
await File.WriteAllTextAsync(_layoutPath, payload);
|
||||
}
|
||||
}
|
||||
37
src/PhysOn.Desktop/ViewLocator.cs
Normal file
37
src/PhysOn.Desktop/ViewLocator.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
|
||||
namespace PhysOn.Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Given a view model, returns the corresponding view if possible.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode(
|
||||
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
|
||||
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
|
||||
public class ViewLocator : IDataTemplate
|
||||
{
|
||||
public Control? Build(object? param)
|
||||
{
|
||||
if (param is null)
|
||||
return null;
|
||||
|
||||
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||
var type = Type.GetType(name);
|
||||
|
||||
if (type != null)
|
||||
{
|
||||
return (Control)Activator.CreateInstance(type)!;
|
||||
}
|
||||
|
||||
return new TextBlock { Text = "Not Found: " + name };
|
||||
}
|
||||
|
||||
public bool Match(object? data)
|
||||
{
|
||||
return data is ViewModelBase;
|
||||
}
|
||||
}
|
||||
32
src/PhysOn.Desktop/ViewModels/ConversationRowViewModel.cs
Normal file
32
src/PhysOn.Desktop/ViewModels/ConversationRowViewModel.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PhysOn.Desktop.ViewModels;
|
||||
|
||||
public partial class ConversationRowViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string conversationId = string.Empty;
|
||||
[ObservableProperty] private string title = string.Empty;
|
||||
[ObservableProperty] private string subtitle = string.Empty;
|
||||
[ObservableProperty] private string lastMessageText = string.Empty;
|
||||
[ObservableProperty] private string metaText = string.Empty;
|
||||
[ObservableProperty] private int unreadCount;
|
||||
[ObservableProperty] private bool isPinned;
|
||||
[ObservableProperty] private bool isSelected;
|
||||
[ObservableProperty] private long lastReadSequence;
|
||||
[ObservableProperty] private DateTimeOffset sortKey;
|
||||
|
||||
public bool HasUnread => UnreadCount > 0;
|
||||
public string UnreadBadgeText => UnreadCount.ToString();
|
||||
public string AvatarText => string.IsNullOrWhiteSpace(Title) ? "VS" : Title.Trim()[..Math.Min(2, Title.Trim().Length)];
|
||||
|
||||
partial void OnUnreadCountChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasUnread));
|
||||
OnPropertyChanged(nameof(UnreadBadgeText));
|
||||
}
|
||||
|
||||
partial void OnTitleChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AvatarText));
|
||||
}
|
||||
}
|
||||
224
src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs
Normal file
224
src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PhysOn.Contracts.Conversations;
|
||||
using PhysOn.Contracts.Realtime;
|
||||
using PhysOn.Desktop.Services;
|
||||
|
||||
namespace PhysOn.Desktop.ViewModels;
|
||||
|
||||
public partial class ConversationWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||
{
|
||||
private readonly PhysOnApiClient _apiClient = new();
|
||||
private readonly PhysOnRealtimeClient _realtimeClient = new();
|
||||
private readonly ConversationWindowLaunch _launchContext;
|
||||
|
||||
public ConversationWindowViewModel(ConversationWindowLaunch launchContext)
|
||||
{
|
||||
_launchContext = launchContext;
|
||||
ConversationTitle = launchContext.ConversationTitle;
|
||||
ConversationSubtitle = launchContext.ConversationSubtitle;
|
||||
SendMessageCommand = new AsyncRelayCommand(SendMessageAsync, CanSendMessage);
|
||||
ReloadCommand = new AsyncRelayCommand(LoadMessagesAsync, () => !IsBusy);
|
||||
|
||||
_realtimeClient.ConnectionStateChanged += HandleRealtimeConnectionStateChanged;
|
||||
_realtimeClient.MessageCreated += HandleMessageCreated;
|
||||
}
|
||||
|
||||
public ObservableCollection<MessageRowViewModel> Messages { get; } = [];
|
||||
|
||||
public IAsyncRelayCommand SendMessageCommand { get; }
|
||||
public IAsyncRelayCommand ReloadCommand { get; }
|
||||
|
||||
[ObservableProperty] private string conversationTitle = string.Empty;
|
||||
[ObservableProperty] private string conversationSubtitle = string.Empty;
|
||||
[ObservableProperty] private string composerText = string.Empty;
|
||||
[ObservableProperty] private string statusText = "·";
|
||||
[ObservableProperty] private bool isBusy;
|
||||
[ObservableProperty] private string? errorText;
|
||||
|
||||
public string ConversationGlyph =>
|
||||
string.IsNullOrWhiteSpace(ConversationTitle) ? "PO" : ConversationTitle.Trim()[..Math.Min(2, ConversationTitle.Trim().Length)];
|
||||
|
||||
public bool HasErrorText => !string.IsNullOrWhiteSpace(ErrorText);
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await LoadMessagesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var bootstrap = await _apiClient.GetBootstrapAsync(
|
||||
_launchContext.ApiBaseUrl,
|
||||
_launchContext.AccessToken,
|
||||
CancellationToken.None);
|
||||
await _realtimeClient.ConnectAsync(bootstrap.Ws.Url, _launchContext.AccessToken, CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ErrorText = exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendMessageFromShortcutAsync()
|
||||
{
|
||||
if (SendMessageCommand.CanExecute(null))
|
||||
{
|
||||
await SendMessageCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _realtimeClient.DisposeAsync();
|
||||
}
|
||||
|
||||
partial void OnComposerTextChanged(string value) => SendMessageCommand.NotifyCanExecuteChanged();
|
||||
partial void OnErrorTextChanged(string? value) => OnPropertyChanged(nameof(HasErrorText));
|
||||
partial void OnConversationTitleChanged(string value) => OnPropertyChanged(nameof(ConversationGlyph));
|
||||
|
||||
private async Task LoadMessagesAsync()
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IsBusy = true;
|
||||
ErrorText = null;
|
||||
StatusText = "◌";
|
||||
|
||||
var items = await _apiClient.GetMessagesAsync(
|
||||
_launchContext.ApiBaseUrl,
|
||||
_launchContext.AccessToken,
|
||||
_launchContext.ConversationId,
|
||||
CancellationToken.None);
|
||||
|
||||
Messages.Clear();
|
||||
foreach (var item in items.Items.OrderBy(message => message.ServerSequence))
|
||||
{
|
||||
Messages.Add(MapMessage(item));
|
||||
}
|
||||
|
||||
StatusText = "●";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ErrorText = exception.Message;
|
||||
StatusText = "×";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
ReloadCommand.NotifyCanExecuteChanged();
|
||||
SendMessageCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendMessageAsync()
|
||||
{
|
||||
if (!CanSendMessage())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var draft = ComposerText.Trim();
|
||||
var clientMessageId = Guid.NewGuid();
|
||||
ComposerText = string.Empty;
|
||||
|
||||
var pendingMessage = new MessageRowViewModel
|
||||
{
|
||||
MessageId = $"pending-{Guid.NewGuid():N}",
|
||||
ClientMessageId = clientMessageId,
|
||||
Text = draft,
|
||||
SenderName = _launchContext.DisplayName,
|
||||
MetaText = "보내는 중",
|
||||
IsMine = true,
|
||||
IsPending = true,
|
||||
ServerSequence = Messages.Count == 0 ? 1 : Messages[^1].ServerSequence + 1
|
||||
};
|
||||
|
||||
Messages.Add(pendingMessage);
|
||||
|
||||
try
|
||||
{
|
||||
var committed = await _apiClient.SendTextMessageAsync(
|
||||
_launchContext.ApiBaseUrl,
|
||||
_launchContext.AccessToken,
|
||||
_launchContext.ConversationId,
|
||||
new PostTextMessageRequest(clientMessageId, draft),
|
||||
CancellationToken.None);
|
||||
|
||||
Messages.Remove(pendingMessage);
|
||||
UpsertMessage(MapMessage(committed));
|
||||
StatusText = "●";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
pendingMessage.IsPending = false;
|
||||
pendingMessage.IsFailed = true;
|
||||
pendingMessage.MetaText = "전송 실패";
|
||||
ErrorText = exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanSendMessage() => !IsBusy && !string.IsNullOrWhiteSpace(ComposerText);
|
||||
|
||||
private void HandleRealtimeConnectionStateChanged(RealtimeConnectionState state)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
StatusText = state switch
|
||||
{
|
||||
RealtimeConnectionState.Connected => "●",
|
||||
RealtimeConnectionState.Reconnecting => "◔",
|
||||
RealtimeConnectionState.Disconnected => "○",
|
||||
RealtimeConnectionState.Connecting => "◌",
|
||||
_ => StatusText
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleMessageCreated(MessageItemDto payload)
|
||||
{
|
||||
if (!string.Equals(payload.ConversationId, _launchContext.ConversationId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() => UpsertMessage(MapMessage(payload)));
|
||||
}
|
||||
|
||||
private static MessageRowViewModel MapMessage(MessageItemDto item)
|
||||
{
|
||||
return new MessageRowViewModel
|
||||
{
|
||||
MessageId = item.MessageId,
|
||||
ClientMessageId = item.ClientMessageId,
|
||||
Text = item.Text,
|
||||
SenderName = item.Sender.DisplayName,
|
||||
MetaText = item.CreatedAt.LocalDateTime.ToString("HH:mm"),
|
||||
IsMine = item.IsMine,
|
||||
ServerSequence = item.ServerSequence
|
||||
};
|
||||
}
|
||||
|
||||
private void UpsertMessage(MessageRowViewModel next)
|
||||
{
|
||||
var existing = Messages.FirstOrDefault(item =>
|
||||
string.Equals(item.MessageId, next.MessageId, StringComparison.Ordinal) ||
|
||||
(next.ClientMessageId != Guid.Empty && item.ClientMessageId == next.ClientMessageId));
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
var index = Messages.IndexOf(existing);
|
||||
Messages[index] = next;
|
||||
return;
|
||||
}
|
||||
|
||||
Messages.Add(next);
|
||||
}
|
||||
}
|
||||
1052
src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
Normal file
1052
src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
Normal file
File diff suppressed because it is too large
Load diff
23
src/PhysOn.Desktop/ViewModels/MessageRowViewModel.cs
Normal file
23
src/PhysOn.Desktop/ViewModels/MessageRowViewModel.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PhysOn.Desktop.ViewModels;
|
||||
|
||||
public partial class MessageRowViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string messageId = string.Empty;
|
||||
[ObservableProperty] private Guid clientMessageId;
|
||||
[ObservableProperty] private string text = string.Empty;
|
||||
[ObservableProperty] private string senderName = string.Empty;
|
||||
[ObservableProperty] private string metaText = string.Empty;
|
||||
[ObservableProperty] private bool isMine;
|
||||
[ObservableProperty] private bool isPending;
|
||||
[ObservableProperty] private bool isFailed;
|
||||
[ObservableProperty] private long serverSequence;
|
||||
|
||||
public bool ShowSenderName => !IsMine;
|
||||
|
||||
partial void OnIsMineChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowSenderName));
|
||||
}
|
||||
}
|
||||
7
src/PhysOn.Desktop/ViewModels/ViewModelBase.cs
Normal file
7
src/PhysOn.Desktop/ViewModels/ViewModelBase.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PhysOn.Desktop.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
142
src/PhysOn.Desktop/Views/ConversationWindow.axaml
Normal file
142
src/PhysOn.Desktop/Views/ConversationWindow.axaml
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PhysOn.Desktop.ViewModels"
|
||||
x:Class="PhysOn.Desktop.Views.ConversationWindow"
|
||||
x:DataType="vm:ConversationWindowViewModel"
|
||||
Width="460"
|
||||
Height="760"
|
||||
MinWidth="360"
|
||||
MinHeight="520"
|
||||
Background="#F6F7F8"
|
||||
Title="{Binding ConversationTitle}">
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.surface">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E6E8EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.soft">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="BorderBrush" Value="#ECEEF1" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="9,7" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#D9DDE2" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="12,9" />
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="#6A7380" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="#151A20" />
|
||||
</Style>
|
||||
<Style Selector="Border.bubble">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="8,7" />
|
||||
<Setter Property="Margin" Value="0,0,0,5" />
|
||||
<Setter Property="Background" Value="#F6F7F8" />
|
||||
<Setter Property="BorderBrush" Value="#EAEDF0" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
<Style Selector="Border.bubble.mine">
|
||||
<Setter Property="Background" Value="#ECEFF3" />
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid Margin="16" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="10">
|
||||
<Border Classes="surface" Padding="12">
|
||||
<Grid ColumnDefinitions="42,*,Auto" ColumnSpacing="12">
|
||||
<Border Width="42" Height="42" Classes="soft">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding ConversationGlyph}"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#151A20" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="2">
|
||||
<TextBlock Text="{Binding ConversationTitle}"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#151A20"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||
<Border Classes="soft" Padding="8,5">
|
||||
<TextBlock Text="{Binding StatusText}" Classes="caption" />
|
||||
</Border>
|
||||
<Button Classes="icon"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="↻" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Classes="surface"
|
||||
Padding="10"
|
||||
IsVisible="{Binding HasErrorText}">
|
||||
<TextBlock Text="{Binding ErrorText}"
|
||||
Foreground="#C62828"
|
||||
Classes="caption"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2" Classes="surface" Padding="12">
|
||||
<ScrollViewer Name="MessagesScrollViewer">
|
||||
<ItemsControl ItemsSource="{Binding Messages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
||||
<Border Classes="bubble"
|
||||
Classes.mine="{Binding IsMine}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding SenderName}"
|
||||
Classes="caption"
|
||||
FontWeight="SemiBold"
|
||||
IsVisible="{Binding ShowSenderName}" />
|
||||
<TextBlock Text="{Binding Text}"
|
||||
Classes="body"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding MetaText}" Classes="caption" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3" Classes="surface" Padding="10">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||
<TextBox Name="ComposerTextBox"
|
||||
PlaceholderText="메시지"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="46"
|
||||
Text="{Binding ComposerText}"
|
||||
KeyDown="ComposerTextBox_OnKeyDown" />
|
||||
<Button Grid.Column="1"
|
||||
Classes="primary"
|
||||
Command="{Binding SendMessageCommand}"
|
||||
Content="보내기" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
72
src/PhysOn.Desktop/Views/ConversationWindow.axaml.cs
Normal file
72
src/PhysOn.Desktop/Views/ConversationWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System.Collections.Specialized;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
|
||||
namespace PhysOn.Desktop.Views;
|
||||
|
||||
public partial class ConversationWindow : Window
|
||||
{
|
||||
private ConversationWindowViewModel? _boundViewModel;
|
||||
|
||||
public ConversationWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private async void ComposerTextBox_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
if (DataContext is ConversationWindowViewModel viewModel)
|
||||
{
|
||||
await viewModel.SendMessageFromShortcutAsync();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
}
|
||||
|
||||
_boundViewModel = DataContext as ConversationWindowViewModel;
|
||||
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged += Messages_OnCollectionChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void Messages_OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action is NotifyCollectionChangedAction.Add or NotifyCollectionChangedAction.Reset or NotifyCollectionChangedAction.Replace)
|
||||
{
|
||||
Dispatcher.UIThread.Post(ScrollMessagesToEnd, DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
}
|
||||
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
private void ScrollMessagesToEnd()
|
||||
{
|
||||
if (this.FindControl<ScrollViewer>("MessagesScrollViewer") is { } scrollViewer)
|
||||
{
|
||||
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, scrollViewer.Extent.Height);
|
||||
}
|
||||
}
|
||||
}
|
||||
712
src/PhysOn.Desktop/Views/MainWindow.axaml
Normal file
712
src/PhysOn.Desktop/Views/MainWindow.axaml
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PhysOn.Desktop.ViewModels"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
x:Name="RootWindow"
|
||||
x:Class="PhysOn.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
d:DesignWidth="1440"
|
||||
d:DesignHeight="900"
|
||||
Title="KoTalk"
|
||||
Width="1440"
|
||||
Height="900"
|
||||
MinWidth="1040"
|
||||
MinHeight="680"
|
||||
Background="#F6F7F8">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.surface">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.surface-muted">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Background" Value="#F5F6F7" />
|
||||
<Setter Property="BorderBrush" Value="#E8EBEF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.rail-surface">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.dashboard-card">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="BorderBrush" Value="#E8EBEF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
</Style>
|
||||
<Style Selector="Border.empty-card">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="18" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.display-title">
|
||||
<Setter Property="FontSize" Value="28" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="#111418" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.section-title">
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="#111418" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Foreground" Value="#1D232B" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="Foreground" Value="#6A7480" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.eyebrow">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="#6A7480" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.input">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="11,8" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#D9DEE4" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.search-input">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
<Setter Property="Background" Value="#F6F7F8" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary-button">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="14,10" />
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary-button">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="13,10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="Foreground" Value="#1E252C" />
|
||||
<Setter Property="BorderBrush" Value="#D9DEE4" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon-button">
|
||||
<Setter Property="Height" Value="34" />
|
||||
<Setter Property="MinWidth" Value="34" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="10,0" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="Foreground" Value="#1E252C" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon-button.compact">
|
||||
<Setter Property="MinWidth" Value="28" />
|
||||
<Setter Property="Height" Value="28" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="8,0" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
</Style>
|
||||
<Style Selector="Button.filter-button">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="10,6" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="Foreground" Value="#69727D" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Button.filter-button.selected">
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#111418" />
|
||||
</Style>
|
||||
<Style Selector="Button.rail-button">
|
||||
<Setter Property="MinWidth" Value="48" />
|
||||
<Setter Property="Height" Value="52" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="Foreground" Value="#1E252C" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
<Style Selector="Button.rail-button.active">
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#111418" />
|
||||
</Style>
|
||||
<Style Selector="Button.row-button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
<Style Selector="Border.row-card">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#EEF1F4" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.row-card.active">
|
||||
<Setter Property="Background" Value="#F3F5F7" />
|
||||
<Setter Property="BorderBrush" Value="#D8DDE4" />
|
||||
</Style>
|
||||
<Style Selector="Border.status-chip">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.inline-alert">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#C9392C" />
|
||||
<Setter Property="BorderThickness" Value="1,0,0,0" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
</Style>
|
||||
<Style Selector="Border.avatar-badge">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#F3F4F6" />
|
||||
</Style>
|
||||
<Style Selector="Border.unread-badge">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Padding" Value="7,2" />
|
||||
</Style>
|
||||
<Style Selector="Border.message-bubble">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="MaxWidth" Value="680" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
<Style Selector="Border.message-bubble.mine">
|
||||
<Setter Property="Background" Value="#EEF1F4" />
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
<Style Selector="Border.message-bubble.pending">
|
||||
<Setter Property="Opacity" Value="0.72" />
|
||||
</Style>
|
||||
<Style Selector="Border.message-bubble.failed">
|
||||
<Setter Property="BorderBrush" Value="#C9392C" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.message-text">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="#111418" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.message-meta">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Foreground" Value="#69727D" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid Margin="20">
|
||||
<Grid IsVisible="{Binding ShowOnboarding}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
MaxWidth="1120"
|
||||
ColumnDefinitions="1.2*,420"
|
||||
ColumnSpacing="18">
|
||||
<Border Grid.Column="0" Classes="surface" Padding="28">
|
||||
<Grid RowDefinitions="Auto,Auto,*" RowSpacing="18">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="KTOP" Classes="eyebrow" />
|
||||
<StackPanel Spacing="6">
|
||||
<Border Width="60" Height="60" Classes="surface-muted" HorizontalAlignment="Left">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="KO"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
<TextBlock Text="KoTalk" Classes="display-title" />
|
||||
<TextBlock Text="열면 바로 이어지는 대화 워크스페이스" Classes="body" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,*,*" ColumnSpacing="10">
|
||||
<Border Grid.Column="0" Classes="dashboard-card">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="빠른 입장" Classes="eyebrow" />
|
||||
<TextBlock Text="표시 이름 + 참여 키" Classes="section-title" />
|
||||
<TextBlock Text="필수 입력만 바로 보입니다." Classes="caption" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Classes="dashboard-card">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="대화 집중" Classes="eyebrow" />
|
||||
<TextBlock Text="받은함과 대화만 남김" Classes="section-title" />
|
||||
<TextBlock Text="불필요한 빈 패널을 줄였습니다." Classes="caption" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="2" Classes="dashboard-card">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="멀티 윈도우" Classes="eyebrow" />
|
||||
<TextBlock Text="대화별 분리 창" Classes="section-title" />
|
||||
<TextBlock Text="작업 흐름을 끊지 않고 분리합니다." Classes="caption" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="2" Classes="surface-muted" Padding="16">
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||
<Border Width="36" Height="36" Classes="surface">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="·"
|
||||
FontWeight="Bold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="세션은 이 기기에만 남깁니다." Classes="section-title" />
|
||||
<TextBlock Text="서버 주소는 기본으로 숨겨 두고, 변경이 필요할 때만 고급 설정에서 엽니다." Classes="caption" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Classes="surface" Padding="22">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*" RowSpacing="10">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="입장" Classes="section-title" />
|
||||
<TextBlock Text="필수 입력만 먼저 받습니다." Classes="caption" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Grid.Row="1"
|
||||
Classes="input"
|
||||
PlaceholderText="표시 이름"
|
||||
Text="{Binding DisplayName}" />
|
||||
|
||||
<TextBox Grid.Row="2"
|
||||
Classes="input"
|
||||
PlaceholderText="참여 키"
|
||||
Text="{Binding InviteCode}" />
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" ColumnSpacing="10">
|
||||
<Button Classes="secondary-button"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding ToggleAdvancedSettingsCommand}"
|
||||
Content="{Binding AdvancedSettingsButtonText}" />
|
||||
<TextBox Grid.Column="1"
|
||||
Classes="input"
|
||||
IsVisible="{Binding ShowAdvancedSettings}"
|
||||
PlaceholderText="기본 서버를 바꾸는 경우만 입력"
|
||||
Text="{Binding ApiBaseUrl}" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="4" VerticalAlignment="Bottom" Spacing="10">
|
||||
<CheckBox Content="이 기기에서 이어서 열기" IsChecked="{Binding RememberSession}" />
|
||||
<Border Classes="inline-alert" IsVisible="{Binding HasErrorText}">
|
||||
<TextBlock Text="{Binding ErrorText}"
|
||||
Classes="caption"
|
||||
Foreground="#C9392C"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<Button Classes="primary-button"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{Binding SignInCommand}"
|
||||
Content="대화 열기" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid IsVisible="{Binding ShowShell}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="72" />
|
||||
<ColumnDefinition Width="{Binding ConversationPaneWidth}" />
|
||||
<ColumnDefinition Width="8" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Classes="rail-surface" Padding="10">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Spacing="12">
|
||||
<Border Width="48" Height="48" Classes="surface-muted">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="KO"
|
||||
FontWeight="Bold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
|
||||
<Button Classes="rail-button active" ToolTip.Tip="받은함">
|
||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||
<TextBlock Text="⌂" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="대화" Classes="caption" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="rail-button"
|
||||
ToolTip.Tip="대화를 분리 창으로 열기"
|
||||
Command="{Binding DetachConversationCommand}">
|
||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding DetachedWindowActionGlyph}" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="분리" Classes="caption" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="rail-button"
|
||||
ToolTip.Tip="대화 목록 접기"
|
||||
Command="{Binding ToggleConversationPaneCommand}">
|
||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding PaneActionGlyph}" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="목록" Classes="caption" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2" Spacing="10">
|
||||
<Border Width="48" Height="48" Classes="surface-muted">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding CurrentUserMonogram}"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#1E252C" />
|
||||
</Border>
|
||||
<Button Classes="rail-button"
|
||||
ToolTip.Tip="로그아웃"
|
||||
Command="{Binding SignOutCommand}">
|
||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||
<TextBlock Text="⎋" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="종료" Classes="caption" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Classes="surface"
|
||||
Padding="14"
|
||||
IsVisible="{Binding IsConversationPaneExpanded}">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" RowSpacing="12">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="받은함" Classes="section-title" />
|
||||
<TextBlock Text="{Binding CurrentUserDisplayName}" Classes="caption" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Column="1" Classes="status-chip" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StatusSummaryText}" Classes="caption" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0"
|
||||
Classes="search-input"
|
||||
PlaceholderText="{Binding SearchWatermark}"
|
||||
Text="{Binding ConversationSearchText}" />
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="icon-button"
|
||||
ToolTip.Tip="밀도 전환"
|
||||
Command="{Binding ToggleCompactModeCommand}"
|
||||
Content="{Binding DensityGlyph}" />
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-button"
|
||||
ToolTip.Tip="새로고침"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="↻" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="2" Spacing="10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="filter-button"
|
||||
Classes.selected="{Binding IsAllFilterSelected}"
|
||||
Command="{Binding ShowAllConversationsCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="전체" />
|
||||
<TextBlock Text="{Binding TotalConversationCount}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="filter-button"
|
||||
Classes.selected="{Binding IsUnreadFilterSelected}"
|
||||
Command="{Binding ShowUnreadConversationsCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="안읽음" />
|
||||
<TextBlock Text="{Binding UnreadConversationCount}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="filter-button"
|
||||
Classes.selected="{Binding IsPinnedFilterSelected}"
|
||||
Command="{Binding ShowPinnedConversationsCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="고정" />
|
||||
<TextBlock Text="{Binding PinnedConversationCount}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<Border Classes="empty-card"
|
||||
IsVisible="{Binding ShowConversationEmptyState}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding ConversationEmptyStateText}" Classes="section-title" />
|
||||
<TextBlock Text="필터를 바꾸거나 새로고침으로 다시 맞춰 보세요." Classes="caption" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="secondary-button"
|
||||
Command="{Binding ShowAllConversationsCommand}"
|
||||
Content="전체 보기" />
|
||||
<Button Classes="secondary-button"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="다시 확인" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer IsVisible="{Binding HasFilteredConversations}">
|
||||
<ItemsControl ItemsSource="{Binding FilteredConversations}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConversationRowViewModel">
|
||||
<Grid Margin="0,0,0,6" ColumnDefinitions="*,Auto" ColumnSpacing="6">
|
||||
<Button Classes="row-button"
|
||||
Command="{Binding $parent[Window].DataContext.SelectConversationCommand}"
|
||||
CommandParameter="{Binding .}">
|
||||
<Border Classes="row-card"
|
||||
Classes.active="{Binding IsSelected}"
|
||||
Padding="{Binding $parent[Window].DataContext.ConversationRowPadding}">
|
||||
<Grid ColumnDefinitions="34,*,Auto" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="2">
|
||||
<Border Width="34"
|
||||
Height="34"
|
||||
Classes="avatar-badge">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding AvatarText}"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Title}"
|
||||
Classes="body"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding MetaText}"
|
||||
Classes="caption" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Text="{Binding LastMessageText}"
|
||||
Classes="caption"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<StackPanel Grid.Row="1"
|
||||
Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
HorizontalAlignment="Right">
|
||||
<TextBlock Text="★"
|
||||
Classes="caption"
|
||||
IsVisible="{Binding IsPinned}" />
|
||||
<Border Classes="unread-badge"
|
||||
IsVisible="{Binding HasUnread}">
|
||||
<TextBlock Text="{Binding UnreadBadgeText}"
|
||||
FontSize="11"
|
||||
Foreground="#FFFFFF" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Button>
|
||||
<Button Grid.Column="1"
|
||||
Classes="icon-button compact"
|
||||
ToolTip.Tip="분리 창으로 열기"
|
||||
Command="{Binding $parent[Window].DataContext.DetachConversationRowCommand}"
|
||||
CommandParameter="{Binding .}"
|
||||
Content="↗" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,Auto" ColumnSpacing="8">
|
||||
<Border Classes="status-chip">
|
||||
<TextBlock Text="{Binding RealtimeStatusText}" Classes="caption" />
|
||||
</Border>
|
||||
<Border Grid.Column="1" Classes="status-chip">
|
||||
<TextBlock Text="{Binding WorkspaceModeText}" Classes="caption" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="2"
|
||||
Width="6"
|
||||
IsVisible="{Binding IsConversationPaneExpanded}"
|
||||
Background="#E4E7EB"
|
||||
ResizeDirection="Columns"
|
||||
ShowsPreview="True" />
|
||||
|
||||
<Border Grid.Column="3" Classes="surface" Padding="14">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" RowSpacing="12">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto" ColumnSpacing="8">
|
||||
<Grid Grid.Column="0" ColumnDefinitions="Auto,*" ColumnSpacing="10">
|
||||
<Border Width="38" Height="38" Classes="surface-muted">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding SelectedConversationGlyph}"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Spacing="2">
|
||||
<TextBlock Text="{Binding SelectedConversationTitle}"
|
||||
Classes="section-title"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding SelectedConversationSubtitle}"
|
||||
Classes="caption"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Classes="status-chip" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding RealtimeStatusText}" Classes="caption" />
|
||||
</Border>
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-button"
|
||||
ToolTip.Tip="분리 창으로 열기"
|
||||
Command="{Binding DetachConversationCommand}"
|
||||
Content="↗" />
|
||||
<Button Grid.Column="3"
|
||||
Classes="icon-button"
|
||||
ToolTip.Tip="새로고침"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="↻" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,*,*" ColumnSpacing="8">
|
||||
<Border Grid.Column="0" Classes="dashboard-card">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="안읽음" Classes="eyebrow" />
|
||||
<TextBlock Text="{Binding UnreadConversationCount}" Classes="section-title" />
|
||||
<TextBlock Text="지금 바로 볼 대화" Classes="caption" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Classes="dashboard-card">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="고정" Classes="eyebrow" />
|
||||
<TextBlock Text="{Binding PinnedConversationCount}" Classes="section-title" />
|
||||
<TextBlock Text="자주 여는 대화" Classes="caption" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="2" Classes="dashboard-card">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="창 상태" Classes="eyebrow" />
|
||||
<TextBlock Text="{Binding WorkspaceModeText}" Classes="section-title" />
|
||||
<TextBlock Text="분리 창을 바로 열 수 있습니다." Classes="caption" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2" RowDefinitions="Auto,*" RowSpacing="10">
|
||||
<Border Classes="inline-alert"
|
||||
IsVisible="{Binding HasErrorText}">
|
||||
<TextBlock Text="{Binding ErrorText}"
|
||||
Classes="caption"
|
||||
Foreground="#C9392C"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<ScrollViewer Name="MessagesScrollViewer">
|
||||
<ItemsControl ItemsSource="{Binding Messages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
||||
<Border Classes="message-bubble"
|
||||
Classes.mine="{Binding IsMine}"
|
||||
Classes.pending="{Binding IsPending}"
|
||||
Classes.failed="{Binding IsFailed}"
|
||||
Padding="{Binding $parent[Window].DataContext.MessageBubblePadding}">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="{Binding SenderName}"
|
||||
Classes="caption"
|
||||
FontWeight="SemiBold"
|
||||
IsVisible="{Binding ShowSenderName}" />
|
||||
<TextBlock Text="{Binding Text}" Classes="message-text" />
|
||||
<TextBlock Text="{Binding MetaText}" Classes="message-meta" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Border Classes="empty-card"
|
||||
Width="340"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowMessageEmptyState}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding MessageEmptyStateTitle}" Classes="section-title" />
|
||||
<TextBlock Text="{Binding MessageEmptyStateText}" Classes="caption" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="secondary-button"
|
||||
Command="{Binding DetachConversationCommand}"
|
||||
Content="분리 창" />
|
||||
<Button Classes="secondary-button"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="새로고침" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="3" Classes="surface-muted" Padding="10">
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto" ColumnSpacing="10" RowSpacing="8">
|
||||
<TextBox Name="ComposerTextBox"
|
||||
Grid.RowSpan="2"
|
||||
Classes="input"
|
||||
PlaceholderText="{Binding ComposerPlaceholderText}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="{Binding ComposerMinHeight}"
|
||||
Text="{Binding ComposerText}"
|
||||
KeyDown="ComposerTextBox_OnKeyDown" />
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="primary-button"
|
||||
VerticalAlignment="Stretch"
|
||||
Command="{Binding SendMessageCommand}"
|
||||
Content="{Binding ComposerActionText}" />
|
||||
|
||||
<Border Grid.Row="1" Grid.Column="1" Classes="status-chip" HorizontalAlignment="Right">
|
||||
<TextBlock Text="{Binding ComposerCounterText}" Classes="caption" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
87
src/PhysOn.Desktop/Views/MainWindow.axaml.cs
Normal file
87
src/PhysOn.Desktop/Views/MainWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System.Collections.Specialized;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
|
||||
namespace PhysOn.Desktop.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private MainWindowViewModel? _boundViewModel;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private async void ComposerTextBox_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
if (DataContext is MainWindowViewModel viewModel)
|
||||
{
|
||||
await viewModel.SendMessageFromShortcutAsync();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
|
||||
if (e.Key == Key.O &&
|
||||
e.KeyModifiers.HasFlag(KeyModifiers.Control) &&
|
||||
e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
|
||||
DataContext is MainWindowViewModel viewModel)
|
||||
{
|
||||
_ = viewModel.OpenDetachedConversationFromShortcutAsync();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
}
|
||||
|
||||
_boundViewModel = DataContext as MainWindowViewModel;
|
||||
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged += Messages_OnCollectionChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void Messages_OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action is NotifyCollectionChangedAction.Add or NotifyCollectionChangedAction.Reset or NotifyCollectionChangedAction.Replace)
|
||||
{
|
||||
Dispatcher.UIThread.Post(ScrollMessagesToEnd, DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
_ = _boundViewModel.DisposeAsync();
|
||||
}
|
||||
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
private void ScrollMessagesToEnd()
|
||||
{
|
||||
if (this.FindControl<ScrollViewer>("MessagesScrollViewer") is { } scrollViewer)
|
||||
{
|
||||
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, scrollViewer.Extent.Height);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/PhysOn.Desktop/app.manifest
Normal file
18
src/PhysOn.Desktop/app.manifest
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="PhysOn.Desktop.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
13
src/PhysOn.Domain/Accounts/Account.cs
Normal file
13
src/PhysOn.Domain/Accounts/Account.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace PhysOn.Domain.Accounts;
|
||||
|
||||
public sealed class Account
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? StatusMessage { get; set; }
|
||||
public string Locale { get; set; } = "ko-KR";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public List<Device> Devices { get; } = [];
|
||||
public List<Session> Sessions { get; } = [];
|
||||
}
|
||||
14
src/PhysOn.Domain/Accounts/Device.cs
Normal file
14
src/PhysOn.Domain/Accounts/Device.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace PhysOn.Domain.Accounts;
|
||||
|
||||
public sealed class Device
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid AccountId { get; set; }
|
||||
public Account? Account { get; set; }
|
||||
public string InstallId { get; set; } = string.Empty;
|
||||
public string Platform { get; set; } = "windows";
|
||||
public string DeviceName { get; set; } = "Windows PC";
|
||||
public string AppVersion { get; set; } = "0.1.0";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset LastSeenAt { get; set; }
|
||||
}
|
||||
18
src/PhysOn.Domain/Accounts/Session.cs
Normal file
18
src/PhysOn.Domain/Accounts/Session.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
namespace PhysOn.Domain.Accounts;
|
||||
|
||||
public sealed class Session
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid AccountId { get; set; }
|
||||
public Account? Account { get; set; }
|
||||
public Guid DeviceId { get; set; }
|
||||
public Device? Device { get; set; }
|
||||
public string RefreshTokenHash { get; set; } = string.Empty;
|
||||
public Guid TokenFamilyId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset LastSeenAt { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
public bool IsActive(DateTimeOffset now) => RevokedAt is null && ExpiresAt > now;
|
||||
}
|
||||
13
src/PhysOn.Domain/Conversations/Conversation.cs
Normal file
13
src/PhysOn.Domain/Conversations/Conversation.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace PhysOn.Domain.Conversations;
|
||||
|
||||
public sealed class Conversation
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public ConversationType Type { get; set; }
|
||||
public Guid? CreatedByAccountId { get; set; }
|
||||
public long LastMessageSequence { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public List<ConversationMember> Members { get; } = [];
|
||||
public List<PhysOn.Domain.Messages.Message> Messages { get; } = [];
|
||||
}
|
||||
16
src/PhysOn.Domain/Conversations/ConversationMember.cs
Normal file
16
src/PhysOn.Domain/Conversations/ConversationMember.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
using PhysOn.Domain.Accounts;
|
||||
|
||||
namespace PhysOn.Domain.Conversations;
|
||||
|
||||
public sealed class ConversationMember
|
||||
{
|
||||
public Guid ConversationId { get; set; }
|
||||
public Conversation? Conversation { get; set; }
|
||||
public Guid AccountId { get; set; }
|
||||
public Account? Account { get; set; }
|
||||
public ConversationRole Role { get; set; } = ConversationRole.Member;
|
||||
public DateTimeOffset JoinedAt { get; set; }
|
||||
public long LastReadSequence { get; set; }
|
||||
public bool IsMuted { get; set; }
|
||||
public int? PinOrder { get; set; }
|
||||
}
|
||||
7
src/PhysOn.Domain/Conversations/ConversationRole.cs
Normal file
7
src/PhysOn.Domain/Conversations/ConversationRole.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace PhysOn.Domain.Conversations;
|
||||
|
||||
public enum ConversationRole
|
||||
{
|
||||
Member = 1,
|
||||
Owner = 2
|
||||
}
|
||||
8
src/PhysOn.Domain/Conversations/ConversationType.cs
Normal file
8
src/PhysOn.Domain/Conversations/ConversationType.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace PhysOn.Domain.Conversations;
|
||||
|
||||
public enum ConversationType
|
||||
{
|
||||
Self = 1,
|
||||
Direct = 2,
|
||||
Group = 3
|
||||
}
|
||||
28
src/PhysOn.Domain/Invites/Invite.cs
Normal file
28
src/PhysOn.Domain/Invites/Invite.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace PhysOn.Domain.Invites;
|
||||
|
||||
public sealed class Invite
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string CodeHash { get; set; } = string.Empty;
|
||||
public Guid? IssuedByAccountId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public int MaxUses { get; set; }
|
||||
public int UsedCount { get; set; }
|
||||
|
||||
public bool CanUse(DateTimeOffset now)
|
||||
{
|
||||
if (RevokedAt is not null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ExpiresAt is not null && ExpiresAt <= now)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return UsedCount < MaxUses;
|
||||
}
|
||||
}
|
||||
17
src/PhysOn.Domain/Messages/Message.cs
Normal file
17
src/PhysOn.Domain/Messages/Message.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace PhysOn.Domain.Messages;
|
||||
|
||||
public sealed class Message
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ConversationId { get; set; }
|
||||
public PhysOn.Domain.Conversations.Conversation? Conversation { get; set; }
|
||||
public Guid SenderAccountId { get; set; }
|
||||
public PhysOn.Domain.Accounts.Account? SenderAccount { get; set; }
|
||||
public Guid ClientRequestId { get; set; }
|
||||
public long ServerSequence { get; set; }
|
||||
public MessageType MessageType { get; set; } = MessageType.Text;
|
||||
public string BodyText { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? EditedAt { get; set; }
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
6
src/PhysOn.Domain/Messages/MessageType.cs
Normal file
6
src/PhysOn.Domain/Messages/MessageType.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace PhysOn.Domain.Messages;
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
Text = 1
|
||||
}
|
||||
9
src/PhysOn.Domain/PhysOn.Domain.csproj
Normal file
9
src/PhysOn.Domain/PhysOn.Domain.csproj
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
11
src/PhysOn.Infrastructure/Auth/JwtOptions.cs
Normal file
11
src/PhysOn.Infrastructure/Auth/JwtOptions.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace PhysOn.Infrastructure.Auth;
|
||||
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
public string Issuer { get; set; } = "PhysOn";
|
||||
public string Audience { get; set; } = "PhysOn.Desktop";
|
||||
public string SigningKey { get; set; } = "vsmessenger-dev-signing-key-change-me-2026";
|
||||
public int AccessTokenMinutes { get; set; } = 15;
|
||||
public int RefreshTokenDays { get; set; } = 30;
|
||||
public int RealtimeTicketMinutes { get; set; } = 15;
|
||||
}
|
||||
120
src/PhysOn.Infrastructure/Auth/JwtTokenService.cs
Normal file
120
src/PhysOn.Infrastructure/Auth/JwtTokenService.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using PhysOn.Application.Abstractions;
|
||||
using PhysOn.Domain.Accounts;
|
||||
|
||||
namespace PhysOn.Infrastructure.Auth;
|
||||
|
||||
public sealed class JwtTokenService : ITokenService
|
||||
{
|
||||
private readonly JwtOptions _options;
|
||||
private readonly TokenValidationParameters _validationParameters;
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler = new();
|
||||
private readonly SymmetricSecurityKey _securityKey;
|
||||
|
||||
public JwtTokenService(IOptions<JwtOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
_securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
|
||||
_validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _options.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _options.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = _securityKey,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
}
|
||||
|
||||
public IssuedTokenSet IssueTokens(Account account, Session session, Device device, DateTimeOffset now)
|
||||
{
|
||||
var accessTokenExpiresAt = now.AddMinutes(_options.AccessTokenMinutes);
|
||||
var refreshTokenExpiresAt = now.AddDays(_options.RefreshTokenDays);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, account.Id.ToString()),
|
||||
new("sid", session.Id.ToString()),
|
||||
new("did", device.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Name, account.DisplayName),
|
||||
new("ver", "1")
|
||||
};
|
||||
|
||||
var credentials = new SigningCredentials(_securityKey, SecurityAlgorithms.HmacSha256);
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: now.UtcDateTime,
|
||||
expires: accessTokenExpiresAt.UtcDateTime,
|
||||
signingCredentials: credentials);
|
||||
|
||||
var accessToken = _tokenHandler.WriteToken(jwt);
|
||||
var refreshTokenBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var refreshToken = WebEncoders.Base64UrlEncode(refreshTokenBytes);
|
||||
var refreshTokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken)));
|
||||
|
||||
return new IssuedTokenSet(
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
refreshTokenHash,
|
||||
refreshTokenExpiresAt);
|
||||
}
|
||||
|
||||
public IssuedRealtimeTicket IssueRealtimeTicket(Account account, Session session, Device device, DateTimeOffset now)
|
||||
{
|
||||
var expiresAt = now.AddMinutes(Math.Max(1, _options.RealtimeTicketMinutes));
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, account.Id.ToString()),
|
||||
new("sid", session.Id.ToString()),
|
||||
new("did", device.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Name, account.DisplayName),
|
||||
new("scp", "ws"),
|
||||
new("ver", "1")
|
||||
};
|
||||
|
||||
var credentials = new SigningCredentials(_securityKey, SecurityAlgorithms.HmacSha256);
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: now.UtcDateTime,
|
||||
expires: expiresAt.UtcDateTime,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new IssuedRealtimeTicket(_tokenHandler.WriteToken(jwt), expiresAt);
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? TryReadPrincipal(string accessToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _tokenHandler.ValidateToken(accessToken, _validationParameters, out _);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? TryReadRealtimePrincipal(string accessToken)
|
||||
{
|
||||
var principal = TryReadPrincipal(accessToken);
|
||||
if (principal?.FindFirst("scp")?.Value != "ws")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return principal;
|
||||
}
|
||||
}
|
||||
8
src/PhysOn.Infrastructure/Clock/SystemClock.cs
Normal file
8
src/PhysOn.Infrastructure/Clock/SystemClock.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
using PhysOn.Application.Abstractions;
|
||||
|
||||
namespace PhysOn.Infrastructure.Clock;
|
||||
|
||||
public sealed class SystemClock : IClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
}
|
||||
76
src/PhysOn.Infrastructure/Persistence/DatabaseInitializer.cs
Normal file
76
src/PhysOn.Infrastructure/Persistence/DatabaseInitializer.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PhysOn.Domain.Invites;
|
||||
|
||||
namespace PhysOn.Infrastructure.Persistence;
|
||||
|
||||
public sealed class DatabaseInitializer
|
||||
{
|
||||
private readonly PhysOnDbContext _dbContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly ILogger<DatabaseInitializer> _logger;
|
||||
|
||||
public DatabaseInitializer(
|
||||
PhysOnDbContext dbContext,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment,
|
||||
ILogger<DatabaseInitializer> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _dbContext.Database.EnsureCreatedAsync(cancellationToken);
|
||||
|
||||
if (await _dbContext.Invites.AnyAsync(cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var configuredInviteCodes = _configuration.GetSection("Bootstrap:InviteCodes").Get<string[]>() ?? [];
|
||||
var allowDefaultSeed = _configuration.GetValue<bool>("Bootstrap:SeedDefaultInviteCodes");
|
||||
var inviteCodes = configuredInviteCodes;
|
||||
|
||||
if (inviteCodes.Length == 0 && allowDefaultSeed && (_environment.IsDevelopment() || _environment.IsEnvironment("Testing")))
|
||||
{
|
||||
inviteCodes = ["ALPHA-OPEN-2026"];
|
||||
}
|
||||
|
||||
if (inviteCodes.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("No bootstrap invite codes were seeded.");
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var code in inviteCodes.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim().ToUpperInvariant()).Distinct())
|
||||
{
|
||||
_dbContext.Invites.Add(new Invite
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CodeHash = HashInviteCode(code),
|
||||
CreatedAt = now,
|
||||
ExpiresAt = now.AddYears(1),
|
||||
MaxUses = 10_000
|
||||
});
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
_logger.LogInformation("Seeded {InviteCount} bootstrap invite codes.", inviteCodes.Length);
|
||||
}
|
||||
|
||||
private static string HashInviteCode(string inviteCode)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(inviteCode));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
108
src/PhysOn.Infrastructure/Persistence/VsMessengerDbContext.cs
Normal file
108
src/PhysOn.Infrastructure/Persistence/VsMessengerDbContext.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using PhysOn.Application.Abstractions;
|
||||
using PhysOn.Domain.Accounts;
|
||||
using PhysOn.Domain.Conversations;
|
||||
using PhysOn.Domain.Invites;
|
||||
using PhysOn.Domain.Messages;
|
||||
|
||||
namespace PhysOn.Infrastructure.Persistence;
|
||||
|
||||
public sealed class PhysOnDbContext : DbContext, IAppDbContext
|
||||
{
|
||||
public PhysOnDbContext(DbContextOptions<PhysOnDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<Account> Accounts => Set<Account>();
|
||||
public DbSet<Device> Devices => Set<Device>();
|
||||
public DbSet<Session> Sessions => Set<Session>();
|
||||
public DbSet<Invite> Invites => Set<Invite>();
|
||||
public DbSet<Conversation> Conversations => Set<Conversation>();
|
||||
public DbSet<ConversationMember> ConversationMembers => Set<ConversationMember>();
|
||||
public DbSet<Message> Messages => Set<Message>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Account>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.DisplayName).HasMaxLength(40);
|
||||
entity.Property(x => x.StatusMessage).HasMaxLength(120);
|
||||
entity.Property(x => x.Locale).HasMaxLength(10);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Device>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.InstallId).HasMaxLength(100);
|
||||
entity.Property(x => x.Platform).HasMaxLength(16);
|
||||
entity.Property(x => x.DeviceName).HasMaxLength(80);
|
||||
entity.Property(x => x.AppVersion).HasMaxLength(32);
|
||||
entity.HasOne(x => x.Account)
|
||||
.WithMany(x => x.Devices)
|
||||
.HasForeignKey(x => x.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Session>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.RefreshTokenHash).HasMaxLength(128);
|
||||
entity.HasIndex(x => x.RefreshTokenHash).IsUnique();
|
||||
entity.HasOne(x => x.Account)
|
||||
.WithMany(x => x.Sessions)
|
||||
.HasForeignKey(x => x.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Device)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.DeviceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Invite>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.CodeHash).HasMaxLength(128);
|
||||
entity.HasIndex(x => x.CodeHash).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Conversation>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Type).HasConversion<string>().HasMaxLength(16);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ConversationMember>(entity =>
|
||||
{
|
||||
entity.HasKey(x => new { x.ConversationId, x.AccountId });
|
||||
entity.Property(x => x.Role).HasConversion<string>().HasMaxLength(16);
|
||||
entity.HasOne(x => x.Conversation)
|
||||
.WithMany(x => x.Members)
|
||||
.HasForeignKey(x => x.ConversationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasIndex(x => new { x.AccountId, x.PinOrder });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Message>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.MessageType).HasConversion<string>().HasMaxLength(16);
|
||||
entity.Property(x => x.BodyText).HasMaxLength(4000);
|
||||
entity.HasIndex(x => new { x.ConversationId, x.ClientRequestId }).IsUnique();
|
||||
entity.HasIndex(x => new { x.ConversationId, x.ServerSequence }).IsUnique();
|
||||
entity.HasOne(x => x.Conversation)
|
||||
.WithMany(x => x.Messages)
|
||||
.HasForeignKey(x => x.ConversationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.SenderAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.SenderAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
}
|
||||
}
|
||||
31
src/PhysOn.Infrastructure/PhysOn.Infrastructure.csproj
Normal file
31
src/PhysOn.Infrastructure/PhysOn.Infrastructure.csproj
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PhysOn.Application\PhysOn.Application.csproj" />
|
||||
<ProjectReference Include="..\PhysOn.Domain\PhysOn.Domain.csproj" />
|
||||
<ProjectReference Include="..\PhysOn.Contracts\PhysOn.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
136
src/PhysOn.Infrastructure/Realtime/WebSocketConnectionHub.cs
Normal file
136
src/PhysOn.Infrastructure/Realtime/WebSocketConnectionHub.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using PhysOn.Application.Abstractions;
|
||||
using PhysOn.Contracts.Realtime;
|
||||
|
||||
namespace PhysOn.Infrastructure.Realtime;
|
||||
|
||||
public sealed class WebSocketConnectionHub : IRealtimeNotifier
|
||||
{
|
||||
private readonly IClock _clock;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly ConcurrentDictionary<Guid, ConcurrentDictionary<string, ClientConnection>> _connections = new();
|
||||
|
||||
public WebSocketConnectionHub(IClock clock)
|
||||
{
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
public async Task AcceptConnectionAsync(
|
||||
Guid accountId,
|
||||
Guid sessionId,
|
||||
WebSocket socket,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionId = Guid.NewGuid().ToString("N");
|
||||
var client = new ClientConnection(connectionId, socket, _jsonOptions);
|
||||
var accountConnections = _connections.GetOrAdd(accountId, _ => new ConcurrentDictionary<string, ClientConnection>());
|
||||
accountConnections[connectionId] = client;
|
||||
|
||||
await client.SendAsync(
|
||||
new RealtimeEventEnvelope(
|
||||
"session.connected",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
_clock.UtcNow,
|
||||
new SessionConnectedDto(sessionId.ToString())),
|
||||
cancellationToken);
|
||||
|
||||
var buffer = new byte[4096];
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(buffer, cancellationToken);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
accountConnections.TryRemove(connectionId, out _);
|
||||
if (accountConnections.IsEmpty)
|
||||
{
|
||||
_connections.TryRemove(accountId, out _);
|
||||
}
|
||||
|
||||
if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived)
|
||||
{
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PublishToAccountsAsync(
|
||||
IEnumerable<Guid> accountIds,
|
||||
string eventName,
|
||||
object payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new RealtimeEventEnvelope(
|
||||
eventName,
|
||||
Guid.NewGuid().ToString("N"),
|
||||
_clock.UtcNow,
|
||||
payload);
|
||||
|
||||
var publishTasks = new List<Task>();
|
||||
foreach (var accountId in accountIds.Distinct())
|
||||
{
|
||||
if (!_connections.TryGetValue(accountId, out var accountConnections))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var connection in accountConnections.Values)
|
||||
{
|
||||
publishTasks.Add(connection.SendAsync(envelope, cancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(publishTasks);
|
||||
}
|
||||
|
||||
private sealed class ClientConnection
|
||||
{
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public ClientConnection(string id, WebSocket socket, JsonSerializerOptions jsonOptions)
|
||||
{
|
||||
Id = id;
|
||||
Socket = socket;
|
||||
_jsonOptions = jsonOptions;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public WebSocket Socket { get; }
|
||||
|
||||
public async Task SendAsync(RealtimeEventEnvelope envelope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Socket.State != WebSocketState.Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(envelope, _jsonOptions);
|
||||
var buffer = Encoding.UTF8.GetBytes(payload);
|
||||
|
||||
await _sendLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (Socket.State == WebSocketState.Open)
|
||||
{
|
||||
await Socket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/PhysOn.Infrastructure/ServiceCollectionExtensions.cs
Normal file
112
src/PhysOn.Infrastructure/ServiceCollectionExtensions.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using PhysOn.Application.Abstractions;
|
||||
using PhysOn.Infrastructure.Auth;
|
||||
using PhysOn.Infrastructure.Clock;
|
||||
using PhysOn.Infrastructure.Persistence;
|
||||
using PhysOn.Infrastructure.Realtime;
|
||||
|
||||
namespace PhysOn.Infrastructure;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddPhysOnInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString("Main") ?? "Data Source=vs-messenger.db";
|
||||
var jwtOptions = configuration.GetSection("Auth:Jwt").Get<JwtOptions>() ?? new JwtOptions();
|
||||
ValidateJwtOptions(jwtOptions, environment);
|
||||
|
||||
services.Configure<JwtOptions>(configuration.GetSection("Auth:Jwt"));
|
||||
services.AddSingleton<IClock, SystemClock>();
|
||||
services.AddSingleton<ITokenService, JwtTokenService>();
|
||||
services.AddSingleton<WebSocketConnectionHub>();
|
||||
services.AddSingleton<IRealtimeNotifier>(sp => sp.GetRequiredService<WebSocketConnectionHub>());
|
||||
services.AddScoped<DatabaseInitializer>();
|
||||
|
||||
services.AddDbContext<PhysOnDbContext>(options => options.UseSqlite(connectionString));
|
||||
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<PhysOnDbContext>());
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer();
|
||||
|
||||
services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<JwtOptions>, IHostEnvironment>((options, jwtOptionsAccessor, hostEnvironment) =>
|
||||
{
|
||||
var resolvedJwtOptions = jwtOptionsAccessor.Value;
|
||||
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(resolvedJwtOptions.SigningKey));
|
||||
|
||||
options.RequireHttpsMetadata = !hostEnvironment.IsDevelopment() && !hostEnvironment.IsEnvironment("Testing");
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = resolvedJwtOptions.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = resolvedJwtOptions.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = signingKey,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnTokenValidated = async context =>
|
||||
{
|
||||
var accountIdRaw = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var sessionIdRaw = context.Principal?.FindFirstValue("sid");
|
||||
|
||||
if (!Guid.TryParse(accountIdRaw, out var accountId) || !Guid.TryParse(sessionIdRaw, out var sessionId))
|
||||
{
|
||||
context.Fail("invalid_session_claims");
|
||||
return;
|
||||
}
|
||||
|
||||
var db = context.HttpContext.RequestServices.GetRequiredService<IAppDbContext>();
|
||||
var clock = context.HttpContext.RequestServices.GetRequiredService<IClock>();
|
||||
var session = await db.Sessions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == sessionId && x.AccountId == accountId,
|
||||
context.HttpContext.RequestAborted);
|
||||
|
||||
if (session is null || !session.IsActive(clock.UtcNow))
|
||||
{
|
||||
context.Fail("session_inactive");
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
services.AddAuthorization();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ValidateJwtOptions(JwtOptions jwtOptions, IHostEnvironment environment)
|
||||
{
|
||||
var signingKey = jwtOptions.SigningKey?.Trim() ?? string.Empty;
|
||||
var looksDefault =
|
||||
string.IsNullOrWhiteSpace(signingKey) ||
|
||||
signingKey.Contains("change-me", StringComparison.OrdinalIgnoreCase) ||
|
||||
signingKey.Equals("vsmessenger-dev-signing-key-change-me-2026", StringComparison.Ordinal);
|
||||
|
||||
var tooShort = signingKey.Length < 32;
|
||||
|
||||
if (!environment.IsDevelopment() && !environment.IsEnvironment("Testing") && (looksDefault || tooShort))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"운영 환경에서는 기본 JWT 서명키를 사용할 수 없습니다. Auth:Jwt:SigningKey를 32자 이상 강한 비밀값으로 설정하세요.");
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/PhysOn.Web/.gitignore
vendored
Normal file
25
src/PhysOn.Web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.tsbuildinfo
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
27
src/PhysOn.Web/README.md
Normal file
27
src/PhysOn.Web/README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# vstalk Web
|
||||
|
||||
`src/PhysOn.Web`는 `vstalk.phy.kr`에 배포할 모바일 퍼스트 웹앱 채널입니다.
|
||||
|
||||
현재 범위:
|
||||
- 이름 + 초대코드 기반 초간단 가입
|
||||
- 최근 대화 목록
|
||||
- 대화 진입과 메시지 읽기
|
||||
- 텍스트 메시지 전송
|
||||
- 모바일 브라우저용 PWA 메타데이터
|
||||
|
||||
개발 명령:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
기본 개발 프록시:
|
||||
- 웹앱: `http://127.0.0.1:4173`
|
||||
- API 프록시 대상: `http://127.0.0.1:5082`
|
||||
|
||||
환경 변수:
|
||||
- `VITE_API_BASE_URL`
|
||||
- `VITE_DEV_PROXY_TARGET`
|
||||
|
||||
프로덕션 배포는 루트 문서의 [deploy/README.md](../../deploy/README.md)와 [문서/17-vstalk-webapp-mvp-and-rollout-plan.md](../../문서/17-vstalk-webapp-mvp-and-rollout-plan.md)를 따른다.
|
||||
28
src/PhysOn.Web/eslint.config.js
Normal file
28
src/PhysOn.Web/eslint.config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
23
src/PhysOn.Web/index.html
Normal file
23
src/PhysOn.Web/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#101826" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="KoTalk" />
|
||||
<meta
|
||||
name="description"
|
||||
content="업무 대화와 일상 대화를 같은 흐름 안에서 가볍게 이어 주는 KoTalk 웹앱입니다."
|
||||
/>
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>KoTalk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3811
src/PhysOn.Web/package-lock.json
generated
Normal file
3811
src/PhysOn.Web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
src/PhysOn.Web/package.json
Normal file
30
src/PhysOn.Web/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "physon-web",
|
||||
"private": true,
|
||||
"version": "0.1.0-alpha.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"puppeteer-core": "^24.41.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
5
src/PhysOn.Web/public/apple-touch-icon.svg
Normal file
5
src/PhysOn.Web/public/apple-touch-icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="180" height="180" rx="50" fill="#101826"/>
|
||||
<path d="M42 55C42 47.268 48.268 41 56 41H124C131.732 41 138 47.268 138 55V104C138 111.732 131.732 118 124 118H89.285L65.86 136.409C61.252 140.031 54.55 136.752 54.55 130.896V118H56C48.268 118 42 111.732 42 104V55Z" fill="#F6F2EA"/>
|
||||
<path d="M63 77.5L78.363 100.438L92.545 77.727L107.223 100.438L122.5 77.5" stroke="#101826" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 562 B |
5
src/PhysOn.Web/public/icon.svg
Normal file
5
src/PhysOn.Web/public/icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="144" fill="#101826"/>
|
||||
<path d="M120 150C120 127.909 137.909 110 160 110H352C374.091 110 392 127.909 392 150V304C392 326.091 374.091 344 352 344H251.232L184.314 396.674C171.148 407.041 152 397.656 152 380.899V344H160C137.909 344 120 326.091 120 304V150Z" fill="#F6F2EA"/>
|
||||
<path d="M171 201.5L214.894 267L255.4 202.15L297.35 267L341 201.5" stroke="#101826" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 570 B |
24
src/PhysOn.Web/public/manifest.webmanifest
Normal file
24
src/PhysOn.Web/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "PhysOn",
|
||||
"short_name": "PhysOn",
|
||||
"description": "업무 소통과 친근한 대화를 한 손 흐름으로 이어 주는 모바일 중심 메신저 웹앱",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#101826",
|
||||
"theme_color": "#101826",
|
||||
"lang": "ko-KR",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/mask-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
src/PhysOn.Web/public/mask-icon.svg
Normal file
4
src/PhysOn.Web/public/mask-icon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="144" fill="#101826"/>
|
||||
<path d="M120 150C120 127.909 137.909 110 160 110H352C374.091 110 392 127.909 392 150V304C392 326.091 374.091 344 352 344H251.232L184.314 396.674C171.148 407.041 152 397.656 152 380.899V344H160C137.909 344 120 326.091 120 304V150Z" fill="#F6F2EA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
57
src/PhysOn.Web/public/sw.js
Normal file
57
src/PhysOn.Web/public/sw.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
const CACHE_NAME = 'vs-talk-shell-v2';
|
||||
const SHELL = ['/manifest.webmanifest', '/icon.svg', '/apple-touch-icon.svg', '/mask-icon.svg'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
|
||||
),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/v1/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(async (response) => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put('/index.html', response.clone());
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
return (await cache.match('/index.html')) ?? Response.error();
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return fetch(event.request).then((response) => {
|
||||
const cloned = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, cloned));
|
||||
return response;
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
5
src/PhysOn.Web/public/vs-mark.svg
Normal file
5
src/PhysOn.Web/public/vs-mark.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||
<rect width="128" height="128" rx="34" fill="#17372F"/>
|
||||
<path d="M27 33h23l14 25 14-25h23L75 95H53L27 33Z" fill="#F6E8D5"/>
|
||||
<path d="M60 39h8v50h-8z" fill="#DDB07B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 255 B |
1093
src/PhysOn.Web/src/App.css
Normal file
1093
src/PhysOn.Web/src/App.css
Normal file
File diff suppressed because it is too large
Load diff
1573
src/PhysOn.Web/src/App.tsx
Normal file
1573
src/PhysOn.Web/src/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
71
src/PhysOn.Web/src/index.css
Normal file
71
src/PhysOn.Web/src/index.css
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
:root {
|
||||
font-family:
|
||||
'Pretendard Variable',
|
||||
'SUIT Variable',
|
||||
'Noto Sans KR',
|
||||
'Apple SD Gothic Neo',
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
color: #111111;
|
||||
background: #f5f5f6;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--surface-page: #f5f5f6;
|
||||
--surface-base: #ffffff;
|
||||
--surface-raised: #fcfcfc;
|
||||
--surface-muted: #f6f6f7;
|
||||
--surface-selected: #eef2f6;
|
||||
--surface-chat-mine: #f1f1f2;
|
||||
--border-subtle: #ececef;
|
||||
--border-strong: #d8d8dd;
|
||||
--border-contrast: #18181b;
|
||||
--text-strong: #141416;
|
||||
--text-soft: #333338;
|
||||
--text-muted: #7b7b84;
|
||||
--focus-ring: #1a73e8;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--surface-page);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100svh;
|
||||
background: var(--surface-page);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100svh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
202
src/PhysOn.Web/src/lib/api.ts
Normal file
202
src/PhysOn.Web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import type {
|
||||
ApiEnvelope,
|
||||
ApiErrorEnvelope,
|
||||
BootstrapResponse,
|
||||
ListEnvelope,
|
||||
MessageItemDto,
|
||||
ReadCursorUpdatedDto,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
RegisterAlphaQuickRequest,
|
||||
RegisterAlphaQuickResponse,
|
||||
UpdateReadCursorRequest,
|
||||
} from '../types'
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
readonly code?: string
|
||||
readonly status?: number
|
||||
|
||||
constructor(message: string, code?: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'ApiRequestError'
|
||||
this.code = code
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function resolveErrorMessage(status: number, code?: string, fallback?: string): string {
|
||||
if (status === 401) {
|
||||
return '연결이 잠시 만료되었습니다. 다시 이어서 들어와 주세요.'
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return '이 화면은 아직 사용할 수 없습니다. 초대 상태를 확인해 주세요.'
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return '대화 정보를 다시 불러오는 중입니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return '요청이 많습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return '지금은 연결이 고르지 않습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
if (code === 'invite_code_invalid') {
|
||||
return '초대코드를 다시 확인해 주세요.'
|
||||
}
|
||||
|
||||
return fallback ?? '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string): string {
|
||||
if (!value) {
|
||||
return `${window.location.origin}/`
|
||||
}
|
||||
|
||||
return value.endsWith('/') ? value : `${value}/`
|
||||
}
|
||||
|
||||
function buildUrl(apiBaseUrl: string, path: string): string {
|
||||
return new URL(path.replace(/^\//, ''), ensureTrailingSlash(apiBaseUrl)).toString()
|
||||
}
|
||||
|
||||
function parsePayload<T>(text: string): ApiEnvelope<T> | ApiErrorEnvelope | null {
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as ApiEnvelope<T> | ApiErrorEnvelope
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
apiBaseUrl: string,
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
accessToken?: string,
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
|
||||
if (init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(buildUrl(apiBaseUrl, path), {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiRequestError('네트워크 연결을 확인한 뒤 다시 시도해 주세요.')
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
const payload = parsePayload<T>(text)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (payload as ApiErrorEnvelope | null)?.error
|
||||
throw new ApiRequestError(
|
||||
resolveErrorMessage(response.status, error?.code, error?.message ?? undefined),
|
||||
error?.code,
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
|
||||
if (!payload || !('data' in payload)) {
|
||||
throw new ApiRequestError('응답을 다시 확인하는 중입니다. 잠시 후 다시 시도해 주세요.')
|
||||
}
|
||||
|
||||
return payload.data
|
||||
}
|
||||
|
||||
export function registerAlphaQuick(
|
||||
apiBaseUrl: string,
|
||||
body: RegisterAlphaQuickRequest,
|
||||
): Promise<RegisterAlphaQuickResponse> {
|
||||
return request<RegisterAlphaQuickResponse>(apiBaseUrl, '/v1/auth/register/alpha-quick', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshToken(
|
||||
apiBaseUrl: string,
|
||||
body: RefreshTokenRequest,
|
||||
): Promise<RefreshTokenResponse> {
|
||||
return request<RefreshTokenResponse>(apiBaseUrl, '/v1/auth/token/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function getBootstrap(apiBaseUrl: string, accessToken: string): Promise<BootstrapResponse> {
|
||||
return request<BootstrapResponse>(apiBaseUrl, '/v1/bootstrap', { method: 'GET' }, accessToken)
|
||||
}
|
||||
|
||||
export function getMessages(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
conversationId: string,
|
||||
beforeSequence?: number,
|
||||
): Promise<ListEnvelope<MessageItemDto>> {
|
||||
const query = new URLSearchParams()
|
||||
if (beforeSequence) {
|
||||
query.set('beforeSequence', String(beforeSequence))
|
||||
}
|
||||
query.set('limit', '60')
|
||||
|
||||
const suffix = query.toString()
|
||||
return request<ListEnvelope<MessageItemDto>>(
|
||||
apiBaseUrl,
|
||||
`/v1/conversations/${conversationId}/messages${suffix ? `?${suffix}` : ''}`,
|
||||
{ method: 'GET' },
|
||||
accessToken,
|
||||
)
|
||||
}
|
||||
|
||||
export function sendTextMessage(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
conversationId: string,
|
||||
body: { clientRequestId: string; body: string },
|
||||
): Promise<MessageItemDto> {
|
||||
return request<MessageItemDto>(
|
||||
apiBaseUrl,
|
||||
`/v1/conversations/${conversationId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
accessToken,
|
||||
)
|
||||
}
|
||||
|
||||
export function updateReadCursor(
|
||||
apiBaseUrl: string,
|
||||
accessToken: string,
|
||||
conversationId: string,
|
||||
body: UpdateReadCursorRequest,
|
||||
): Promise<ReadCursorUpdatedDto> {
|
||||
return request<ReadCursorUpdatedDto>(
|
||||
apiBaseUrl,
|
||||
`/v1/conversations/${conversationId}/read-cursor`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
accessToken,
|
||||
)
|
||||
}
|
||||
48
src/PhysOn.Web/src/lib/realtime.ts
Normal file
48
src/PhysOn.Web/src/lib/realtime.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type {
|
||||
MessageItemDto,
|
||||
ReadCursorUpdatedDto,
|
||||
RealtimeEventEnvelope,
|
||||
SessionConnectedDto,
|
||||
} from '../types'
|
||||
|
||||
export type RealtimeEvent =
|
||||
| { kind: 'session.connected'; payload: SessionConnectedDto }
|
||||
| { kind: 'message.created'; payload: MessageItemDto }
|
||||
| { kind: 'read_cursor.updated'; payload: ReadCursorUpdatedDto }
|
||||
| { kind: 'unknown'; payload: unknown }
|
||||
|
||||
export function buildBrowserWsUrl(rawUrl: string, ticket: string): string {
|
||||
const url = new URL(rawUrl)
|
||||
if (window.location.protocol === 'https:' && url.protocol === 'ws:') {
|
||||
url.protocol = 'wss:'
|
||||
}
|
||||
if (window.location.protocol === 'http:' && url.protocol === 'wss:') {
|
||||
url.protocol = 'ws:'
|
||||
}
|
||||
url.searchParams.set('access_token', ticket)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function parseRealtimeEvent(message: string): RealtimeEvent | null {
|
||||
let envelope: RealtimeEventEnvelope
|
||||
try {
|
||||
envelope = JSON.parse(message) as RealtimeEventEnvelope
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!envelope.event) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (envelope.event) {
|
||||
case 'session.connected':
|
||||
return { kind: envelope.event, payload: envelope.data as SessionConnectedDto }
|
||||
case 'message.created':
|
||||
return { kind: envelope.event, payload: envelope.data as MessageItemDto }
|
||||
case 'read_cursor.updated':
|
||||
return { kind: envelope.event, payload: envelope.data as ReadCursorUpdatedDto }
|
||||
default:
|
||||
return { kind: 'unknown', payload: envelope.data }
|
||||
}
|
||||
}
|
||||
98
src/PhysOn.Web/src/lib/storage.ts
Normal file
98
src/PhysOn.Web/src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type { StoredSession } from '../types'
|
||||
|
||||
const SESSION_KEY = 'vs-talk.session'
|
||||
const INSTALL_ID_KEY = 'vs-talk.install-id'
|
||||
const INVITE_CODE_KEY = 'vs-talk.invite-code'
|
||||
const DRAFTS_KEY = 'vs-talk.drafts'
|
||||
|
||||
function fallbackRandomId(): string {
|
||||
return `web-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function getInstallId(): string {
|
||||
const existing = window.localStorage.getItem(INSTALL_ID_KEY)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const next = typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : fallbackRandomId()
|
||||
window.localStorage.setItem(INSTALL_ID_KEY, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function readStoredSession(): StoredSession | null {
|
||||
const raw = window.sessionStorage.getItem(SESSION_KEY) ?? window.localStorage.getItem(SESSION_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredSession
|
||||
if (window.localStorage.getItem(SESSION_KEY)) {
|
||||
window.localStorage.removeItem(SESSION_KEY)
|
||||
window.sessionStorage.setItem(SESSION_KEY, raw)
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
window.sessionStorage.removeItem(SESSION_KEY)
|
||||
window.localStorage.removeItem(SESSION_KEY)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredSession(session: StoredSession): void {
|
||||
window.sessionStorage.setItem(SESSION_KEY, JSON.stringify(session))
|
||||
window.localStorage.removeItem(SESSION_KEY)
|
||||
}
|
||||
|
||||
export function clearStoredSession(): void {
|
||||
window.sessionStorage.removeItem(SESSION_KEY)
|
||||
window.localStorage.removeItem(SESSION_KEY)
|
||||
}
|
||||
|
||||
export function readSavedInviteCode(): string {
|
||||
return window.localStorage.getItem(INVITE_CODE_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function writeSavedInviteCode(value: string): void {
|
||||
window.localStorage.setItem(INVITE_CODE_KEY, value)
|
||||
}
|
||||
|
||||
function readDraftMap(): Record<string, string> {
|
||||
const raw = window.localStorage.getItem(DRAFTS_KEY)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, string>
|
||||
} catch {
|
||||
window.localStorage.removeItem(DRAFTS_KEY)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function readConversationDraft(conversationId: string): string {
|
||||
return readDraftMap()[conversationId] ?? ''
|
||||
}
|
||||
|
||||
export function writeConversationDraft(conversationId: string, value: string): void {
|
||||
const next = readDraftMap()
|
||||
if (value.trim()) {
|
||||
next[conversationId] = value
|
||||
} else {
|
||||
delete next[conversationId]
|
||||
}
|
||||
|
||||
window.localStorage.setItem(DRAFTS_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
export function clearConversationDraft(conversationId: string): void {
|
||||
const next = readDraftMap()
|
||||
delete next[conversationId]
|
||||
window.localStorage.setItem(DRAFTS_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
export function clearConversationDrafts(): void {
|
||||
window.localStorage.removeItem(DRAFTS_KEY)
|
||||
}
|
||||
26
src/PhysOn.Web/src/main.tsx
Normal file
26
src/PhysOn.Web/src/main.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const isLocalPreview = ['127.0.0.1', 'localhost'].includes(window.location.hostname)
|
||||
|
||||
if ('serviceWorker' in navigator && isLocalPreview) {
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
registrations.forEach((registration) => {
|
||||
registration.unregister().catch(() => undefined)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator && !isLocalPreview) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
156
src/PhysOn.Web/src/types.ts
Normal file
156
src/PhysOn.Web/src/types.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
export interface ApiEnvelope<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string
|
||||
message: string
|
||||
retryable?: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ApiErrorEnvelope {
|
||||
error: ApiError
|
||||
}
|
||||
|
||||
export interface ListEnvelope<T> {
|
||||
items: T[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface MeDto {
|
||||
userId: string
|
||||
displayName: string
|
||||
profileImageUrl: string | null
|
||||
statusMessage: string | null
|
||||
}
|
||||
|
||||
export interface SessionDto {
|
||||
sessionId: string
|
||||
deviceId: string
|
||||
deviceName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface TokenPairDto {
|
||||
accessToken: string
|
||||
accessTokenExpiresAt: string
|
||||
refreshToken: string
|
||||
refreshTokenExpiresAt: string
|
||||
}
|
||||
|
||||
export interface BootstrapWsDto {
|
||||
url: string
|
||||
ticket: string
|
||||
ticketExpiresAt: string
|
||||
}
|
||||
|
||||
export interface MessagePreviewDto {
|
||||
messageId: string
|
||||
text: string
|
||||
createdAt: string
|
||||
senderUserId: string
|
||||
}
|
||||
|
||||
export interface ConversationSummaryDto {
|
||||
conversationId: string
|
||||
type: string
|
||||
title: string
|
||||
avatarUrl: string | null
|
||||
subtitle: string
|
||||
memberCount: number
|
||||
isMuted: boolean
|
||||
isPinned: boolean
|
||||
sortKey: string
|
||||
unreadCount: number
|
||||
lastReadSequence: number
|
||||
lastMessage: MessagePreviewDto | null
|
||||
}
|
||||
|
||||
export interface MessageSenderDto {
|
||||
userId: string
|
||||
displayName: string
|
||||
profileImageUrl: string | null
|
||||
}
|
||||
|
||||
export interface MessageItemDto {
|
||||
messageId: string
|
||||
conversationId: string
|
||||
clientMessageId: string
|
||||
kind: string
|
||||
text: string
|
||||
createdAt: string
|
||||
editedAt: string | null
|
||||
sender: MessageSenderDto
|
||||
isMine: boolean
|
||||
serverSequence: number
|
||||
}
|
||||
|
||||
export interface BootstrapResponse {
|
||||
me: MeDto
|
||||
session: SessionDto
|
||||
ws: BootstrapWsDto
|
||||
conversations: ListEnvelope<ConversationSummaryDto>
|
||||
}
|
||||
|
||||
export interface RegisterAlphaQuickResponse {
|
||||
account: MeDto
|
||||
session: SessionDto
|
||||
tokens: TokenPairDto
|
||||
bootstrap: BootstrapResponse
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
tokens: TokenPairDto
|
||||
}
|
||||
|
||||
export interface DeviceRegistrationDto {
|
||||
installId: string
|
||||
platform: string
|
||||
deviceName: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export interface RegisterAlphaQuickRequest {
|
||||
displayName: string
|
||||
inviteCode: string
|
||||
device: DeviceRegistrationDto
|
||||
}
|
||||
|
||||
export interface PostTextMessageRequest {
|
||||
clientRequestId: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface UpdateReadCursorRequest {
|
||||
lastReadSequence: number
|
||||
}
|
||||
|
||||
export interface ReadCursorUpdatedDto {
|
||||
conversationId: string
|
||||
accountId: string
|
||||
lastReadSequence: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface StoredSession {
|
||||
apiBaseUrl: string
|
||||
tokens: TokenPairDto
|
||||
bootstrap: BootstrapResponse
|
||||
savedAt: string
|
||||
}
|
||||
|
||||
export interface RealtimeEventEnvelope<T = unknown> {
|
||||
event: string
|
||||
eventId: string
|
||||
occurredAt: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface SessionConnectedDto {
|
||||
sessionId: string
|
||||
}
|
||||
1
src/PhysOn.Web/src/vite-env.d.ts
vendored
Normal file
1
src/PhysOn.Web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
24
src/PhysOn.Web/tsconfig.app.json
Normal file
24
src/PhysOn.Web/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
src/PhysOn.Web/tsconfig.json
Normal file
7
src/PhysOn.Web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
src/PhysOn.Web/tsconfig.node.json
Normal file
22
src/PhysOn.Web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
30
src/PhysOn.Web/vite.config.ts
Normal file
30
src/PhysOn.Web/vite.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '')
|
||||
const proxyTarget = env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:5082'
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 4173,
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/health': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 4173,
|
||||
},
|
||||
}
|
||||
})
|
||||
18
src/PhysOn.Worker/PhysOn.Worker.csproj
Normal file
18
src/PhysOn.Worker/PhysOn.Worker.csproj
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-PhysOn.Worker-7770e040-77eb-48bb-9394-b7fdb25ab33c</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PhysOn.Application\PhysOn.Application.csproj" />
|
||||
<ProjectReference Include="..\PhysOn.Infrastructure\PhysOn.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
7
src/PhysOn.Worker/Program.cs
Normal file
7
src/PhysOn.Worker/Program.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
using PhysOn.Worker;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services.AddHostedService<Worker>();
|
||||
|
||||
var host = builder.Build();
|
||||
host.Run();
|
||||
12
src/PhysOn.Worker/Properties/launchSettings.json
Normal file
12
src/PhysOn.Worker/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"PhysOn.Worker": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/PhysOn.Worker/Worker.cs
Normal file
23
src/PhysOn.Worker/Worker.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
namespace PhysOn.Worker;
|
||||
|
||||
public class Worker : BackgroundService
|
||||
{
|
||||
private readonly ILogger<Worker> _logger;
|
||||
|
||||
public Worker(ILogger<Worker> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
|
||||
}
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/PhysOn.Worker/appsettings.Development.json
Normal file
8
src/PhysOn.Worker/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/PhysOn.Worker/appsettings.json
Normal file
8
src/PhysOn.Worker/appsettings.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue