공개: KoTalk 최신 기준선

This commit is contained in:
Ian 2026-04-16 09:24:26 +09:00
commit debf62f76e
572 changed files with 41689 additions and 0 deletions

View 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);
}
}

View 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);
}
}

View file

@ -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);
}
});
}

View 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
View 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;

View 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"
}
}
}
}

View 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"
}
}
}

View 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]"
}

View 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);
}

View file

@ -0,0 +1,6 @@
namespace PhysOn.Application.Abstractions;
public interface IClock
{
DateTimeOffset UtcNow { get; }
}

View file

@ -0,0 +1,10 @@
namespace PhysOn.Application.Abstractions;
public interface IRealtimeNotifier
{
Task PublishToAccountsAsync(
IEnumerable<Guid> accountIds,
string eventName,
object payload,
CancellationToken cancellationToken = default);
}

View 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);

View 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; }
}

View 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>

View 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);
}

View 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);

View 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);

View 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);

View 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);

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View 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);

View 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>

View 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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View file

@ -0,0 +1,8 @@
namespace PhysOn.Desktop.Models;
public sealed record DesktopSession(
string ApiBaseUrl,
string AccessToken,
string RefreshToken,
string DisplayName,
string? LastConversationId);

View file

@ -0,0 +1,6 @@
namespace PhysOn.Desktop.Models;
public sealed record DesktopWorkspaceLayout(
bool IsCompactDensity,
bool IsInspectorVisible,
bool IsConversationPaneCollapsed);

View 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>

View 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();
}

View 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();
}
}

View 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);

View 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}/";
}

View 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);
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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));
}
}

View 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);
}
}

File diff suppressed because it is too large Load diff

View 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));
}
}

View file

@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PhysOn.Desktop.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View 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>

View 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);
}
}
}

View 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>

View 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);
}
}
}

View 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>

View 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; } = [];
}

View 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; }
}

View 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;
}

View 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; } = [];
}

View 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; }
}

View file

@ -0,0 +1,7 @@
namespace PhysOn.Domain.Conversations;
public enum ConversationRole
{
Member = 1,
Owner = 2
}

View file

@ -0,0 +1,8 @@
namespace PhysOn.Domain.Conversations;
public enum ConversationType
{
Self = 1,
Direct = 2,
Group = 3
}

View 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;
}
}

View 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; }
}

View file

@ -0,0 +1,6 @@
namespace PhysOn.Domain.Messages;
public enum MessageType
{
Text = 1
}

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View 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;
}

View 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;
}
}

View file

@ -0,0 +1,8 @@
using PhysOn.Application.Abstractions;
namespace PhysOn.Infrastructure.Clock;
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}

View 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);
}
}

View 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);
});
}
}

View 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>

View 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();
}
}
}
}

View 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
View 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
View 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)를 따른다.

View 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
View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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

View 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

View 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"
}
]
}

View 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

View 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;
});
}),
);
});

View 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

File diff suppressed because it is too large Load diff

1573
src/PhysOn.Web/src/App.tsx Normal file

File diff suppressed because it is too large Load diff

View 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;
}

View 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,
)
}

View 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 }
}
}

View 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)
}

View 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
View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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,
},
}
})

View 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>

View file

@ -0,0 +1,7 @@
using PhysOn.Worker;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();

View file

@ -0,0 +1,12 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"PhysOn.Worker": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View 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);
}
}
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}