공개: KoTalk 최신 기준선
This commit is contained in:
commit
debf62f76e
572 changed files with 41689 additions and 0 deletions
16
src/PhysOn.Desktop/App.axaml
Normal file
16
src/PhysOn.Desktop/App.axaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PhysOn.Desktop.App"
|
||||
xmlns:local="using:PhysOn.Desktop"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<Style Selector="Application">
|
||||
<Setter Property="TextElement.FontFamily" Value="Pretendard Variable, SUIT Variable, Malgun Gothic, Segoe UI, sans-serif" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
34
src/PhysOn.Desktop/App.axaml.cs
Normal file
34
src/PhysOn.Desktop/App.axaml.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PhysOn.Desktop.Services;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
using PhysOn.Desktop.Views;
|
||||
|
||||
namespace PhysOn.Desktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var conversationWindowManager = new ConversationWindowManager();
|
||||
var workspaceLayoutStore = new WorkspaceLayoutStore();
|
||||
var viewModel = new MainWindowViewModel(conversationWindowManager, workspaceLayoutStore);
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = viewModel,
|
||||
};
|
||||
|
||||
_ = viewModel.InitializeAsync();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
BIN
src/PhysOn.Desktop/Assets/avalonia-logo.ico
Normal file
BIN
src/PhysOn.Desktop/Assets/avalonia-logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
8
src/PhysOn.Desktop/Models/DesktopSession.cs
Normal file
8
src/PhysOn.Desktop/Models/DesktopSession.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace PhysOn.Desktop.Models;
|
||||
|
||||
public sealed record DesktopSession(
|
||||
string ApiBaseUrl,
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
string DisplayName,
|
||||
string? LastConversationId);
|
||||
6
src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs
Normal file
6
src/PhysOn.Desktop/Models/DesktopWorkspaceLayout.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace PhysOn.Desktop.Models;
|
||||
|
||||
public sealed record DesktopWorkspaceLayout(
|
||||
bool IsCompactDensity,
|
||||
bool IsInspectorVisible,
|
||||
bool IsConversationPaneCollapsed);
|
||||
31
src/PhysOn.Desktop/PhysOn.Desktop.csproj
Normal file
31
src/PhysOn.Desktop/PhysOn.Desktop.csproj
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.1" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PhysOn.Contracts\PhysOn.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
23
src/PhysOn.Desktop/Program.cs
Normal file
23
src/PhysOn.Desktop/Program.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace PhysOn.Desktop;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
#if DEBUG
|
||||
.WithDeveloperTools()
|
||||
#endif
|
||||
.LogToTrace();
|
||||
}
|
||||
47
src/PhysOn.Desktop/Services/ConversationWindowManager.cs
Normal file
47
src/PhysOn.Desktop/Services/ConversationWindowManager.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using Avalonia.Controls;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
using PhysOn.Desktop.Views;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public sealed class ConversationWindowManager : IConversationWindowManager
|
||||
{
|
||||
private readonly Dictionary<string, ConversationWindow> _openWindows = new(StringComparer.Ordinal);
|
||||
|
||||
public event Action<int>? WindowCountChanged;
|
||||
|
||||
public Task ShowOrFocusAsync(ConversationWindowLaunch launchContext, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_openWindows.TryGetValue(launchContext.ConversationId, out var existingWindow))
|
||||
{
|
||||
existingWindow.WindowState = WindowState.Normal;
|
||||
existingWindow.Activate();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var viewModel = new ConversationWindowViewModel(launchContext);
|
||||
var window = new ConversationWindow
|
||||
{
|
||||
DataContext = viewModel,
|
||||
Title = launchContext.ConversationTitle
|
||||
};
|
||||
|
||||
window.Closed += (_, _) => _ = HandleWindowClosedAsync(launchContext.ConversationId, viewModel);
|
||||
|
||||
_openWindows[launchContext.ConversationId] = window;
|
||||
WindowCountChanged?.Invoke(_openWindows.Count);
|
||||
|
||||
window.Show();
|
||||
_ = viewModel.InitializeAsync();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleWindowClosedAsync(string conversationId, ConversationWindowViewModel viewModel)
|
||||
{
|
||||
_openWindows.Remove(conversationId);
|
||||
WindowCountChanged?.Invoke(_openWindows.Count);
|
||||
await viewModel.DisposeAsync();
|
||||
}
|
||||
}
|
||||
16
src/PhysOn.Desktop/Services/IConversationWindowManager.cs
Normal file
16
src/PhysOn.Desktop/Services/IConversationWindowManager.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public interface IConversationWindowManager
|
||||
{
|
||||
event Action<int>? WindowCountChanged;
|
||||
|
||||
Task ShowOrFocusAsync(ConversationWindowLaunch launchContext, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record ConversationWindowLaunch(
|
||||
string ApiBaseUrl,
|
||||
string AccessToken,
|
||||
string DisplayName,
|
||||
string ConversationId,
|
||||
string ConversationTitle,
|
||||
string ConversationSubtitle);
|
||||
127
src/PhysOn.Desktop/Services/PhysOnApiClient.cs
Normal file
127
src/PhysOn.Desktop/Services/PhysOnApiClient.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using PhysOn.Contracts.Auth;
|
||||
using PhysOn.Contracts.Common;
|
||||
using PhysOn.Contracts.Conversations;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public sealed class PhysOnApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public Task<RegisterAlphaQuickResponse> RegisterAlphaQuickAsync(
|
||||
string apiBaseUrl,
|
||||
RegisterAlphaQuickRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<RegisterAlphaQuickResponse>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Post,
|
||||
"/v1/auth/register/alpha-quick",
|
||||
null,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<BootstrapResponse> GetBootstrapAsync(
|
||||
string apiBaseUrl,
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<BootstrapResponse>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Get,
|
||||
"/v1/bootstrap",
|
||||
accessToken,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
public Task<ListEnvelope<MessageItemDto>> GetMessagesAsync(
|
||||
string apiBaseUrl,
|
||||
string accessToken,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<ListEnvelope<MessageItemDto>>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Get,
|
||||
$"/v1/conversations/{conversationId}/messages",
|
||||
accessToken,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
public Task<MessageItemDto> SendTextMessageAsync(
|
||||
string apiBaseUrl,
|
||||
string accessToken,
|
||||
string conversationId,
|
||||
PostTextMessageRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<MessageItemDto>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Post,
|
||||
$"/v1/conversations/{conversationId}/messages",
|
||||
accessToken,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
public Task<ReadCursorUpdatedDto> UpdateReadCursorAsync(
|
||||
string apiBaseUrl,
|
||||
string accessToken,
|
||||
string conversationId,
|
||||
UpdateReadCursorRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync<ReadCursorUpdatedDto>(
|
||||
apiBaseUrl,
|
||||
HttpMethod.Post,
|
||||
$"/v1/conversations/{conversationId}/read-cursor",
|
||||
accessToken,
|
||||
request,
|
||||
cancellationToken);
|
||||
|
||||
private static async Task<T> SendAsync<T>(
|
||||
string apiBaseUrl,
|
||||
HttpMethod method,
|
||||
string path,
|
||||
string? accessToken,
|
||||
object? body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(EnsureTrailingSlash(apiBaseUrl)),
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = JsonSerializer.Deserialize<ApiErrorEnvelope>(payload, JsonOptions);
|
||||
throw new InvalidOperationException(error?.Error.Message ?? $"요청이 실패했습니다. ({response.StatusCode})");
|
||||
}
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<ApiEnvelope<T>>(payload, JsonOptions);
|
||||
if (envelope is null)
|
||||
{
|
||||
throw new InvalidOperationException("서버 응답을 읽지 못했습니다.");
|
||||
}
|
||||
|
||||
return envelope.Data;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSlash(string apiBaseUrl) =>
|
||||
apiBaseUrl.EndsWith("/", StringComparison.Ordinal) ? apiBaseUrl : $"{apiBaseUrl}/";
|
||||
}
|
||||
287
src/PhysOn.Desktop/Services/PhysOnRealtimeClient.cs
Normal file
287
src/PhysOn.Desktop/Services/PhysOnRealtimeClient.cs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using PhysOn.Contracts.Conversations;
|
||||
using PhysOn.Contracts.Realtime;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public enum RealtimeConnectionState
|
||||
{
|
||||
Idle,
|
||||
Connecting,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
Disconnected
|
||||
}
|
||||
|
||||
public sealed class PhysOnRealtimeClient : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly SemaphoreSlim _lifecycleLock = new(1, 1);
|
||||
|
||||
private ClientWebSocket? _socket;
|
||||
private CancellationTokenSource? _connectionCts;
|
||||
private Task? _connectionTask;
|
||||
private bool _disposed;
|
||||
|
||||
public event Action<RealtimeConnectionState>? ConnectionStateChanged;
|
||||
public event Action<SessionConnectedDto>? SessionConnected;
|
||||
public event Action<MessageItemDto>? MessageCreated;
|
||||
public event Action<ReadCursorUpdatedDto>? ReadCursorUpdated;
|
||||
|
||||
public async Task ConnectAsync(string wsUrl, string accessToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _lifecycleLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await DisconnectCoreAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(wsUrl) || string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
NotifyStateChanged(RealtimeConnectionState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
_connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_connectionTask = RunConnectionLoopAsync(new Uri(wsUrl), accessToken, _connectionCts.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifecycleLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _lifecycleLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await DisconnectCoreAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lifecycleLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
await DisconnectAsync();
|
||||
_lifecycleLock.Dispose();
|
||||
}
|
||||
|
||||
private async Task DisconnectCoreAsync()
|
||||
{
|
||||
var cts = _connectionCts;
|
||||
var socket = _socket;
|
||||
var task = _connectionTask;
|
||||
|
||||
_connectionCts = null;
|
||||
_connectionTask = null;
|
||||
_socket = null;
|
||||
|
||||
if (cts is null && socket is null && task is null)
|
||||
{
|
||||
NotifyStateChanged(RealtimeConnectionState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cts?.Cancel();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cancellation races during shutdown.
|
||||
}
|
||||
|
||||
if (socket is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "shutdown", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore close failures during shutdown.
|
||||
}
|
||||
finally
|
||||
{
|
||||
socket.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (task is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when the client is disposed or explicitly disconnected.
|
||||
}
|
||||
}
|
||||
|
||||
cts?.Dispose();
|
||||
NotifyStateChanged(RealtimeConnectionState.Idle);
|
||||
}
|
||||
|
||||
private async Task RunConnectionLoopAsync(Uri wsUri, string accessToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var reconnecting = false;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using var socket = new ClientWebSocket();
|
||||
socket.Options.SetRequestHeader("Authorization", $"Bearer {accessToken}");
|
||||
_socket = socket;
|
||||
|
||||
NotifyStateChanged(reconnecting ? RealtimeConnectionState.Reconnecting : RealtimeConnectionState.Connecting);
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(wsUri, cancellationToken);
|
||||
NotifyStateChanged(RealtimeConnectionState.Connected);
|
||||
await ReceiveLoopAsync(socket, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch
|
||||
{
|
||||
NotifyStateChanged(RealtimeConnectionState.Disconnected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_socket, socket))
|
||||
{
|
||||
_socket = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore connection teardown errors.
|
||||
}
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
reconnecting = true;
|
||||
NotifyStateChanged(RealtimeConnectionState.Disconnected);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NotifyStateChanged(RealtimeConnectionState.Idle);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
WebSocketReceiveResult result;
|
||||
|
||||
do
|
||||
{
|
||||
result = await socket.ReceiveAsync(buffer, cancellationToken);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
stream.Write(buffer, 0, result.Count);
|
||||
}
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType != WebSocketMessageType.Text || stream.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
DispatchIncomingEvent(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private void DispatchIncomingEvent(Stream payloadStream)
|
||||
{
|
||||
using var document = JsonDocument.Parse(payloadStream);
|
||||
if (!document.RootElement.TryGetProperty("event", out var eventProperty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.RootElement.TryGetProperty("data", out var dataProperty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var eventName = eventProperty.GetString();
|
||||
switch (eventName)
|
||||
{
|
||||
case "session.connected":
|
||||
var sessionConnected = dataProperty.Deserialize<SessionConnectedDto>(JsonOptions);
|
||||
if (sessionConnected is not null)
|
||||
{
|
||||
SessionConnected?.Invoke(sessionConnected);
|
||||
}
|
||||
break;
|
||||
|
||||
case "message.created":
|
||||
var messageCreated = dataProperty.Deserialize<MessageItemDto>(JsonOptions);
|
||||
if (messageCreated is not null)
|
||||
{
|
||||
MessageCreated?.Invoke(messageCreated);
|
||||
}
|
||||
break;
|
||||
|
||||
case "read_cursor.updated":
|
||||
var readCursorUpdated = dataProperty.Deserialize<ReadCursorUpdatedDto>(JsonOptions);
|
||||
if (readCursorUpdated is not null)
|
||||
{
|
||||
ReadCursorUpdated?.Invoke(readCursorUpdated);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyStateChanged(RealtimeConnectionState state) => ConnectionStateChanged?.Invoke(state);
|
||||
}
|
||||
136
src/PhysOn.Desktop/Services/SessionStore.cs
Normal file
136
src/PhysOn.Desktop/Services/SessionStore.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using PhysOn.Desktop.Models;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public sealed class SessionStore
|
||||
{
|
||||
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("PhysOn.Desktop.Session");
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly byte[] WindowsHeader = "VSMW1"u8.ToArray();
|
||||
|
||||
private readonly string _sessionDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PhysOn");
|
||||
private readonly string _sessionPath;
|
||||
private readonly string _legacySessionPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PhysOn",
|
||||
"session.json");
|
||||
|
||||
public SessionStore()
|
||||
{
|
||||
_sessionPath = OperatingSystem.IsWindows()
|
||||
? Path.Combine(_sessionDirectory, "session.dat")
|
||||
: Path.Combine(_sessionDirectory, "session.json");
|
||||
}
|
||||
|
||||
public async Task<DesktopSession?> LoadAsync()
|
||||
{
|
||||
if (File.Exists(_sessionPath))
|
||||
{
|
||||
return await LoadFromPathAsync(_sessionPath);
|
||||
}
|
||||
|
||||
if (File.Exists(_legacySessionPath))
|
||||
{
|
||||
return await LoadFromPathAsync(_legacySessionPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task SaveAsync(DesktopSession session)
|
||||
{
|
||||
Directory.CreateDirectory(_sessionDirectory);
|
||||
ApplyDirectoryPermissions(_sessionDirectory);
|
||||
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(session, JsonOptions);
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
payload = WindowsHeader
|
||||
.Concat(ProtectedData.Protect(payload, Entropy, DataProtectionScope.CurrentUser))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
await File.WriteAllBytesAsync(_sessionPath, payload);
|
||||
ApplyFilePermissions(_sessionPath);
|
||||
|
||||
if (File.Exists(_legacySessionPath))
|
||||
{
|
||||
File.Delete(_legacySessionPath);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ClearAsync()
|
||||
{
|
||||
if (File.Exists(_sessionPath))
|
||||
{
|
||||
File.Delete(_sessionPath);
|
||||
}
|
||||
|
||||
if (File.Exists(_legacySessionPath))
|
||||
{
|
||||
File.Delete(_legacySessionPath);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<DesktopSession?> LoadFromPathAsync(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = await File.ReadAllBytesAsync(path);
|
||||
if (payload.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows() && payload.AsSpan().StartsWith(WindowsHeader))
|
||||
{
|
||||
var encrypted = payload.AsSpan(WindowsHeader.Length).ToArray();
|
||||
var decrypted = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
||||
return JsonSerializer.Deserialize<DesktopSession>(decrypted, JsonOptions);
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<DesktopSession>(payload, JsonOptions);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyDirectoryPermissions(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetAttributes(path, FileAttributes.Directory | FileAttributes.Hidden);
|
||||
return;
|
||||
}
|
||||
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
}
|
||||
|
||||
private static void ApplyFilePermissions(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetAttributes(path, FileAttributes.Hidden);
|
||||
return;
|
||||
}
|
||||
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
51
src/PhysOn.Desktop/Services/WorkspaceLayoutStore.cs
Normal file
51
src/PhysOn.Desktop/Services/WorkspaceLayoutStore.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using System.Text.Json;
|
||||
using PhysOn.Desktop.Models;
|
||||
|
||||
namespace PhysOn.Desktop.Services;
|
||||
|
||||
public sealed class WorkspaceLayoutStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _directoryPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PhysOn");
|
||||
private readonly string _layoutPath;
|
||||
|
||||
public WorkspaceLayoutStore()
|
||||
{
|
||||
_layoutPath = Path.Combine(_directoryPath, "workspace-layout.json");
|
||||
}
|
||||
|
||||
public async Task<DesktopWorkspaceLayout?> LoadAsync()
|
||||
{
|
||||
if (!File.Exists(_layoutPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = await File.ReadAllTextAsync(_layoutPath);
|
||||
return JsonSerializer.Deserialize<DesktopWorkspaceLayout>(payload, JsonOptions);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync(DesktopWorkspaceLayout layout)
|
||||
{
|
||||
Directory.CreateDirectory(_directoryPath);
|
||||
var payload = JsonSerializer.Serialize(layout, JsonOptions);
|
||||
await File.WriteAllTextAsync(_layoutPath, payload);
|
||||
}
|
||||
}
|
||||
37
src/PhysOn.Desktop/ViewLocator.cs
Normal file
37
src/PhysOn.Desktop/ViewLocator.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
|
||||
namespace PhysOn.Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Given a view model, returns the corresponding view if possible.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode(
|
||||
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
|
||||
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
|
||||
public class ViewLocator : IDataTemplate
|
||||
{
|
||||
public Control? Build(object? param)
|
||||
{
|
||||
if (param is null)
|
||||
return null;
|
||||
|
||||
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||
var type = Type.GetType(name);
|
||||
|
||||
if (type != null)
|
||||
{
|
||||
return (Control)Activator.CreateInstance(type)!;
|
||||
}
|
||||
|
||||
return new TextBlock { Text = "Not Found: " + name };
|
||||
}
|
||||
|
||||
public bool Match(object? data)
|
||||
{
|
||||
return data is ViewModelBase;
|
||||
}
|
||||
}
|
||||
32
src/PhysOn.Desktop/ViewModels/ConversationRowViewModel.cs
Normal file
32
src/PhysOn.Desktop/ViewModels/ConversationRowViewModel.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PhysOn.Desktop.ViewModels;
|
||||
|
||||
public partial class ConversationRowViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string conversationId = string.Empty;
|
||||
[ObservableProperty] private string title = string.Empty;
|
||||
[ObservableProperty] private string subtitle = string.Empty;
|
||||
[ObservableProperty] private string lastMessageText = string.Empty;
|
||||
[ObservableProperty] private string metaText = string.Empty;
|
||||
[ObservableProperty] private int unreadCount;
|
||||
[ObservableProperty] private bool isPinned;
|
||||
[ObservableProperty] private bool isSelected;
|
||||
[ObservableProperty] private long lastReadSequence;
|
||||
[ObservableProperty] private DateTimeOffset sortKey;
|
||||
|
||||
public bool HasUnread => UnreadCount > 0;
|
||||
public string UnreadBadgeText => UnreadCount.ToString();
|
||||
public string AvatarText => string.IsNullOrWhiteSpace(Title) ? "VS" : Title.Trim()[..Math.Min(2, Title.Trim().Length)];
|
||||
|
||||
partial void OnUnreadCountChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasUnread));
|
||||
OnPropertyChanged(nameof(UnreadBadgeText));
|
||||
}
|
||||
|
||||
partial void OnTitleChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AvatarText));
|
||||
}
|
||||
}
|
||||
224
src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs
Normal file
224
src/PhysOn.Desktop/ViewModels/ConversationWindowViewModel.cs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PhysOn.Contracts.Conversations;
|
||||
using PhysOn.Contracts.Realtime;
|
||||
using PhysOn.Desktop.Services;
|
||||
|
||||
namespace PhysOn.Desktop.ViewModels;
|
||||
|
||||
public partial class ConversationWindowViewModel : ViewModelBase, IAsyncDisposable
|
||||
{
|
||||
private readonly PhysOnApiClient _apiClient = new();
|
||||
private readonly PhysOnRealtimeClient _realtimeClient = new();
|
||||
private readonly ConversationWindowLaunch _launchContext;
|
||||
|
||||
public ConversationWindowViewModel(ConversationWindowLaunch launchContext)
|
||||
{
|
||||
_launchContext = launchContext;
|
||||
ConversationTitle = launchContext.ConversationTitle;
|
||||
ConversationSubtitle = launchContext.ConversationSubtitle;
|
||||
SendMessageCommand = new AsyncRelayCommand(SendMessageAsync, CanSendMessage);
|
||||
ReloadCommand = new AsyncRelayCommand(LoadMessagesAsync, () => !IsBusy);
|
||||
|
||||
_realtimeClient.ConnectionStateChanged += HandleRealtimeConnectionStateChanged;
|
||||
_realtimeClient.MessageCreated += HandleMessageCreated;
|
||||
}
|
||||
|
||||
public ObservableCollection<MessageRowViewModel> Messages { get; } = [];
|
||||
|
||||
public IAsyncRelayCommand SendMessageCommand { get; }
|
||||
public IAsyncRelayCommand ReloadCommand { get; }
|
||||
|
||||
[ObservableProperty] private string conversationTitle = string.Empty;
|
||||
[ObservableProperty] private string conversationSubtitle = string.Empty;
|
||||
[ObservableProperty] private string composerText = string.Empty;
|
||||
[ObservableProperty] private string statusText = "·";
|
||||
[ObservableProperty] private bool isBusy;
|
||||
[ObservableProperty] private string? errorText;
|
||||
|
||||
public string ConversationGlyph =>
|
||||
string.IsNullOrWhiteSpace(ConversationTitle) ? "PO" : ConversationTitle.Trim()[..Math.Min(2, ConversationTitle.Trim().Length)];
|
||||
|
||||
public bool HasErrorText => !string.IsNullOrWhiteSpace(ErrorText);
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await LoadMessagesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var bootstrap = await _apiClient.GetBootstrapAsync(
|
||||
_launchContext.ApiBaseUrl,
|
||||
_launchContext.AccessToken,
|
||||
CancellationToken.None);
|
||||
await _realtimeClient.ConnectAsync(bootstrap.Ws.Url, _launchContext.AccessToken, CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ErrorText = exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendMessageFromShortcutAsync()
|
||||
{
|
||||
if (SendMessageCommand.CanExecute(null))
|
||||
{
|
||||
await SendMessageCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _realtimeClient.DisposeAsync();
|
||||
}
|
||||
|
||||
partial void OnComposerTextChanged(string value) => SendMessageCommand.NotifyCanExecuteChanged();
|
||||
partial void OnErrorTextChanged(string? value) => OnPropertyChanged(nameof(HasErrorText));
|
||||
partial void OnConversationTitleChanged(string value) => OnPropertyChanged(nameof(ConversationGlyph));
|
||||
|
||||
private async Task LoadMessagesAsync()
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IsBusy = true;
|
||||
ErrorText = null;
|
||||
StatusText = "◌";
|
||||
|
||||
var items = await _apiClient.GetMessagesAsync(
|
||||
_launchContext.ApiBaseUrl,
|
||||
_launchContext.AccessToken,
|
||||
_launchContext.ConversationId,
|
||||
CancellationToken.None);
|
||||
|
||||
Messages.Clear();
|
||||
foreach (var item in items.Items.OrderBy(message => message.ServerSequence))
|
||||
{
|
||||
Messages.Add(MapMessage(item));
|
||||
}
|
||||
|
||||
StatusText = "●";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
ErrorText = exception.Message;
|
||||
StatusText = "×";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
ReloadCommand.NotifyCanExecuteChanged();
|
||||
SendMessageCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendMessageAsync()
|
||||
{
|
||||
if (!CanSendMessage())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var draft = ComposerText.Trim();
|
||||
var clientMessageId = Guid.NewGuid();
|
||||
ComposerText = string.Empty;
|
||||
|
||||
var pendingMessage = new MessageRowViewModel
|
||||
{
|
||||
MessageId = $"pending-{Guid.NewGuid():N}",
|
||||
ClientMessageId = clientMessageId,
|
||||
Text = draft,
|
||||
SenderName = _launchContext.DisplayName,
|
||||
MetaText = "보내는 중",
|
||||
IsMine = true,
|
||||
IsPending = true,
|
||||
ServerSequence = Messages.Count == 0 ? 1 : Messages[^1].ServerSequence + 1
|
||||
};
|
||||
|
||||
Messages.Add(pendingMessage);
|
||||
|
||||
try
|
||||
{
|
||||
var committed = await _apiClient.SendTextMessageAsync(
|
||||
_launchContext.ApiBaseUrl,
|
||||
_launchContext.AccessToken,
|
||||
_launchContext.ConversationId,
|
||||
new PostTextMessageRequest(clientMessageId, draft),
|
||||
CancellationToken.None);
|
||||
|
||||
Messages.Remove(pendingMessage);
|
||||
UpsertMessage(MapMessage(committed));
|
||||
StatusText = "●";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
pendingMessage.IsPending = false;
|
||||
pendingMessage.IsFailed = true;
|
||||
pendingMessage.MetaText = "전송 실패";
|
||||
ErrorText = exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanSendMessage() => !IsBusy && !string.IsNullOrWhiteSpace(ComposerText);
|
||||
|
||||
private void HandleRealtimeConnectionStateChanged(RealtimeConnectionState state)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
StatusText = state switch
|
||||
{
|
||||
RealtimeConnectionState.Connected => "●",
|
||||
RealtimeConnectionState.Reconnecting => "◔",
|
||||
RealtimeConnectionState.Disconnected => "○",
|
||||
RealtimeConnectionState.Connecting => "◌",
|
||||
_ => StatusText
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleMessageCreated(MessageItemDto payload)
|
||||
{
|
||||
if (!string.Equals(payload.ConversationId, _launchContext.ConversationId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() => UpsertMessage(MapMessage(payload)));
|
||||
}
|
||||
|
||||
private static MessageRowViewModel MapMessage(MessageItemDto item)
|
||||
{
|
||||
return new MessageRowViewModel
|
||||
{
|
||||
MessageId = item.MessageId,
|
||||
ClientMessageId = item.ClientMessageId,
|
||||
Text = item.Text,
|
||||
SenderName = item.Sender.DisplayName,
|
||||
MetaText = item.CreatedAt.LocalDateTime.ToString("HH:mm"),
|
||||
IsMine = item.IsMine,
|
||||
ServerSequence = item.ServerSequence
|
||||
};
|
||||
}
|
||||
|
||||
private void UpsertMessage(MessageRowViewModel next)
|
||||
{
|
||||
var existing = Messages.FirstOrDefault(item =>
|
||||
string.Equals(item.MessageId, next.MessageId, StringComparison.Ordinal) ||
|
||||
(next.ClientMessageId != Guid.Empty && item.ClientMessageId == next.ClientMessageId));
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
var index = Messages.IndexOf(existing);
|
||||
Messages[index] = next;
|
||||
return;
|
||||
}
|
||||
|
||||
Messages.Add(next);
|
||||
}
|
||||
}
|
||||
1052
src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
Normal file
1052
src/PhysOn.Desktop/ViewModels/MainWindowViewModel.cs
Normal file
File diff suppressed because it is too large
Load diff
23
src/PhysOn.Desktop/ViewModels/MessageRowViewModel.cs
Normal file
23
src/PhysOn.Desktop/ViewModels/MessageRowViewModel.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PhysOn.Desktop.ViewModels;
|
||||
|
||||
public partial class MessageRowViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string messageId = string.Empty;
|
||||
[ObservableProperty] private Guid clientMessageId;
|
||||
[ObservableProperty] private string text = string.Empty;
|
||||
[ObservableProperty] private string senderName = string.Empty;
|
||||
[ObservableProperty] private string metaText = string.Empty;
|
||||
[ObservableProperty] private bool isMine;
|
||||
[ObservableProperty] private bool isPending;
|
||||
[ObservableProperty] private bool isFailed;
|
||||
[ObservableProperty] private long serverSequence;
|
||||
|
||||
public bool ShowSenderName => !IsMine;
|
||||
|
||||
partial void OnIsMineChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowSenderName));
|
||||
}
|
||||
}
|
||||
7
src/PhysOn.Desktop/ViewModels/ViewModelBase.cs
Normal file
7
src/PhysOn.Desktop/ViewModels/ViewModelBase.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace PhysOn.Desktop.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
142
src/PhysOn.Desktop/Views/ConversationWindow.axaml
Normal file
142
src/PhysOn.Desktop/Views/ConversationWindow.axaml
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PhysOn.Desktop.ViewModels"
|
||||
x:Class="PhysOn.Desktop.Views.ConversationWindow"
|
||||
x:DataType="vm:ConversationWindowViewModel"
|
||||
Width="460"
|
||||
Height="760"
|
||||
MinWidth="360"
|
||||
MinHeight="520"
|
||||
Background="#F6F7F8"
|
||||
Title="{Binding ConversationTitle}">
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.surface">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E6E8EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.soft">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="BorderBrush" Value="#ECEEF1" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="9,7" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#D9DDE2" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="12,9" />
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="#6A7380" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="#151A20" />
|
||||
</Style>
|
||||
<Style Selector="Border.bubble">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="8,7" />
|
||||
<Setter Property="Margin" Value="0,0,0,5" />
|
||||
<Setter Property="Background" Value="#F6F7F8" />
|
||||
<Setter Property="BorderBrush" Value="#EAEDF0" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
<Style Selector="Border.bubble.mine">
|
||||
<Setter Property="Background" Value="#ECEFF3" />
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid Margin="16" RowDefinitions="Auto,Auto,*,Auto" RowSpacing="10">
|
||||
<Border Classes="surface" Padding="12">
|
||||
<Grid ColumnDefinitions="42,*,Auto" ColumnSpacing="12">
|
||||
<Border Width="42" Height="42" Classes="soft">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding ConversationGlyph}"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#151A20" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="2">
|
||||
<TextBlock Text="{Binding ConversationTitle}"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#151A20"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||
<Border Classes="soft" Padding="8,5">
|
||||
<TextBlock Text="{Binding StatusText}" Classes="caption" />
|
||||
</Border>
|
||||
<Button Classes="icon"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="↻" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Classes="surface"
|
||||
Padding="10"
|
||||
IsVisible="{Binding HasErrorText}">
|
||||
<TextBlock Text="{Binding ErrorText}"
|
||||
Foreground="#C62828"
|
||||
Classes="caption"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2" Classes="surface" Padding="12">
|
||||
<ScrollViewer Name="MessagesScrollViewer">
|
||||
<ItemsControl ItemsSource="{Binding Messages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
||||
<Border Classes="bubble"
|
||||
Classes.mine="{Binding IsMine}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding SenderName}"
|
||||
Classes="caption"
|
||||
FontWeight="SemiBold"
|
||||
IsVisible="{Binding ShowSenderName}" />
|
||||
<TextBlock Text="{Binding Text}"
|
||||
Classes="body"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding MetaText}" Classes="caption" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3" Classes="surface" Padding="10">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||
<TextBox Name="ComposerTextBox"
|
||||
PlaceholderText="메시지"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="46"
|
||||
Text="{Binding ComposerText}"
|
||||
KeyDown="ComposerTextBox_OnKeyDown" />
|
||||
<Button Grid.Column="1"
|
||||
Classes="primary"
|
||||
Command="{Binding SendMessageCommand}"
|
||||
Content="보내기" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
72
src/PhysOn.Desktop/Views/ConversationWindow.axaml.cs
Normal file
72
src/PhysOn.Desktop/Views/ConversationWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System.Collections.Specialized;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
|
||||
namespace PhysOn.Desktop.Views;
|
||||
|
||||
public partial class ConversationWindow : Window
|
||||
{
|
||||
private ConversationWindowViewModel? _boundViewModel;
|
||||
|
||||
public ConversationWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private async void ComposerTextBox_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
if (DataContext is ConversationWindowViewModel viewModel)
|
||||
{
|
||||
await viewModel.SendMessageFromShortcutAsync();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
}
|
||||
|
||||
_boundViewModel = DataContext as ConversationWindowViewModel;
|
||||
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged += Messages_OnCollectionChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void Messages_OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action is NotifyCollectionChangedAction.Add or NotifyCollectionChangedAction.Reset or NotifyCollectionChangedAction.Replace)
|
||||
{
|
||||
Dispatcher.UIThread.Post(ScrollMessagesToEnd, DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
}
|
||||
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
private void ScrollMessagesToEnd()
|
||||
{
|
||||
if (this.FindControl<ScrollViewer>("MessagesScrollViewer") is { } scrollViewer)
|
||||
{
|
||||
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, scrollViewer.Extent.Height);
|
||||
}
|
||||
}
|
||||
}
|
||||
712
src/PhysOn.Desktop/Views/MainWindow.axaml
Normal file
712
src/PhysOn.Desktop/Views/MainWindow.axaml
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:PhysOn.Desktop.ViewModels"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
x:Name="RootWindow"
|
||||
x:Class="PhysOn.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
d:DesignWidth="1440"
|
||||
d:DesignHeight="900"
|
||||
Title="KoTalk"
|
||||
Width="1440"
|
||||
Height="900"
|
||||
MinWidth="1040"
|
||||
MinHeight="680"
|
||||
Background="#F6F7F8">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.surface">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.surface-muted">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Background" Value="#F5F6F7" />
|
||||
<Setter Property="BorderBrush" Value="#E8EBEF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.rail-surface">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.dashboard-card">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="BorderBrush" Value="#E8EBEF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
</Style>
|
||||
<Style Selector="Border.empty-card">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="18" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.display-title">
|
||||
<Setter Property="FontSize" Value="28" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="#111418" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.section-title">
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="#111418" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Foreground" Value="#1D232B" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="Foreground" Value="#6A7480" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.eyebrow">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="#6A7480" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.input">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="11,8" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#D9DEE4" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.search-input">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
<Setter Property="Background" Value="#F6F7F8" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary-button">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="14,10" />
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary-button">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="13,10" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="Foreground" Value="#1E252C" />
|
||||
<Setter Property="BorderBrush" Value="#D9DEE4" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon-button">
|
||||
<Setter Property="Height" Value="34" />
|
||||
<Setter Property="MinWidth" Value="34" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="10,0" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="Foreground" Value="#1E252C" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon-button.compact">
|
||||
<Setter Property="MinWidth" Value="28" />
|
||||
<Setter Property="Height" Value="28" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="8,0" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
</Style>
|
||||
<Style Selector="Button.filter-button">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="10,6" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="Foreground" Value="#69727D" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Button.filter-button.selected">
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#111418" />
|
||||
</Style>
|
||||
<Style Selector="Button.rail-button">
|
||||
<Setter Property="MinWidth" Value="48" />
|
||||
<Setter Property="Height" Value="52" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="Foreground" Value="#1E252C" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
<Style Selector="Button.rail-button.active">
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#111418" />
|
||||
</Style>
|
||||
<Style Selector="Button.row-button">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
<Style Selector="Border.row-card">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#EEF1F4" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.row-card.active">
|
||||
<Setter Property="Background" Value="#F3F5F7" />
|
||||
<Setter Property="BorderBrush" Value="#D8DDE4" />
|
||||
</Style>
|
||||
<Style Selector="Border.status-chip">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.inline-alert">
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Background" Value="#FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#C9392C" />
|
||||
<Setter Property="BorderThickness" Value="1,0,0,0" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
</Style>
|
||||
<Style Selector="Border.avatar-badge">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#F3F4F6" />
|
||||
</Style>
|
||||
<Style Selector="Border.unread-badge">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Background" Value="#111418" />
|
||||
<Setter Property="Padding" Value="7,2" />
|
||||
</Style>
|
||||
<Style Selector="Border.message-bubble">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#F7F8F9" />
|
||||
<Setter Property="BorderBrush" Value="#E4E7EB" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="MaxWidth" Value="680" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
<Style Selector="Border.message-bubble.mine">
|
||||
<Setter Property="Background" Value="#EEF1F4" />
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
<Style Selector="Border.message-bubble.pending">
|
||||
<Setter Property="Opacity" Value="0.72" />
|
||||
</Style>
|
||||
<Style Selector="Border.message-bubble.failed">
|
||||
<Setter Property="BorderBrush" Value="#C9392C" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.message-text">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="#111418" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.message-meta">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Foreground" Value="#69727D" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid Margin="20">
|
||||
<Grid IsVisible="{Binding ShowOnboarding}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
MaxWidth="1120"
|
||||
ColumnDefinitions="1.2*,420"
|
||||
ColumnSpacing="18">
|
||||
<Border Grid.Column="0" Classes="surface" Padding="28">
|
||||
<Grid RowDefinitions="Auto,Auto,*" RowSpacing="18">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="KTOP" Classes="eyebrow" />
|
||||
<StackPanel Spacing="6">
|
||||
<Border Width="60" Height="60" Classes="surface-muted" HorizontalAlignment="Left">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="KO"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
<TextBlock Text="KoTalk" Classes="display-title" />
|
||||
<TextBlock Text="열면 바로 이어지는 대화 워크스페이스" Classes="body" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,*,*" ColumnSpacing="10">
|
||||
<Border Grid.Column="0" Classes="dashboard-card">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="빠른 입장" Classes="eyebrow" />
|
||||
<TextBlock Text="표시 이름 + 참여 키" Classes="section-title" />
|
||||
<TextBlock Text="필수 입력만 바로 보입니다." Classes="caption" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Classes="dashboard-card">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="대화 집중" Classes="eyebrow" />
|
||||
<TextBlock Text="받은함과 대화만 남김" Classes="section-title" />
|
||||
<TextBlock Text="불필요한 빈 패널을 줄였습니다." Classes="caption" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="2" Classes="dashboard-card">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="멀티 윈도우" Classes="eyebrow" />
|
||||
<TextBlock Text="대화별 분리 창" Classes="section-title" />
|
||||
<TextBlock Text="작업 흐름을 끊지 않고 분리합니다." Classes="caption" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="2" Classes="surface-muted" Padding="16">
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||
<Border Width="36" Height="36" Classes="surface">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="·"
|
||||
FontWeight="Bold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Spacing="4">
|
||||
<TextBlock Text="세션은 이 기기에만 남깁니다." Classes="section-title" />
|
||||
<TextBlock Text="서버 주소는 기본으로 숨겨 두고, 변경이 필요할 때만 고급 설정에서 엽니다." Classes="caption" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Classes="surface" Padding="22">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*" RowSpacing="10">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="입장" Classes="section-title" />
|
||||
<TextBlock Text="필수 입력만 먼저 받습니다." Classes="caption" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Grid.Row="1"
|
||||
Classes="input"
|
||||
PlaceholderText="표시 이름"
|
||||
Text="{Binding DisplayName}" />
|
||||
|
||||
<TextBox Grid.Row="2"
|
||||
Classes="input"
|
||||
PlaceholderText="참여 키"
|
||||
Text="{Binding InviteCode}" />
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" ColumnSpacing="10">
|
||||
<Button Classes="secondary-button"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding ToggleAdvancedSettingsCommand}"
|
||||
Content="{Binding AdvancedSettingsButtonText}" />
|
||||
<TextBox Grid.Column="1"
|
||||
Classes="input"
|
||||
IsVisible="{Binding ShowAdvancedSettings}"
|
||||
PlaceholderText="기본 서버를 바꾸는 경우만 입력"
|
||||
Text="{Binding ApiBaseUrl}" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="4" VerticalAlignment="Bottom" Spacing="10">
|
||||
<CheckBox Content="이 기기에서 이어서 열기" IsChecked="{Binding RememberSession}" />
|
||||
<Border Classes="inline-alert" IsVisible="{Binding HasErrorText}">
|
||||
<TextBlock Text="{Binding ErrorText}"
|
||||
Classes="caption"
|
||||
Foreground="#C9392C"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<Button Classes="primary-button"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{Binding SignInCommand}"
|
||||
Content="대화 열기" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid IsVisible="{Binding ShowShell}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="72" />
|
||||
<ColumnDefinition Width="{Binding ConversationPaneWidth}" />
|
||||
<ColumnDefinition Width="8" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Classes="rail-surface" Padding="10">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Spacing="12">
|
||||
<Border Width="48" Height="48" Classes="surface-muted">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="KO"
|
||||
FontWeight="Bold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
|
||||
<Button Classes="rail-button active" ToolTip.Tip="받은함">
|
||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||
<TextBlock Text="⌂" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="대화" Classes="caption" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="rail-button"
|
||||
ToolTip.Tip="대화를 분리 창으로 열기"
|
||||
Command="{Binding DetachConversationCommand}">
|
||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding DetachedWindowActionGlyph}" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="분리" Classes="caption" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="rail-button"
|
||||
ToolTip.Tip="대화 목록 접기"
|
||||
Command="{Binding ToggleConversationPaneCommand}">
|
||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||
<TextBlock Text="{Binding PaneActionGlyph}" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="목록" Classes="caption" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2" Spacing="10">
|
||||
<Border Width="48" Height="48" Classes="surface-muted">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding CurrentUserMonogram}"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#1E252C" />
|
||||
</Border>
|
||||
<Button Classes="rail-button"
|
||||
ToolTip.Tip="로그아웃"
|
||||
Command="{Binding SignOutCommand}">
|
||||
<StackPanel Spacing="2" HorizontalAlignment="Center">
|
||||
<TextBlock Text="⎋" HorizontalAlignment="Center" />
|
||||
<TextBlock Text="종료" Classes="caption" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Classes="surface"
|
||||
Padding="14"
|
||||
IsVisible="{Binding IsConversationPaneExpanded}">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" RowSpacing="12">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="받은함" Classes="section-title" />
|
||||
<TextBlock Text="{Binding CurrentUserDisplayName}" Classes="caption" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Column="1" Classes="status-chip" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StatusSummaryText}" Classes="caption" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0"
|
||||
Classes="search-input"
|
||||
PlaceholderText="{Binding SearchWatermark}"
|
||||
Text="{Binding ConversationSearchText}" />
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="icon-button"
|
||||
ToolTip.Tip="밀도 전환"
|
||||
Command="{Binding ToggleCompactModeCommand}"
|
||||
Content="{Binding DensityGlyph}" />
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-button"
|
||||
ToolTip.Tip="새로고침"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="↻" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="2" Spacing="10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="filter-button"
|
||||
Classes.selected="{Binding IsAllFilterSelected}"
|
||||
Command="{Binding ShowAllConversationsCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="전체" />
|
||||
<TextBlock Text="{Binding TotalConversationCount}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="filter-button"
|
||||
Classes.selected="{Binding IsUnreadFilterSelected}"
|
||||
Command="{Binding ShowUnreadConversationsCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="안읽음" />
|
||||
<TextBlock Text="{Binding UnreadConversationCount}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="filter-button"
|
||||
Classes.selected="{Binding IsPinnedFilterSelected}"
|
||||
Command="{Binding ShowPinnedConversationsCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="고정" />
|
||||
<TextBlock Text="{Binding PinnedConversationCount}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<Border Classes="empty-card"
|
||||
IsVisible="{Binding ShowConversationEmptyState}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding ConversationEmptyStateText}" Classes="section-title" />
|
||||
<TextBlock Text="필터를 바꾸거나 새로고침으로 다시 맞춰 보세요." Classes="caption" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="secondary-button"
|
||||
Command="{Binding ShowAllConversationsCommand}"
|
||||
Content="전체 보기" />
|
||||
<Button Classes="secondary-button"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="다시 확인" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer IsVisible="{Binding HasFilteredConversations}">
|
||||
<ItemsControl ItemsSource="{Binding FilteredConversations}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConversationRowViewModel">
|
||||
<Grid Margin="0,0,0,6" ColumnDefinitions="*,Auto" ColumnSpacing="6">
|
||||
<Button Classes="row-button"
|
||||
Command="{Binding $parent[Window].DataContext.SelectConversationCommand}"
|
||||
CommandParameter="{Binding .}">
|
||||
<Border Classes="row-card"
|
||||
Classes.active="{Binding IsSelected}"
|
||||
Padding="{Binding $parent[Window].DataContext.ConversationRowPadding}">
|
||||
<Grid ColumnDefinitions="34,*,Auto" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="2">
|
||||
<Border Width="34"
|
||||
Height="34"
|
||||
Classes="avatar-badge">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding AvatarText}"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Title}"
|
||||
Classes="body"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2"
|
||||
Text="{Binding MetaText}"
|
||||
Classes="caption" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Text="{Binding LastMessageText}"
|
||||
Classes="caption"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<StackPanel Grid.Row="1"
|
||||
Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6"
|
||||
HorizontalAlignment="Right">
|
||||
<TextBlock Text="★"
|
||||
Classes="caption"
|
||||
IsVisible="{Binding IsPinned}" />
|
||||
<Border Classes="unread-badge"
|
||||
IsVisible="{Binding HasUnread}">
|
||||
<TextBlock Text="{Binding UnreadBadgeText}"
|
||||
FontSize="11"
|
||||
Foreground="#FFFFFF" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Button>
|
||||
<Button Grid.Column="1"
|
||||
Classes="icon-button compact"
|
||||
ToolTip.Tip="분리 창으로 열기"
|
||||
Command="{Binding $parent[Window].DataContext.DetachConversationRowCommand}"
|
||||
CommandParameter="{Binding .}"
|
||||
Content="↗" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,Auto" ColumnSpacing="8">
|
||||
<Border Classes="status-chip">
|
||||
<TextBlock Text="{Binding RealtimeStatusText}" Classes="caption" />
|
||||
</Border>
|
||||
<Border Grid.Column="1" Classes="status-chip">
|
||||
<TextBlock Text="{Binding WorkspaceModeText}" Classes="caption" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="2"
|
||||
Width="6"
|
||||
IsVisible="{Binding IsConversationPaneExpanded}"
|
||||
Background="#E4E7EB"
|
||||
ResizeDirection="Columns"
|
||||
ShowsPreview="True" />
|
||||
|
||||
<Border Grid.Column="3" Classes="surface" Padding="14">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" RowSpacing="12">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto" ColumnSpacing="8">
|
||||
<Grid Grid.Column="0" ColumnDefinitions="Auto,*" ColumnSpacing="10">
|
||||
<Border Width="38" Height="38" Classes="surface-muted">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding SelectedConversationGlyph}"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="#111418" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Spacing="2">
|
||||
<TextBlock Text="{Binding SelectedConversationTitle}"
|
||||
Classes="section-title"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding SelectedConversationSubtitle}"
|
||||
Classes="caption"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Classes="status-chip" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding RealtimeStatusText}" Classes="caption" />
|
||||
</Border>
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-button"
|
||||
ToolTip.Tip="분리 창으로 열기"
|
||||
Command="{Binding DetachConversationCommand}"
|
||||
Content="↗" />
|
||||
<Button Grid.Column="3"
|
||||
Classes="icon-button"
|
||||
ToolTip.Tip="새로고침"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="↻" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,*,*" ColumnSpacing="8">
|
||||
<Border Grid.Column="0" Classes="dashboard-card">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="안읽음" Classes="eyebrow" />
|
||||
<TextBlock Text="{Binding UnreadConversationCount}" Classes="section-title" />
|
||||
<TextBlock Text="지금 바로 볼 대화" Classes="caption" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Classes="dashboard-card">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="고정" Classes="eyebrow" />
|
||||
<TextBlock Text="{Binding PinnedConversationCount}" Classes="section-title" />
|
||||
<TextBlock Text="자주 여는 대화" Classes="caption" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="2" Classes="dashboard-card">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="창 상태" Classes="eyebrow" />
|
||||
<TextBlock Text="{Binding WorkspaceModeText}" Classes="section-title" />
|
||||
<TextBlock Text="분리 창을 바로 열 수 있습니다." Classes="caption" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2" RowDefinitions="Auto,*" RowSpacing="10">
|
||||
<Border Classes="inline-alert"
|
||||
IsVisible="{Binding HasErrorText}">
|
||||
<TextBlock Text="{Binding ErrorText}"
|
||||
Classes="caption"
|
||||
Foreground="#C9392C"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<ScrollViewer Name="MessagesScrollViewer">
|
||||
<ItemsControl ItemsSource="{Binding Messages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MessageRowViewModel">
|
||||
<Border Classes="message-bubble"
|
||||
Classes.mine="{Binding IsMine}"
|
||||
Classes.pending="{Binding IsPending}"
|
||||
Classes.failed="{Binding IsFailed}"
|
||||
Padding="{Binding $parent[Window].DataContext.MessageBubblePadding}">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="{Binding SenderName}"
|
||||
Classes="caption"
|
||||
FontWeight="SemiBold"
|
||||
IsVisible="{Binding ShowSenderName}" />
|
||||
<TextBlock Text="{Binding Text}" Classes="message-text" />
|
||||
<TextBlock Text="{Binding MetaText}" Classes="message-meta" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Border Classes="empty-card"
|
||||
Width="340"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowMessageEmptyState}">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="{Binding MessageEmptyStateTitle}" Classes="section-title" />
|
||||
<TextBlock Text="{Binding MessageEmptyStateText}" Classes="caption" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="secondary-button"
|
||||
Command="{Binding DetachConversationCommand}"
|
||||
Content="분리 창" />
|
||||
<Button Classes="secondary-button"
|
||||
Command="{Binding ReloadCommand}"
|
||||
Content="새로고침" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="3" Classes="surface-muted" Padding="10">
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto" ColumnSpacing="10" RowSpacing="8">
|
||||
<TextBox Name="ComposerTextBox"
|
||||
Grid.RowSpan="2"
|
||||
Classes="input"
|
||||
PlaceholderText="{Binding ComposerPlaceholderText}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="{Binding ComposerMinHeight}"
|
||||
Text="{Binding ComposerText}"
|
||||
KeyDown="ComposerTextBox_OnKeyDown" />
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="primary-button"
|
||||
VerticalAlignment="Stretch"
|
||||
Command="{Binding SendMessageCommand}"
|
||||
Content="{Binding ComposerActionText}" />
|
||||
|
||||
<Border Grid.Row="1" Grid.Column="1" Classes="status-chip" HorizontalAlignment="Right">
|
||||
<TextBlock Text="{Binding ComposerCounterText}" Classes="caption" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
87
src/PhysOn.Desktop/Views/MainWindow.axaml.cs
Normal file
87
src/PhysOn.Desktop/Views/MainWindow.axaml.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System.Collections.Specialized;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using PhysOn.Desktop.ViewModels;
|
||||
|
||||
namespace PhysOn.Desktop.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private MainWindowViewModel? _boundViewModel;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private async void ComposerTextBox_OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter && !e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
if (DataContext is MainWindowViewModel viewModel)
|
||||
{
|
||||
await viewModel.SendMessageFromShortcutAsync();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
|
||||
if (e.Key == Key.O &&
|
||||
e.KeyModifiers.HasFlag(KeyModifiers.Control) &&
|
||||
e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
|
||||
DataContext is MainWindowViewModel viewModel)
|
||||
{
|
||||
_ = viewModel.OpenDetachedConversationFromShortcutAsync();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
}
|
||||
|
||||
_boundViewModel = DataContext as MainWindowViewModel;
|
||||
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged += Messages_OnCollectionChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void Messages_OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action is NotifyCollectionChangedAction.Add or NotifyCollectionChangedAction.Reset or NotifyCollectionChangedAction.Replace)
|
||||
{
|
||||
Dispatcher.UIThread.Post(ScrollMessagesToEnd, DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
if (_boundViewModel is not null)
|
||||
{
|
||||
_boundViewModel.Messages.CollectionChanged -= Messages_OnCollectionChanged;
|
||||
_ = _boundViewModel.DisposeAsync();
|
||||
}
|
||||
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
private void ScrollMessagesToEnd()
|
||||
{
|
||||
if (this.FindControl<ScrollViewer>("MessagesScrollViewer") is { } scrollViewer)
|
||||
{
|
||||
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, scrollViewer.Extent.Height);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/PhysOn.Desktop/app.manifest
Normal file
18
src/PhysOn.Desktop/app.manifest
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="PhysOn.Desktop.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
Loading…
Add table
Add a link
Reference in a new issue