공개: 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,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>