mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 00:46:11 +02:00
Basic echo implementation
This commit is contained in:
+9
-2
@@ -9,6 +9,8 @@ namespace Decho;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private MainWindowViewModel? _viewModel;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
@@ -18,13 +20,18 @@ public partial class App : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
_viewModel = new MainWindowViewModel();
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
DataContext = _viewModel,
|
||||
};
|
||||
|
||||
_viewModel.SetMainWindow(desktop.MainWindow);
|
||||
|
||||
desktop.Exit += (_, _) => _viewModel.Dispose();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
@@ -25,5 +26,15 @@
|
||||
<PackageReference Include="Projektanker.Icons.Avalonia.FontAwesome" Version="9.6.2" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="11.3.8" />
|
||||
<PackageReference Include="Romzetron.Avalonia" Version="11.3.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\EchoHub\src\EchoHub.Client\EchoHub.Client.csproj" />
|
||||
<ProjectReference Include="..\..\EchoHub\src\EchoHub.Core\EchoHub.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,16 +4,27 @@ namespace Decho.Models;
|
||||
|
||||
public sealed class ChannelModel
|
||||
{
|
||||
public ChannelModel(string id, string name, ObservableCollection<MessageModel> messages)
|
||||
public ChannelModel(
|
||||
string id,
|
||||
string name,
|
||||
ObservableCollection<MessageModel> messages,
|
||||
string? topic = null,
|
||||
bool isPublic = true)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Messages = messages;
|
||||
Topic = topic;
|
||||
IsPublic = isPublic;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string? Topic { get; set; }
|
||||
|
||||
public bool IsPublic { get; }
|
||||
|
||||
public ObservableCollection<MessageModel> Messages { get; }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
using System;
|
||||
using EchoHub.Core.Models;
|
||||
|
||||
namespace Decho.Models;
|
||||
|
||||
public sealed class MessageModel
|
||||
{
|
||||
public MessageModel(string id, UserModel author, DateTimeOffset sentAt, string content)
|
||||
public MessageModel(
|
||||
string id,
|
||||
UserModel author,
|
||||
DateTimeOffset sentAt,
|
||||
string content,
|
||||
string channelName,
|
||||
string? serverUrl = null,
|
||||
MessageType type = MessageType.Text,
|
||||
string? attachmentUrl = null,
|
||||
string? attachmentFileName = null,
|
||||
long? attachmentFileSize = null)
|
||||
{
|
||||
Id = id;
|
||||
Author = author;
|
||||
SentAt = sentAt;
|
||||
Content = content;
|
||||
ChannelName = channelName;
|
||||
ServerUrl = serverUrl;
|
||||
Type = type;
|
||||
AttachmentUrl = attachmentUrl;
|
||||
AttachmentFileName = attachmentFileName;
|
||||
AttachmentFileSize = attachmentFileSize;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
@@ -19,4 +35,16 @@ public sealed class MessageModel
|
||||
public DateTimeOffset SentAt { get; }
|
||||
|
||||
public string Content { get; }
|
||||
|
||||
public string ChannelName { get; }
|
||||
|
||||
public string? ServerUrl { get; }
|
||||
|
||||
public MessageType Type { get; }
|
||||
|
||||
public string? AttachmentUrl { get; }
|
||||
|
||||
public string? AttachmentFileName { get; }
|
||||
|
||||
public long? AttachmentFileSize { get; }
|
||||
}
|
||||
|
||||
@@ -4,16 +4,35 @@ namespace Decho.Models;
|
||||
|
||||
public sealed class ServerModel
|
||||
{
|
||||
public ServerModel(string id, string name, ObservableCollection<ChannelModel> channels)
|
||||
public ServerModel(
|
||||
string id,
|
||||
string name,
|
||||
ObservableCollection<ChannelModel> channels,
|
||||
string serverUrl = "",
|
||||
bool isConnected = false,
|
||||
bool isConnecting = false,
|
||||
string? connectedUser = null)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Channels = channels;
|
||||
ServerUrl = serverUrl;
|
||||
IsConnected = isConnected;
|
||||
IsConnecting = isConnecting;
|
||||
ConnectedUser = connectedUser;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string ServerUrl { get; set; }
|
||||
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
public bool IsConnecting { get; set; }
|
||||
|
||||
public string? ConnectedUser { get; set; }
|
||||
|
||||
public ObservableCollection<ChannelModel> Channels { get; }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
using EchoHub.Core.Models;
|
||||
|
||||
namespace Decho.Models;
|
||||
|
||||
public sealed class UserModel
|
||||
{
|
||||
public UserModel(string id, string displayName)
|
||||
public UserModel(
|
||||
string id,
|
||||
string displayName,
|
||||
string? nicknameColor = null,
|
||||
UserStatus status = UserStatus.Online,
|
||||
string? statusMessage = null)
|
||||
{
|
||||
Id = id;
|
||||
DisplayName = displayName;
|
||||
NicknameColor = nicknameColor;
|
||||
Status = status;
|
||||
StatusMessage = statusMessage;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? NicknameColor { get; set; }
|
||||
|
||||
public UserStatus Status { get; set; }
|
||||
|
||||
public string? StatusMessage { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using EchoHub.Client.Config;
|
||||
using EchoHub.Client.Services;
|
||||
using EchoHub.Client.Commands;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using Decho.Models;
|
||||
|
||||
namespace Decho.Services;
|
||||
|
||||
public sealed class ConnectionService : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, ServerConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public event Action<ServerModel>? ServerAdded;
|
||||
public event Action<string>? ServerRemoved;
|
||||
public event Action<ServerModel>? ServerStateChanged;
|
||||
public event Action<string, ChannelModel>? ChannelAdded;
|
||||
public event Action<string, string>? ChannelRemoved;
|
||||
public event Action<string, MessageModel>? MessageReceived;
|
||||
public event Action<string, string, string?>? UserJoined;
|
||||
public event Action<string, string>? UserLeft;
|
||||
public event Action<string, string>? ErrorOccurred;
|
||||
|
||||
internal IReadOnlyDictionary<string, ServerConnection> Connections => _connections;
|
||||
|
||||
internal ServerConnection? GetConnection(string serverUrl) =>
|
||||
_connections.TryGetValue(serverUrl, out var conn) ? conn : null;
|
||||
|
||||
public async Task<ServerModel> ConnectAsync(string serverUrl, string username, string password, bool isRegister, bool rememberMe)
|
||||
{
|
||||
var conn = new ConnectionManager();
|
||||
|
||||
var dialogResult = new EchoHub.Client.UI.Dialogs.ConnectDialogResult(
|
||||
serverUrl, username, password, isRegister, rememberMe, null);
|
||||
|
||||
ConnectResult result;
|
||||
try
|
||||
{
|
||||
result = await conn.ConnectAsync(dialogResult, _ => { });
|
||||
}
|
||||
catch
|
||||
{
|
||||
await conn.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var login = result.Login;
|
||||
var userModel = new UserModel(login.Username, login.DisplayName ?? login.Username,
|
||||
login.NicknameColor);
|
||||
|
||||
var channels = new ObservableCollection<ChannelModel>();
|
||||
var serverModel = new ServerModel(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
new Uri(serverUrl).Host,
|
||||
channels,
|
||||
serverUrl,
|
||||
isConnected: true,
|
||||
connectedUser: login.Username);
|
||||
|
||||
var serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel);
|
||||
|
||||
foreach (var ch in result.Channels)
|
||||
{
|
||||
var channelModel = ChannelModelFromDto(ch);
|
||||
channels.Add(channelModel);
|
||||
}
|
||||
|
||||
WireConnectionEvents(serverEntry, conn);
|
||||
|
||||
_connections[serverUrl] = serverEntry;
|
||||
SaveRefreshToken(serverUrl, rememberMe);
|
||||
ServerAdded?.Invoke(serverModel);
|
||||
|
||||
return serverModel;
|
||||
}
|
||||
|
||||
public async Task ConnectWithSavedTokenAsync(string serverUrl, string username, string refreshToken, bool rememberMe)
|
||||
{
|
||||
var conn = new ConnectionManager();
|
||||
|
||||
var dialogResult = new EchoHub.Client.UI.Dialogs.ConnectDialogResult(
|
||||
serverUrl, username, "", false, rememberMe, refreshToken);
|
||||
|
||||
ConnectResult result;
|
||||
try
|
||||
{
|
||||
result = await conn.ConnectAsync(dialogResult, _ => { });
|
||||
}
|
||||
catch
|
||||
{
|
||||
await conn.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
var login = result.Login;
|
||||
var userModel = new UserModel(login.Username, login.DisplayName ?? login.Username, login.NicknameColor);
|
||||
|
||||
var channels = new ObservableCollection<ChannelModel>();
|
||||
var serverModel = new ServerModel(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
new Uri(serverUrl).Host,
|
||||
channels,
|
||||
serverUrl,
|
||||
isConnected: true,
|
||||
connectedUser: login.Username);
|
||||
|
||||
var serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel);
|
||||
|
||||
foreach (var ch in result.Channels)
|
||||
{
|
||||
var channelModel = ChannelModelFromDto(ch);
|
||||
channels.Add(channelModel);
|
||||
}
|
||||
|
||||
WireConnectionEvents(serverEntry, conn);
|
||||
|
||||
_connections[serverUrl] = serverEntry;
|
||||
SaveRefreshToken(serverUrl, rememberMe);
|
||||
ServerAdded?.Invoke(serverModel);
|
||||
}
|
||||
|
||||
private void SaveRefreshToken(string serverUrl, bool rememberMe)
|
||||
{
|
||||
if (!rememberMe) return;
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry)) return;
|
||||
var token = entry.ApiClient.RefreshToken;
|
||||
if (string.IsNullOrEmpty(token)) return;
|
||||
|
||||
var config = ConfigManager.Load();
|
||||
var saved = config.SavedServers.FirstOrDefault(s =>
|
||||
string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase));
|
||||
if (saved is null)
|
||||
{
|
||||
saved = new SavedServer
|
||||
{
|
||||
Name = new Uri(serverUrl).Host,
|
||||
Url = serverUrl,
|
||||
Username = entry.User.Id,
|
||||
RememberMe = true,
|
||||
LastConnected = DateTimeOffset.Now,
|
||||
};
|
||||
config.SavedServers.Add(saved);
|
||||
}
|
||||
|
||||
saved.RefreshToken = token;
|
||||
saved.LastConnected = DateTimeOffset.Now;
|
||||
ConfigManager.Save(config);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(string serverUrl)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
|
||||
entry.Server.IsConnected = false;
|
||||
entry.Server.IsConnecting = false;
|
||||
ServerStateChanged?.Invoke(entry.Server);
|
||||
|
||||
await entry.Manager.CleanupAsync();
|
||||
entry.ApiClient.Dispose();
|
||||
await entry.Manager.DisposeAsync();
|
||||
|
||||
_connections.Remove(serverUrl);
|
||||
ServerRemoved?.Invoke(serverUrl);
|
||||
}
|
||||
|
||||
public async Task SendMessageAsync(string serverUrl, string channelName, string content)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
throw new InvalidOperationException("Not connected to server");
|
||||
|
||||
await entry.Manager.SendMessageAsync(channelName, content);
|
||||
}
|
||||
|
||||
public async Task<List<MessageModel>> JoinChannelAsync(string serverUrl, string channelName)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
throw new InvalidOperationException("Not connected to server");
|
||||
|
||||
if (entry.Manager.TrackChannel(channelName))
|
||||
{
|
||||
var history = await entry.Manager.JoinChannelAsync(channelName);
|
||||
return history.Select(m => MessageModelFromDto(m, entry)).ToList();
|
||||
}
|
||||
|
||||
var existing = await entry.Manager.GetHistoryAsync(channelName);
|
||||
return existing.Select(m => MessageModelFromDto(m, entry)).ToList();
|
||||
}
|
||||
|
||||
public async Task LeaveChannelAsync(string serverUrl, string channelName)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.Manager.LeaveChannelAsync(channelName);
|
||||
}
|
||||
|
||||
public async Task<List<MessageModel>> GetHistoryAsync(string serverUrl, string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return [];
|
||||
|
||||
var history = await entry.Manager.GetHistoryAsync(channelName, count, offset);
|
||||
return history.Select(m => MessageModelFromDto(m, entry)).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string serverUrl, string channelName)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return [];
|
||||
return await entry.Manager.GetOnlineUsersAsync(channelName);
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(string serverUrl, UserStatus status, string? statusMessage)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.Manager.UpdateStatusAsync(status, statusMessage);
|
||||
entry.User.Status = status;
|
||||
entry.User.StatusMessage = statusMessage;
|
||||
}
|
||||
|
||||
public async Task<ChannelDto?> CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return null;
|
||||
return await entry.ApiClient.CreateChannelAsync(name, topic, isPublic);
|
||||
}
|
||||
|
||||
public async Task DeleteChannelAsync(string serverUrl, string channelName)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.DeleteChannelAsync(channelName);
|
||||
entry.Manager.UntrackChannel(channelName);
|
||||
}
|
||||
|
||||
public async Task KickUserAsync(string serverUrl, string username, string? reason)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.KickUserAsync(username, reason);
|
||||
}
|
||||
|
||||
public async Task BanUserAsync(string serverUrl, string username, string? reason)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.BanUserAsync(username, reason);
|
||||
}
|
||||
|
||||
public async Task UnbanUserAsync(string serverUrl, string username)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.UnbanUserAsync(username);
|
||||
}
|
||||
|
||||
public async Task MuteUserAsync(string serverUrl, string username, int? durationMinutes)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.MuteUserAsync(username, durationMinutes);
|
||||
}
|
||||
|
||||
public async Task UnmuteUserAsync(string serverUrl, string username)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.UnmuteUserAsync(username);
|
||||
}
|
||||
|
||||
public async Task AssignRoleAsync(string serverUrl, string username, ServerRole role)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.AssignRoleAsync(username, role);
|
||||
}
|
||||
|
||||
public async Task NukeChannelAsync(string serverUrl, string channelName)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.NukeChannelAsync(channelName);
|
||||
}
|
||||
|
||||
public async Task UpdateProfileAsync(string serverUrl, string? displayName, string? bio, string? nickColor)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.UpdateProfileAsync(new UpdateProfileRequest(displayName, bio, nickColor));
|
||||
}
|
||||
|
||||
public async Task SetAvatarAsync(string serverUrl, string target)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await AvatarHelper.UploadAsync(entry.ApiClient, target);
|
||||
}
|
||||
|
||||
public async Task SendUrlAsync(string serverUrl, string channelName, string url, string? size)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
await entry.ApiClient.SendUrlAsync(channelName, url, size);
|
||||
}
|
||||
|
||||
public async Task UploadFileAsync(string serverUrl, string channelName, string filePath, string? size)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
await entry.ApiClient.UploadFileAsync(channelName, stream, fileName, size);
|
||||
}
|
||||
|
||||
public void UpdateChannelTopic(string serverUrl, string channelName, string? topic)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
|
||||
var channel = entry.Server.Channels.FirstOrDefault(c =>
|
||||
string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
|
||||
if (channel is not null)
|
||||
channel.Topic = topic;
|
||||
}
|
||||
|
||||
public void AddChannelToList(string serverUrl, ChannelDto channelDto)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
|
||||
var model = ChannelModelFromDto(channelDto);
|
||||
entry.Server.Channels.Add(model);
|
||||
ChannelAdded?.Invoke(serverUrl, model);
|
||||
}
|
||||
|
||||
public void RemoveChannelFromList(string serverUrl, string channelName)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return;
|
||||
|
||||
var channel = entry.Server.Channels.FirstOrDefault(c =>
|
||||
string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
|
||||
if (channel is not null)
|
||||
{
|
||||
entry.Server.Channels.Remove(channel);
|
||||
ChannelRemoved?.Invoke(serverUrl, channelName);
|
||||
}
|
||||
}
|
||||
|
||||
public ClientConfig LoadConfig() => ConfigManager.Load();
|
||||
|
||||
public void SaveConfig(ClientConfig config) => ConfigManager.Save(config);
|
||||
|
||||
public void SaveServerToConfig(SavedServer server) => ConfigManager.SaveServer(server);
|
||||
|
||||
public void RemoveServerFromConfig(string url) => ConfigManager.RemoveServer(url);
|
||||
|
||||
public CommandHandler CreateCommandHandler() => new();
|
||||
|
||||
public bool IsCommand(string input) => input.StartsWith('/');
|
||||
|
||||
public string? GetRefreshToken(string serverUrl)
|
||||
{
|
||||
return _connections.TryGetValue(serverUrl, out var entry)
|
||||
? entry.ApiClient.RefreshToken
|
||||
: null;
|
||||
}
|
||||
|
||||
public async Task<string?> DownloadAttachmentAsync(string serverUrl, string relativeUrl, string fileName)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return await entry.ApiClient.DownloadFileToTempAsync(relativeUrl, fileName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]?> DownloadImageBytesAsync(string serverUrl, string relativeUrl)
|
||||
{
|
||||
if (!_connections.TryGetValue(serverUrl, out var entry))
|
||||
return null;
|
||||
return await entry.ApiClient.DownloadBytesAsync(relativeUrl);
|
||||
}
|
||||
|
||||
public string? GetCurrentUsername(string serverUrl)
|
||||
{
|
||||
return _connections.TryGetValue(serverUrl, out var entry)
|
||||
? entry.User.DisplayName
|
||||
: null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var (_, entry) in _connections)
|
||||
{
|
||||
entry.ApiClient.Dispose();
|
||||
}
|
||||
_connections.Clear();
|
||||
}
|
||||
|
||||
private void WireConnectionEvents(ServerConnection entry, ConnectionManager conn)
|
||||
{
|
||||
conn.MessageReceived += message =>
|
||||
{
|
||||
var msg = MessageModelFromDto(message, entry);
|
||||
MessageReceived?.Invoke(entry.Server.ServerUrl, msg);
|
||||
};
|
||||
|
||||
conn.UserJoined += (channelName, username, presence) =>
|
||||
{
|
||||
UserJoined?.Invoke(entry.Server.ServerUrl, channelName, username);
|
||||
};
|
||||
|
||||
conn.UserLeft += (channelName, username) =>
|
||||
{
|
||||
UserLeft?.Invoke(entry.Server.ServerUrl, channelName);
|
||||
};
|
||||
|
||||
conn.UserStatusChanged += presence =>
|
||||
{
|
||||
entry.User.Status = presence.Status;
|
||||
entry.User.StatusMessage = presence.StatusMessage;
|
||||
};
|
||||
|
||||
conn.ChannelUpdated += channel =>
|
||||
{
|
||||
var existing = entry.Server.Channels.FirstOrDefault(c =>
|
||||
string.Equals(c.Name, channel.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Topic = channel.Topic;
|
||||
}
|
||||
};
|
||||
|
||||
conn.ForceDisconnected += reason =>
|
||||
{
|
||||
entry.Server.IsConnected = false;
|
||||
ServerStateChanged?.Invoke(entry.Server);
|
||||
ErrorOccurred?.Invoke(entry.Server.ServerUrl, reason);
|
||||
};
|
||||
|
||||
conn.Error += error =>
|
||||
{
|
||||
ErrorOccurred?.Invoke(entry.Server.ServerUrl, error);
|
||||
};
|
||||
|
||||
conn.ConnectionStatusChanged += status =>
|
||||
{
|
||||
entry.Server.IsConnected = status == "Connected";
|
||||
entry.Server.IsConnecting = status is "Connecting..." or "Authenticating...";
|
||||
ServerStateChanged?.Invoke(entry.Server);
|
||||
};
|
||||
}
|
||||
|
||||
internal static ChannelModel ChannelModelFromDto(ChannelDto dto)
|
||||
{
|
||||
return new ChannelModel(
|
||||
dto.Id.ToString(),
|
||||
dto.Name,
|
||||
new ObservableCollection<MessageModel>(),
|
||||
dto.Topic,
|
||||
dto.IsPublic);
|
||||
}
|
||||
|
||||
internal static MessageModel MessageModelFromDto(MessageDto dto, ServerConnection entry)
|
||||
{
|
||||
var author = new UserModel(
|
||||
dto.SenderUsername,
|
||||
dto.SenderUsername,
|
||||
dto.SenderNicknameColor);
|
||||
|
||||
return new MessageModel(
|
||||
dto.Id.ToString("N"),
|
||||
author,
|
||||
dto.SentAt,
|
||||
dto.Content,
|
||||
dto.ChannelName,
|
||||
entry.Server.ServerUrl,
|
||||
dto.Type,
|
||||
dto.AttachmentUrl,
|
||||
dto.AttachmentFileName,
|
||||
dto.AttachmentFileSize);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ServerConnection
|
||||
{
|
||||
internal ConnectionManager Manager { get; }
|
||||
public ApiClient ApiClient { get; }
|
||||
public ServerModel Server { get; }
|
||||
public UserModel User { get; }
|
||||
|
||||
internal ServerConnection(ConnectionManager manager, ApiClient apiClient, ServerModel server, UserModel user)
|
||||
{
|
||||
Manager = manager;
|
||||
ApiClient = apiClient;
|
||||
Server = server;
|
||||
User = user;
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,40 @@ public sealed class ChannelViewModel : ViewModelBase
|
||||
|
||||
public string Name => Model.Name;
|
||||
|
||||
public string? Topic
|
||||
{
|
||||
get => Model.Topic;
|
||||
set
|
||||
{
|
||||
if (Model.Topic != value)
|
||||
{
|
||||
Model.Topic = value;
|
||||
this.RaisePropertyChanged();
|
||||
this.RaisePropertyChanged(nameof(HasTopic));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
|
||||
|
||||
public bool IsPublic => Model.IsPublic;
|
||||
|
||||
public ObservableCollection<MessageViewModel> Messages { get; }
|
||||
|
||||
public void ClearMessages()
|
||||
{
|
||||
Model.Messages.Clear();
|
||||
Messages.Clear();
|
||||
}
|
||||
|
||||
public void AddMessage(MessageModel message)
|
||||
{
|
||||
Model.Messages.Add(message);
|
||||
Messages.Add(new MessageViewModel(message));
|
||||
}
|
||||
}
|
||||
|
||||
public void AddMessageViewModel(MessageViewModel messageViewModel)
|
||||
{
|
||||
Messages.Add(messageViewModel);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using EchoHub.Client.Commands;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Decho.ViewModels;
|
||||
|
||||
@@ -6,10 +11,15 @@ public sealed class ChatViewModel : ViewModelBase
|
||||
{
|
||||
private ObservableCollection<MessageViewModel> _messages = new();
|
||||
private string _channelTitle = "Select a channel";
|
||||
private string? _channelTopic;
|
||||
private bool _hasTopic;
|
||||
private string _currentServerUrl = string.Empty;
|
||||
private string _currentChannelName = string.Empty;
|
||||
|
||||
public ChatViewModel()
|
||||
{
|
||||
Composer = new MessageComposerViewModel();
|
||||
Composer.CommandRequested += HandleCommandAsync;
|
||||
}
|
||||
|
||||
public ObservableCollection<MessageViewModel> Messages
|
||||
@@ -24,18 +34,76 @@ public sealed class ChatViewModel : ViewModelBase
|
||||
private set => this.RaiseAndSetIfChanged(ref _channelTitle, value);
|
||||
}
|
||||
|
||||
public string? ChannelTopic
|
||||
{
|
||||
get => _channelTopic;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref _channelTopic, value);
|
||||
this.RaisePropertyChanged(nameof(HasTopic));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasTopic
|
||||
{
|
||||
get => _hasTopic;
|
||||
private set => this.RaiseAndSetIfChanged(ref _hasTopic, value);
|
||||
}
|
||||
|
||||
public MessageComposerViewModel Composer { get; }
|
||||
|
||||
public void SetChannel(ChannelViewModel? channel)
|
||||
public string CurrentServerUrl => _currentServerUrl;
|
||||
public string CurrentChannelName => _currentChannelName;
|
||||
|
||||
public event Func<string, Task<string?>>? CommandRequested;
|
||||
|
||||
public void SetChannel(ChannelViewModel? channel, string serverUrl = "")
|
||||
{
|
||||
if (channel is null)
|
||||
{
|
||||
Messages = new ObservableCollection<MessageViewModel>();
|
||||
ChannelTitle = "Select a channel";
|
||||
ChannelTopic = null;
|
||||
HasTopic = false;
|
||||
_currentChannelName = string.Empty;
|
||||
_currentServerUrl = string.Empty;
|
||||
Composer.SetServer(string.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
Messages = channel.Messages;
|
||||
ChannelTitle = "#" + channel.Name;
|
||||
ChannelTopic = channel.Topic;
|
||||
HasTopic = channel.HasTopic;
|
||||
_currentChannelName = channel.Name;
|
||||
_currentServerUrl = serverUrl;
|
||||
Composer.SetServer(serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComposerCommandHandler(CommandHandler handler)
|
||||
{
|
||||
Composer.SetCommandHandler(handler);
|
||||
}
|
||||
|
||||
public void AddMessage(MessageViewModel message)
|
||||
{
|
||||
Messages.Add(message);
|
||||
}
|
||||
|
||||
public void ClearMessages()
|
||||
{
|
||||
Messages = new ObservableCollection<MessageViewModel>();
|
||||
ChannelTitle = "Select a channel";
|
||||
ChannelTopic = null;
|
||||
HasTopic = false;
|
||||
}
|
||||
|
||||
private async Task<string?> HandleCommandAsync(string commandText)
|
||||
{
|
||||
if (CommandRequested is not null)
|
||||
{
|
||||
return await CommandRequested(commandText);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reactive;
|
||||
using System.Reactive.Concurrency;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
|
||||
using Decho.Models;
|
||||
using Decho.Services;
|
||||
|
||||
using EchoHub.Client.Commands;
|
||||
using EchoHub.Client.Config;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Decho.ViewModels;
|
||||
|
||||
public sealed class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
private readonly UserModel _currentUser;
|
||||
private readonly ConnectionService _connectionService;
|
||||
private readonly CommandHandler _commandHandler;
|
||||
private string _statusText = "Ready";
|
||||
private bool _isConnected;
|
||||
private Window? _mainWindow;
|
||||
|
||||
public string Title { get; } = "Decho";
|
||||
|
||||
@@ -18,82 +37,842 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
|
||||
public ChatViewModel Chat { get; }
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get => _statusText;
|
||||
set => this.RaiseAndSetIfChanged(ref _statusText, value);
|
||||
}
|
||||
|
||||
public bool IsConnected
|
||||
{
|
||||
get => _isConnected;
|
||||
set => this.RaiseAndSetIfChanged(ref _isConnected, value);
|
||||
}
|
||||
|
||||
public ConnectionService ConnectionService => _connectionService;
|
||||
|
||||
public ReactiveCommand<Unit, Unit> AddServerCommand { get; }
|
||||
|
||||
public MainWindowViewModel()
|
||||
{
|
||||
_currentUser = new UserModel("user-1", "You");
|
||||
_connectionService = new ConnectionService();
|
||||
_commandHandler = _connectionService.CreateCommandHandler();
|
||||
|
||||
var servers = SeedServers();
|
||||
Sidebar = new SidebarViewModel(servers);
|
||||
Sidebar = new SidebarViewModel();
|
||||
Chat = new ChatViewModel();
|
||||
|
||||
Sidebar.WhenAnyValue(x => x.SelectedChannel)
|
||||
.Subscribe(channel => Chat.SetChannel(channel));
|
||||
AddServerCommand = ReactiveCommand.Create(AddServer);
|
||||
|
||||
Chat.Composer.SendRequested += HandleSendRequested;
|
||||
Chat.Composer.CommandRequested += HandleCommandAsync;
|
||||
Chat.Composer.FileUploadRequested += HandleFileUploadRequested;
|
||||
|
||||
if (Sidebar.SelectedChannel is not null)
|
||||
Chat.SetChannel(Sidebar.SelectedChannel);
|
||||
WireCommandHandlerEvents();
|
||||
WireConnectionServiceEvents();
|
||||
|
||||
Sidebar.WhenAnyValue(x => x.SelectedChannel)
|
||||
.Subscribe(channel => HandleChannelSelected(channel));
|
||||
|
||||
_ = InitializeSavedServersAsync();
|
||||
}
|
||||
|
||||
private void HandleSendRequested(string text)
|
||||
public void SetMainWindow(Window window)
|
||||
{
|
||||
if (Sidebar.SelectedChannel is null)
|
||||
_mainWindow = window;
|
||||
}
|
||||
|
||||
private void AddServer()
|
||||
{
|
||||
var placeholderServer = new ServerModel(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"New Server",
|
||||
new ObservableCollection<ChannelModel>(),
|
||||
isConnected: false);
|
||||
|
||||
var serverVm = new ServerViewModel(placeholderServer);
|
||||
serverVm.ConnectRequested += async () => await HandleServerConnectRequested(serverVm);
|
||||
serverVm.DisconnectRequested += async () => await HandleServerDisconnectRequested(serverVm);
|
||||
|
||||
Sidebar.Servers.Add(serverVm);
|
||||
StatusText = "Click Connect on the server to get started";
|
||||
}
|
||||
|
||||
private void WireCommandHandlerEvents()
|
||||
{
|
||||
_commandHandler.OnSetStatus += async (status, message) =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
await _connectionService.UpdateStatusAsync(serverUrl, status, message);
|
||||
};
|
||||
|
||||
_commandHandler.OnSetTheme += themeName =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_commandHandler.OnJoinChannel += async channelName =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
|
||||
var channel = await _connectionService.JoinChannelAsync(serverUrl, channelName);
|
||||
EnsureChannelInList(serverUrl, channelName);
|
||||
var channelModel = FindChannel(serverUrl, channelName);
|
||||
if (channelModel is not null)
|
||||
{
|
||||
var channelVm = Sidebar.GetServer(serverUrl)?.Channels
|
||||
.FirstOrDefault(c => c.Name == channelName);
|
||||
if (channelVm is not null)
|
||||
{
|
||||
foreach (var msg in channel)
|
||||
channelVm.AddMessage(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_commandHandler.OnLeaveChannel += async () =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
var channel = Chat.CurrentChannelName;
|
||||
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
if (channel == HubConstants.DefaultChannel) return;
|
||||
await _connectionService.LeaveChannelAsync(serverUrl, channel);
|
||||
};
|
||||
|
||||
_commandHandler.OnListUsers += async () =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
var channel = Chat.CurrentChannelName;
|
||||
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
var users = await _connectionService.GetOnlineUsersAsync(serverUrl, channel);
|
||||
var userList = string.Join(", ", users.Select(u => u.DisplayName ?? u.Username));
|
||||
StatusText = $"Online in #{channel}: {userList}";
|
||||
};
|
||||
|
||||
_commandHandler.OnSetTopic += async topic =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
var channel = Chat.CurrentChannelName;
|
||||
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
await _connectionService.UpdateProfileAsync(serverUrl, null, null, null);
|
||||
_connectionService.UpdateChannelTopic(serverUrl, channel, topic);
|
||||
Chat.ChannelTopic = topic;
|
||||
};
|
||||
|
||||
_commandHandler.OnKickUser += async (username, reason) =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
await _connectionService.KickUserAsync(serverUrl, username, reason);
|
||||
};
|
||||
|
||||
_commandHandler.OnBanUser += async (username, reason) =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
await _connectionService.BanUserAsync(serverUrl, username, reason);
|
||||
};
|
||||
|
||||
_commandHandler.OnUnbanUser += async username =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
await _connectionService.UnbanUserAsync(serverUrl, username);
|
||||
};
|
||||
|
||||
_commandHandler.OnMuteUser += async (username, duration) =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
await _connectionService.MuteUserAsync(serverUrl, username, duration);
|
||||
};
|
||||
|
||||
_commandHandler.OnUnmuteUser += async username =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
await _connectionService.UnmuteUserAsync(serverUrl, username);
|
||||
};
|
||||
|
||||
_commandHandler.OnAssignRole += async (username, roleStr) =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
|
||||
var role = roleStr.ToLowerInvariant() switch
|
||||
{
|
||||
"admin" => ServerRole.Admin,
|
||||
"mod" => ServerRole.Mod,
|
||||
_ => ServerRole.Member,
|
||||
};
|
||||
await _connectionService.AssignRoleAsync(serverUrl, username, role);
|
||||
};
|
||||
|
||||
_commandHandler.OnNukeChannel += async () =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
var channel = Chat.CurrentChannelName;
|
||||
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return;
|
||||
await _connectionService.NukeChannelAsync(serverUrl, channel);
|
||||
};
|
||||
|
||||
_commandHandler.OnTestSound += () => Task.CompletedTask;
|
||||
|
||||
_commandHandler.OnQuit += () =>
|
||||
{
|
||||
if (_mainWindow is not null)
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => _mainWindow.Close());
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_commandHandler.OnHelp += () => Task.CompletedTask;
|
||||
|
||||
_commandHandler.OnSendFile += async (target, size) =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
var channel = Chat.CurrentChannelName;
|
||||
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||
{
|
||||
await _connectionService.SendUrlAsync(serverUrl, channel, target, size);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _connectionService.UploadFileAsync(serverUrl, channel, target, size);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText = $"Send failed: {ex.Message}";
|
||||
}
|
||||
};
|
||||
|
||||
_commandHandler.OnSetNick += async displayName =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
await _connectionService.UpdateProfileAsync(serverUrl, displayName, null, null);
|
||||
};
|
||||
|
||||
_commandHandler.OnSetColor += async color =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
await _connectionService.UpdateProfileAsync(serverUrl, null, null, color);
|
||||
};
|
||||
|
||||
_commandHandler.OnSetAvatar += async target =>
|
||||
{
|
||||
var serverUrl = GetCurrentServerUrl();
|
||||
if (string.IsNullOrEmpty(serverUrl)) return;
|
||||
await _connectionService.SetAvatarAsync(serverUrl, target);
|
||||
};
|
||||
|
||||
_commandHandler.OnOpenProfile += async username =>
|
||||
{
|
||||
StatusText = $"Profile: {username ?? "self"}";
|
||||
};
|
||||
|
||||
_commandHandler.OnOpenServers += () =>
|
||||
{
|
||||
var config = _connectionService.LoadConfig();
|
||||
var servers = string.Join("\n", config.SavedServers.Select(s =>
|
||||
$"{s.Name} ({s.Url}) - {s.Username ?? "?"}"));
|
||||
StatusText = servers;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
private void WireConnectionServiceEvents()
|
||||
{
|
||||
_connectionService.ServerAdded += server =>
|
||||
{
|
||||
var serverVm = new ServerViewModel(server);
|
||||
serverVm.ConnectRequested += () => HandleServerConnectRequested(serverVm);
|
||||
serverVm.DisconnectRequested += () => HandleServerDisconnectRequested(serverVm);
|
||||
serverVm.WhenAnyValue(s => s.SelectedChannel)
|
||||
.Where(channel => channel is not null)
|
||||
.Subscribe(channel => Sidebar.SelectedChannel = channel!);
|
||||
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var existing = Sidebar.GetServer(server.ServerUrl);
|
||||
if (existing is not null)
|
||||
Sidebar.Servers.Remove(existing);
|
||||
Sidebar.Servers.Add(serverVm);
|
||||
StatusText = $"Connected to {server.Name}";
|
||||
IsConnected = true;
|
||||
});
|
||||
};
|
||||
|
||||
_connectionService.ServerRemoved += serverUrl =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Sidebar.RemoveServer(serverUrl);
|
||||
if (Sidebar.Servers.Count == 0)
|
||||
{
|
||||
IsConnected = false;
|
||||
Chat.ClearMessages();
|
||||
StatusText = "Ready";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_connectionService.ServerStateChanged += server =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var serverVm = Sidebar.GetServer(server.ServerUrl);
|
||||
if (serverVm is not null)
|
||||
{
|
||||
serverVm.SyncFromModel();
|
||||
if (!server.IsConnected)
|
||||
StatusText = $"Disconnected from {server.Name}";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_connectionService.MessageReceived += (serverUrl, message) =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var channelVm = FindChannelViewModel(serverUrl, message.ChannelName);
|
||||
if (channelVm is not null)
|
||||
{
|
||||
channelVm.AddMessage(message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_connectionService.ChannelAdded += (serverUrl, channel) =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
EnsureChannelInList(serverUrl, channel.Name);
|
||||
});
|
||||
};
|
||||
|
||||
_connectionService.ErrorOccurred += (serverUrl, error) =>
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
StatusText = $"Error: {error}";
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> HandleCommandAsync(string commandText)
|
||||
{
|
||||
var result = await _commandHandler.HandleAsync(commandText);
|
||||
if (result.Message is not null && !result.IsError)
|
||||
{
|
||||
Chat.AddMessage(new MessageViewModel(new MessageModel(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
new UserModel("system", "System"),
|
||||
DateTimeOffset.Now,
|
||||
result.Message,
|
||||
Chat.CurrentChannelName,
|
||||
Chat.CurrentServerUrl)));
|
||||
}
|
||||
return result.Message;
|
||||
}
|
||||
|
||||
private void HandleSendRequested(string serverUrl, string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Chat.CurrentChannelName))
|
||||
return;
|
||||
|
||||
var message = new MessageModel(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
_currentUser,
|
||||
DateTimeOffset.Now,
|
||||
text);
|
||||
if (_commandHandler.IsCommand(text))
|
||||
{
|
||||
_ = HandleCommandAsync(text);
|
||||
return;
|
||||
}
|
||||
|
||||
Sidebar.SelectedChannel.AddMessage(message);
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
StatusText = $"Send failed: {ex.Message}";
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ServerModel> SeedServers()
|
||||
private void HandleFileUploadRequested(string serverUrl, string filePath)
|
||||
{
|
||||
var alex = new UserModel("user-2", "Alex");
|
||||
var sam = new UserModel("user-3", "Sam");
|
||||
if (string.IsNullOrEmpty(Chat.CurrentChannelName)) return;
|
||||
|
||||
var general = new ChannelModel(
|
||||
"channel-1",
|
||||
"general",
|
||||
new ObservableCollection<MessageModel>
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
new("message-1", alex, DateTimeOffset.Now.AddMinutes(-30), "This is a message."),
|
||||
new("message-2", sam, DateTimeOffset.Now.AddMinutes(-25), "Another message for the channel."),
|
||||
new("message-3", alex, DateTimeOffset.Now.AddMinutes(-10), "We should test the \nnew layout."),
|
||||
await _connectionService.UploadFileAsync(serverUrl, Chat.CurrentChannelName, filePath, null);
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
StatusText = "File uploaded");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
StatusText = $"Upload failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleChannelSelected(ChannelViewModel? channel)
|
||||
{
|
||||
if (channel is null)
|
||||
{
|
||||
Chat.SetChannel(null);
|
||||
return;
|
||||
}
|
||||
|
||||
var serverUrl = FindServerUrlForChannel(channel);
|
||||
Chat.SetChannel(channel, serverUrl);
|
||||
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
var commandHandler = _connectionService.CreateCommandHandler();
|
||||
Chat.SetComposerCommandHandler(commandHandler);
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var history = await _connectionService.JoinChannelAsync(serverUrl, channel.Name);
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
channel.ClearMessages();
|
||||
foreach (var msg in history)
|
||||
channel.AddMessage(msg);
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Channel might not be available
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeSavedServersAsync()
|
||||
{
|
||||
var config = _connectionService.LoadConfig();
|
||||
var connectTasks = new List<Task>();
|
||||
|
||||
foreach (var saved in config.SavedServers)
|
||||
{
|
||||
var placeholderServer = new ServerModel(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
saved.Name,
|
||||
new ObservableCollection<ChannelModel>(),
|
||||
saved.Url,
|
||||
isConnected: false);
|
||||
|
||||
var serverVm = new ServerViewModel(placeholderServer);
|
||||
serverVm.ConnectRequested += async () => await HandleServerConnectRequested(serverVm);
|
||||
serverVm.DisconnectRequested += async () => await HandleServerDisconnectRequested(serverVm);
|
||||
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Sidebar.Servers.Add(serverVm);
|
||||
});
|
||||
|
||||
var random = new ChannelModel(
|
||||
"channel-2",
|
||||
"random",
|
||||
new ObservableCollection<MessageModel>
|
||||
if (!string.IsNullOrEmpty(saved.RefreshToken) && saved.RememberMe)
|
||||
{
|
||||
new("message-4", sam, DateTimeOffset.Now.AddMinutes(-5), @"Random chat keeps the vibe light.
|
||||
rwerwerw
|
||||
rw
|
||||
er
|
||||
wer
|
||||
w
|
||||
r
|
||||
ztr5z56j7u5"),
|
||||
});
|
||||
connectTasks.Add(AutoConnectSavedServer(serverVm, saved));
|
||||
}
|
||||
}
|
||||
|
||||
var music = new ChannelModel(
|
||||
"channel-3",
|
||||
"music",
|
||||
new ObservableCollection<MessageModel>());
|
||||
await Task.WhenAll(connectTasks);
|
||||
}
|
||||
|
||||
var server = new ServerModel(
|
||||
"echo.voidcube.cloud",
|
||||
"echo.voidcube.cloud",
|
||||
new ObservableCollection<ChannelModel> { general, random });
|
||||
private async Task AutoConnectSavedServer(ServerViewModel serverVm, SavedServer saved)
|
||||
{
|
||||
if (string.IsNullOrEmpty(saved.Username) || string.IsNullOrEmpty(saved.RefreshToken))
|
||||
return;
|
||||
|
||||
var anotherServer = new ServerModel(
|
||||
"echo.stone-red.net",
|
||||
"echo.stone-red.net",
|
||||
new ObservableCollection<ChannelModel>() { general, music});
|
||||
try
|
||||
{
|
||||
serverVm.IsConnecting = true;
|
||||
await _connectionService.ConnectWithSavedTokenAsync(
|
||||
saved.Url, saved.Username, saved.RefreshToken, saved.RememberMe);
|
||||
}
|
||||
catch
|
||||
{
|
||||
serverVm.IsConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
return new[] { server, anotherServer };
|
||||
private static async Task ShowMessageBox(Window owner, string title, string message)
|
||||
{
|
||||
var msgBox = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 400,
|
||||
Height = 180,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
CanResize = false,
|
||||
};
|
||||
|
||||
var stack = new StackPanel { Spacing = 10, Margin = new Avalonia.Thickness(15) };
|
||||
stack.Children.Add(new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap });
|
||||
var okBtn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center };
|
||||
stack.Children.Add(okBtn);
|
||||
msgBox.Content = stack;
|
||||
|
||||
var tcs = new TaskCompletionSource();
|
||||
okBtn.Click += (_, _) => { msgBox.Close(); tcs.TrySetResult(); };
|
||||
msgBox.Closed += (_, _) => tcs.TrySetResult();
|
||||
|
||||
await msgBox.ShowDialog(owner);
|
||||
await tcs.Task;
|
||||
}
|
||||
|
||||
private async Task HandleServerConnectRequested(ServerViewModel serverVm)
|
||||
{
|
||||
// Show connect dialog
|
||||
if (_mainWindow is null) return;
|
||||
|
||||
var config = _connectionService.LoadConfig();
|
||||
var prefill = config.SavedServers.FirstOrDefault(s =>
|
||||
string.Equals(s.Url, serverVm.ServerUrl, StringComparison.OrdinalIgnoreCase));
|
||||
var dialog = new ConnectDialog(config.SavedServers, prefill);
|
||||
|
||||
var result = await dialog.ShowAsync(_mainWindow);
|
||||
if (result is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
serverVm.IsConnecting = true;
|
||||
StatusText = "Connecting...";
|
||||
|
||||
if (result.IsSavedSession && result.SavedRefreshToken is not null)
|
||||
{
|
||||
await _connectionService.ConnectWithSavedTokenAsync(
|
||||
result.ServerUrl, result.Username, result.SavedRefreshToken, result.RememberMe);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _connectionService.ConnectAsync(
|
||||
result.ServerUrl, result.Username, result.Password, result.IsRegister, result.RememberMe);
|
||||
}
|
||||
|
||||
// Remove the placeholder and let the real server added event handle it
|
||||
Sidebar.RemoveServer(serverVm.ServerUrl);
|
||||
|
||||
// Save to config with refresh token
|
||||
var refreshToken = _connectionService.GetRefreshToken(result.ServerUrl);
|
||||
var savedServer = new SavedServer
|
||||
{
|
||||
Name = new Uri(result.ServerUrl).Host,
|
||||
Url = result.ServerUrl,
|
||||
Username = result.Username,
|
||||
RefreshToken = result.RememberMe ? refreshToken : null,
|
||||
RememberMe = result.RememberMe,
|
||||
LastConnected = DateTimeOffset.Now,
|
||||
};
|
||||
_connectionService.SaveServerToConfig(savedServer);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText = $"Connection failed: {ex.Message}";
|
||||
serverVm.IsConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleServerDisconnectRequested(ServerViewModel serverVm)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connectionService.DisconnectAsync(serverVm.ServerUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText = $"Disconnect error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureChannelInList(string serverUrl, string channelName)
|
||||
{
|
||||
var serverVm = Sidebar.GetServer(serverUrl);
|
||||
if (serverVm is null) return;
|
||||
|
||||
if (!serverVm.Channels.Any(c => c.Name == channelName))
|
||||
{
|
||||
var channelModel = new ChannelModel(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
channelName,
|
||||
new System.Collections.ObjectModel.ObservableCollection<MessageModel>());
|
||||
var channelVm = new ChannelViewModel(channelModel);
|
||||
serverVm.Channels.Add(channelVm);
|
||||
}
|
||||
}
|
||||
|
||||
private ChannelModel? FindChannel(string serverUrl, string channelName)
|
||||
{
|
||||
var serverVm = Sidebar.GetServer(serverUrl);
|
||||
return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName)?.Model;
|
||||
}
|
||||
|
||||
private ChannelViewModel? FindChannelViewModel(string serverUrl, string channelName)
|
||||
{
|
||||
var serverVm = Sidebar.GetServer(serverUrl);
|
||||
return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName);
|
||||
}
|
||||
|
||||
private string FindServerUrlForChannel(ChannelViewModel channel)
|
||||
{
|
||||
foreach (var server in Sidebar.Servers)
|
||||
{
|
||||
if (server.Channels.Contains(channel))
|
||||
return server.ServerUrl;
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private string GetCurrentServerUrl()
|
||||
{
|
||||
return Chat.CurrentServerUrl;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connectionService.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ConnectDialogResult
|
||||
{
|
||||
public string ServerUrl { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public bool IsRegister { get; set; }
|
||||
public bool RememberMe { get; set; }
|
||||
public bool IsSavedSession { get; set; }
|
||||
public string? SavedRefreshToken { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ConnectDialog
|
||||
{
|
||||
private readonly List<SavedServer> _savedServers;
|
||||
private readonly SavedServer? _prefill;
|
||||
|
||||
public ConnectDialog(List<SavedServer> savedServers, SavedServer? prefill = null)
|
||||
{
|
||||
_savedServers = savedServers;
|
||||
_prefill = prefill;
|
||||
}
|
||||
|
||||
public async Task<ConnectDialogResult?> ShowAsync(Window owner)
|
||||
{
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Connect to Server",
|
||||
Width = 450,
|
||||
Height = 380,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
CanResize = false,
|
||||
};
|
||||
|
||||
var stack = new StackPanel { Spacing = 8, Margin = new Avalonia.Thickness(15) };
|
||||
|
||||
var urlLabel = new TextBlock { Text = "Server URL:" };
|
||||
var urlBox = new TextBox { Watermark = "http://localhost:5000", Text = "http://localhost:5000" };
|
||||
stack.Children.Add(urlLabel);
|
||||
stack.Children.Add(urlBox);
|
||||
|
||||
var userLabel = new TextBlock { Text = "Username:" };
|
||||
var userBox = new TextBox { Watermark = "username" };
|
||||
stack.Children.Add(userLabel);
|
||||
stack.Children.Add(userBox);
|
||||
|
||||
var passLabel = new TextBlock { Text = "Password:" };
|
||||
var passBox = new TextBox { Watermark = "password", PasswordChar = '*' };
|
||||
stack.Children.Add(passLabel);
|
||||
stack.Children.Add(passBox);
|
||||
|
||||
var rememberMe = new CheckBox { Content = "Remember me", IsChecked = true };
|
||||
stack.Children.Add(rememberMe);
|
||||
|
||||
var buttonPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Spacing = 10 };
|
||||
|
||||
var loginBtn = new Button { Content = "Login", Width = 100 };
|
||||
var registerBtn = new Button { Content = "Register", Width = 100 };
|
||||
var cancelBtn = new Button { Content = "Cancel", Width = 100 };
|
||||
buttonPanel.Children.Add(loginBtn);
|
||||
buttonPanel.Children.Add(registerBtn);
|
||||
buttonPanel.Children.Add(cancelBtn);
|
||||
stack.Children.Add(buttonPanel);
|
||||
|
||||
ListBox? serversList = null;
|
||||
if (_savedServers.Count > 0)
|
||||
{
|
||||
var sep = new TextBlock { Text = "--- Saved Servers ---", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center };
|
||||
stack.Children.Add(sep);
|
||||
|
||||
serversList = new ListBox { Height = 100 };
|
||||
serversList.ItemsSource = _savedServers.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"}").ToList();
|
||||
stack.Children.Add(serversList);
|
||||
|
||||
serversList.SelectionChanged += (_, _) =>
|
||||
{
|
||||
var idx = serversList.SelectedIndex;
|
||||
if (idx >= 0 && idx < _savedServers.Count)
|
||||
{
|
||||
var s = _savedServers[idx];
|
||||
urlBox.Text = s.Url;
|
||||
userBox.Text = s.Username ?? "";
|
||||
rememberMe.IsChecked = s.RememberMe;
|
||||
if (!string.IsNullOrEmpty(s.RefreshToken))
|
||||
passBox.Text = "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (_prefill is not null)
|
||||
{
|
||||
urlBox.Text = _prefill.Url;
|
||||
userBox.Text = _prefill.Username ?? "";
|
||||
var idx = _savedServers.FindIndex(s =>
|
||||
string.Equals(s.Url, _prefill.Url, StringComparison.OrdinalIgnoreCase));
|
||||
if (idx >= 0 && idx < _savedServers.Count && serversList is not null)
|
||||
serversList.SelectedIndex = idx;
|
||||
}
|
||||
|
||||
dialog.Content = new ScrollViewer { Content = stack };
|
||||
|
||||
var tcs = new TaskCompletionSource<ConnectDialogResult?>();
|
||||
ConnectDialogResult? result = null;
|
||||
|
||||
loginBtn.Click += async (_, _) =>
|
||||
{
|
||||
var url = urlBox.Text?.Trim() ?? "";
|
||||
var user = userBox.Text?.Trim() ?? "";
|
||||
var pass = passBox.Text ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user))
|
||||
{
|
||||
await ShowMessageBox(owner, "Validation", "Server URL and username are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for saved session
|
||||
var saved = _savedServers.FirstOrDefault(s =>
|
||||
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(s.Username, user, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrEmpty(s.RefreshToken));
|
||||
|
||||
if (string.IsNullOrEmpty(pass) && saved is not null)
|
||||
{
|
||||
result = new ConnectDialogResult
|
||||
{
|
||||
ServerUrl = url,
|
||||
Username = user,
|
||||
IsSavedSession = true,
|
||||
SavedRefreshToken = saved.RefreshToken,
|
||||
RememberMe = saved.RememberMe,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrEmpty(pass))
|
||||
{
|
||||
await ShowMessageBox(owner, "Validation", "Password is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
result = new ConnectDialogResult
|
||||
{
|
||||
ServerUrl = url,
|
||||
Username = user,
|
||||
Password = pass,
|
||||
IsRegister = false,
|
||||
RememberMe = rememberMe.IsChecked ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
dialog.Close();
|
||||
tcs.TrySetResult(result);
|
||||
};
|
||||
|
||||
registerBtn.Click += (_, _) =>
|
||||
{
|
||||
var url = urlBox.Text?.Trim() ?? "";
|
||||
var user = userBox.Text?.Trim() ?? "";
|
||||
var pass = passBox.Text ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
||||
{
|
||||
_ = ShowMessageBox(owner, "Validation", "Server URL, username, and password are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
result = new ConnectDialogResult
|
||||
{
|
||||
ServerUrl = url,
|
||||
Username = user,
|
||||
Password = pass,
|
||||
IsRegister = true,
|
||||
RememberMe = rememberMe.IsChecked ?? false,
|
||||
};
|
||||
|
||||
dialog.Close();
|
||||
tcs.TrySetResult(result);
|
||||
};
|
||||
|
||||
cancelBtn.Click += (_, _) =>
|
||||
{
|
||||
dialog.Close();
|
||||
tcs.TrySetResult(null);
|
||||
};
|
||||
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(result);
|
||||
|
||||
if (owner is not null)
|
||||
await dialog.ShowDialog(owner);
|
||||
else
|
||||
dialog.Show();
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
private static async Task ShowMessageBox(Window owner, string title, string message)
|
||||
{
|
||||
var msgBox = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 350,
|
||||
Height = 150,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
CanResize = false,
|
||||
};
|
||||
|
||||
var stack = new StackPanel { Spacing = 10, Margin = new Avalonia.Thickness(15) };
|
||||
stack.Children.Add(new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap });
|
||||
var okBtn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center };
|
||||
stack.Children.Add(okBtn);
|
||||
msgBox.Content = stack;
|
||||
|
||||
var tcs = new TaskCompletionSource();
|
||||
okBtn.Click += (_, _) => { msgBox.Close(); tcs.TrySetResult(); };
|
||||
msgBox.Closed += (_, _) => tcs.TrySetResult();
|
||||
|
||||
await msgBox.ShowDialog(owner);
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Reactive;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using EchoHub.Client.Commands;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Decho.ViewModels;
|
||||
@@ -9,6 +11,8 @@ namespace Decho.ViewModels;
|
||||
public sealed class MessageComposerViewModel : ViewModelBase
|
||||
{
|
||||
private string _draft = string.Empty;
|
||||
private CommandHandler? _commandHandler;
|
||||
private string _serverUrl = string.Empty;
|
||||
|
||||
public MessageComposerViewModel()
|
||||
{
|
||||
@@ -22,9 +26,34 @@ public sealed class MessageComposerViewModel : ViewModelBase
|
||||
set => this.RaiseAndSetIfChanged(ref _draft, value);
|
||||
}
|
||||
|
||||
public string ServerUrl => _serverUrl;
|
||||
|
||||
public ReactiveCommand<Unit, Unit> SendCommand { get; }
|
||||
|
||||
public event Action<string>? SendRequested;
|
||||
public bool HasCommandHandler => _commandHandler is not null;
|
||||
|
||||
public event Action<string, string>? SendRequested;
|
||||
public event Func<string, Task<string?>>? CommandRequested;
|
||||
public event Action<string, string>? FileUploadRequested;
|
||||
|
||||
public void RequestFileUpload(string filePath)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_serverUrl))
|
||||
FileUploadRequested?.Invoke(_serverUrl, filePath);
|
||||
}
|
||||
|
||||
public void SetServer(string serverUrl)
|
||||
{
|
||||
_serverUrl = serverUrl;
|
||||
}
|
||||
|
||||
public void SetCommandHandler(CommandHandler handler)
|
||||
{
|
||||
_commandHandler = handler;
|
||||
this.RaisePropertyChanged(nameof(HasCommandHandler));
|
||||
}
|
||||
|
||||
public bool IsCommand(string input) => _commandHandler?.IsCommand(input) ?? input.StartsWith('/');
|
||||
|
||||
private void Send()
|
||||
{
|
||||
@@ -33,6 +62,14 @@ public sealed class MessageComposerViewModel : ViewModelBase
|
||||
return;
|
||||
|
||||
Draft = string.Empty;
|
||||
SendRequested?.Invoke(text);
|
||||
|
||||
if (_commandHandler is not null && _commandHandler.IsCommand(text))
|
||||
{
|
||||
CommandRequested?.Invoke(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
SendRequested?.Invoke(_serverUrl, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Decho.Models;
|
||||
|
||||
using System;
|
||||
using Avalonia.Media;
|
||||
using EchoHub.Core.Models;
|
||||
using Decho.Models;
|
||||
|
||||
namespace Decho.ViewModels;
|
||||
|
||||
@@ -15,13 +16,41 @@ public sealed class MessageViewModel : ViewModelBase
|
||||
|
||||
public string AuthorName => Model.Author.DisplayName;
|
||||
|
||||
public IBrush? AuthorColor
|
||||
{
|
||||
get
|
||||
{
|
||||
var color = Model.Author.NicknameColor;
|
||||
if (string.IsNullOrEmpty(color)) return null;
|
||||
try { return new SolidColorBrush(Avalonia.Media.Color.Parse(color)); }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
public string Content => Model.Content;
|
||||
|
||||
public string? ServerUrl => Model.ServerUrl;
|
||||
|
||||
public MessageType Type => Model.Type;
|
||||
|
||||
public bool HasAttachment => Model.AttachmentUrl is not null;
|
||||
|
||||
public string? AttachmentFileName => Model.AttachmentFileName;
|
||||
|
||||
public string? AttachmentUrl => Model.AttachmentUrl;
|
||||
|
||||
public bool IsImage => Type == MessageType.Image;
|
||||
|
||||
public bool ShowContent => !IsImage && !IsFile;
|
||||
|
||||
public bool IsAudio => Type == MessageType.Audio;
|
||||
|
||||
public bool IsFile => Type == MessageType.File;
|
||||
|
||||
public string TimeText
|
||||
{
|
||||
get
|
||||
{
|
||||
// If today, show time only; otherwise, show date and time.
|
||||
var now = DateTimeOffset.Now;
|
||||
|
||||
if (Model.SentAt.Date == now.Date)
|
||||
@@ -29,7 +58,7 @@ public sealed class MessageViewModel : ViewModelBase
|
||||
return Model.SentAt.ToLocalTime().ToString("t");
|
||||
}
|
||||
|
||||
return now.ToString("g");
|
||||
return Model.SentAt.ToLocalTime().ToString("g");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Reactive;
|
||||
|
||||
using Decho.Models;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace Decho.ViewModels;
|
||||
|
||||
@@ -9,20 +11,49 @@ public sealed class ServerViewModel : ViewModelBase
|
||||
{
|
||||
private bool _isExpanded = true;
|
||||
private ChannelViewModel? _selectedChannel;
|
||||
private bool _isConnected;
|
||||
private bool _isConnecting;
|
||||
private string? _connectedUser;
|
||||
|
||||
public ServerViewModel(ServerModel model)
|
||||
{
|
||||
Model = model;
|
||||
Channels = new ObservableCollection<ChannelViewModel>(
|
||||
model.Channels.Select(channel => new ChannelViewModel(channel)));
|
||||
|
||||
_isConnected = model.IsConnected;
|
||||
_isConnecting = model.IsConnecting;
|
||||
_connectedUser = model.ConnectedUser;
|
||||
|
||||
ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync);
|
||||
DisconnectCommand = ReactiveCommand.CreateFromTask(DisconnectAsync);
|
||||
}
|
||||
|
||||
public event Func<Task>? ConnectRequested
|
||||
{
|
||||
add => _connectRequested = (Func<Task>?)Delegate.Combine(_connectRequested, value);
|
||||
remove => _connectRequested = (Func<Task>?)Delegate.Remove(_connectRequested, value);
|
||||
}
|
||||
private Func<Task>? _connectRequested;
|
||||
|
||||
public event Func<Task>? DisconnectRequested
|
||||
{
|
||||
add => _disconnectRequested = (Func<Task>?)Delegate.Combine(_disconnectRequested, value);
|
||||
remove => _disconnectRequested = (Func<Task>?)Delegate.Remove(_disconnectRequested, value);
|
||||
}
|
||||
private Func<Task>? _disconnectRequested;
|
||||
|
||||
public ServerModel Model { get; }
|
||||
|
||||
public string Name => Model.Name;
|
||||
|
||||
public string ServerUrl => Model.ServerUrl;
|
||||
|
||||
public ObservableCollection<ChannelViewModel> Channels { get; }
|
||||
|
||||
public ReactiveCommand<Unit, Unit> ConnectCommand { get; }
|
||||
public ReactiveCommand<Unit, Unit> DisconnectCommand { get; }
|
||||
|
||||
public ChannelViewModel? SelectedChannel
|
||||
{
|
||||
get => _selectedChannel;
|
||||
@@ -34,4 +65,60 @@ public sealed class ServerViewModel : ViewModelBase
|
||||
get => _isExpanded;
|
||||
set => this.RaiseAndSetIfChanged(ref _isExpanded, value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsConnected
|
||||
{
|
||||
get => _isConnected;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref _isConnected, value);
|
||||
this.RaisePropertyChanged(nameof(ConnectionStatusText));
|
||||
this.RaisePropertyChanged(nameof(ConnectionStatusColor));
|
||||
this.RaisePropertyChanged(nameof(ShowConnectionControls));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsConnecting
|
||||
{
|
||||
get => _isConnecting;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref _isConnecting, value);
|
||||
this.RaisePropertyChanged(nameof(ConnectionStatusText));
|
||||
this.RaisePropertyChanged(nameof(ShowConnectionControls));
|
||||
}
|
||||
}
|
||||
|
||||
public string? ConnectedUser
|
||||
{
|
||||
get => _connectedUser;
|
||||
set => this.RaiseAndSetIfChanged(ref _connectedUser, value);
|
||||
}
|
||||
|
||||
public string ConnectionStatusText => IsConnecting ? "Connecting..." :
|
||||
IsConnected ? $"Connected as {ConnectedUser}" : "Disconnected";
|
||||
|
||||
public string ConnectionStatusColor => IsConnected ? "Green" : IsConnecting ? "Orange" : "Gray";
|
||||
|
||||
public bool ShowConnectionControls => !IsConnected && !IsConnecting;
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
if (_connectRequested is not null)
|
||||
await _connectRequested();
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
if (_disconnectRequested is not null)
|
||||
await _disconnectRequested();
|
||||
}
|
||||
|
||||
public void SyncFromModel()
|
||||
{
|
||||
IsConnected = Model.IsConnected;
|
||||
IsConnecting = Model.IsConnecting;
|
||||
ConnectedUser = Model.ConnectedUser;
|
||||
IsExpanded = true;
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,9 @@ public sealed class SidebarViewModel : ViewModelBase
|
||||
{
|
||||
private ChannelViewModel? _selectedChannel;
|
||||
|
||||
public SidebarViewModel(IEnumerable<ServerModel> servers)
|
||||
public SidebarViewModel()
|
||||
{
|
||||
Servers = new ObservableCollection<ServerViewModel>(
|
||||
servers.Select(server => new ServerViewModel(server)));
|
||||
SelectedChannel = Servers.FirstOrDefault()?.Channels.FirstOrDefault();
|
||||
|
||||
foreach (var server in Servers)
|
||||
{
|
||||
server.WhenAnyValue(s => s.SelectedChannel)
|
||||
.Where(channel => channel is not null)
|
||||
.Subscribe(new Action<ChannelViewModel?>(channel => SelectedChannel = channel!));
|
||||
}
|
||||
Servers = new ObservableCollection<ServerViewModel>();
|
||||
}
|
||||
|
||||
public ObservableCollection<ServerViewModel> Servers { get; }
|
||||
@@ -56,4 +47,33 @@ public sealed class SidebarViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void AddServer(ServerModel model)
|
||||
{
|
||||
var vm = new ServerViewModel(model);
|
||||
vm.WhenAnyValue(s => s.SelectedChannel)
|
||||
.Where(channel => channel is not null)
|
||||
.Subscribe(channel => SelectedChannel = channel!);
|
||||
|
||||
Servers.Add(vm);
|
||||
}
|
||||
|
||||
public void RemoveServer(string serverUrl)
|
||||
{
|
||||
var server = Servers.FirstOrDefault(s =>
|
||||
string.Equals(s.ServerUrl, serverUrl, StringComparison.OrdinalIgnoreCase));
|
||||
if (server is not null)
|
||||
{
|
||||
if (SelectedChannel is not null && server.Channels.Contains(SelectedChannel))
|
||||
SelectedChannel = null;
|
||||
|
||||
Servers.Remove(server);
|
||||
}
|
||||
}
|
||||
|
||||
public ServerViewModel? GetServer(string serverUrl)
|
||||
{
|
||||
return Servers.FirstOrDefault(s =>
|
||||
string.Equals(s.ServerUrl, serverUrl, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,48 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Decho.ViewModels"
|
||||
xmlns:views="using:Decho.Views"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="Decho.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Title="Decho">
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Decho.ViewModels"
|
||||
xmlns:views="using:Decho.Views"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:i="https://github.com/projektanker/icons.avalonia"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="Decho.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Width="1100" Height="600"
|
||||
Title="Decho">
|
||||
|
||||
<Design.DataContext>
|
||||
<!-- This only sets the DataContext for the previewer in an IDE,
|
||||
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="Auto, *" ColumnDefinitions="Auto, *">
|
||||
<TextBlock FontSize="20"
|
||||
FontWeight="Bold"
|
||||
Margin="0 5"
|
||||
HorizontalAlignment="Center"
|
||||
Text="{Binding Title}" />
|
||||
<views:SidebarView Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
DataContext="{Binding Sidebar}"
|
||||
SelectedChannel="{Binding SelectedChannel, Mode=TwoWay}" />
|
||||
<views:ChatView Grid.RowSpan="2"
|
||||
Grid.Column="1"
|
||||
DataContext="{Binding Chat}" />
|
||||
</Grid>
|
||||
</Window>
|
||||
<Grid RowDefinitions="Auto, *" ColumnDefinitions="Auto, *">
|
||||
<DockPanel HorizontalAlignment="Stretch">
|
||||
<TextBlock FontSize="20"
|
||||
FontWeight="Bold"
|
||||
Margin="5"
|
||||
Text="{Binding Title}" />
|
||||
|
||||
<Button i:Attached.Icon="fa-cog"
|
||||
DockPanel.Dock="Right"
|
||||
Background="Transparent"
|
||||
Foreground="{DynamicResource UiTheme08}"
|
||||
HorizontalAlignment="Right" />
|
||||
|
||||
<Button i:Attached.Icon="fa-plus"
|
||||
Command="{Binding AddServerCommand}"
|
||||
DockPanel.Dock="Right"
|
||||
Background="Transparent"
|
||||
Foreground="{DynamicResource UiTheme08}"
|
||||
HorizontalAlignment="Right"
|
||||
ToolTip.Tip="Add Server" />
|
||||
</DockPanel>
|
||||
<views:SidebarView Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
DataContext="{Binding Sidebar}"
|
||||
SelectedChannel="{Binding SelectedChannel, Mode=TwoWay}" />
|
||||
<views:ChatView Grid.RowSpan="2"
|
||||
Grid.Column="1"
|
||||
DataContext="{Binding Chat}" />
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -5,24 +5,25 @@
|
||||
x:Class="Decho.Views.MessageComposerView"
|
||||
x:DataType="vm:MessageComposerViewModel">
|
||||
<Grid ColumnDefinitions="*, Auto, Auto" Margin="5">
|
||||
<TextBox HorizontalAlignment="Stretch"
|
||||
<TextBox Grid.Column="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="0 0 5 0"
|
||||
Text="{Binding Draft, Mode=TwoWay}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Command="{Binding SendCommand}" Gesture="Enter" />
|
||||
<KeyBinding Command="{Binding SendCommand}" Gesture="Enter" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="1"
|
||||
Height="33"
|
||||
Width="33"
|
||||
Content="Attach"
|
||||
i:Attached.Icon="fa-paperclip"
|
||||
Margin="0 0 5 0" />
|
||||
<Button Grid.Column="2"
|
||||
Height="33"
|
||||
Width="33"
|
||||
Margin="0 0 5 0"
|
||||
Content="Send"
|
||||
i:Attached.Icon="fa-paper-plane"
|
||||
Command="{Binding SendCommand}" />
|
||||
<Button Grid.Column="2"
|
||||
Height="33"
|
||||
Width="33"
|
||||
Click="OnFileUploadClicked"
|
||||
i:Attached.Icon="fa-paperclip" />
|
||||
</Grid>
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
@@ -1,4 +1,9 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Decho.ViewModels;
|
||||
|
||||
namespace Decho.Views;
|
||||
|
||||
@@ -7,5 +12,50 @@ public partial class MessageComposerView : UserControl
|
||||
public MessageComposerView()
|
||||
{
|
||||
InitializeComponent();
|
||||
AddHandler(DragDrop.DragOverEvent, OnDragOver);
|
||||
AddHandler(DragDrop.DropEvent, OnDrop);
|
||||
DragDrop.SetAllowDrop(this, true);
|
||||
}
|
||||
|
||||
private async void OnFileUploadClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MessageComposerViewModel vm) return;
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel?.StorageProvider is null) return;
|
||||
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
AllowMultiple = false,
|
||||
Title = "Select a file to upload",
|
||||
});
|
||||
|
||||
var file = files?.FirstOrDefault();
|
||||
if (file?.TryGetLocalPath() is string path)
|
||||
{
|
||||
vm.RequestFileUpload(path);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
#pragma warning disable CS0618
|
||||
if (e.Data.Contains(DataFormats.Files))
|
||||
#pragma warning restore CS0618
|
||||
e.DragEffects = DragDropEffects.Copy;
|
||||
}
|
||||
|
||||
private void OnDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (DataContext is not MessageComposerViewModel vm) return;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
var paths = e.Data.GetFiles()?
|
||||
.Select(f => f.TryGetLocalPath())
|
||||
.FirstOrDefault(p => p is not null);
|
||||
#pragma warning restore CS0618
|
||||
|
||||
if (paths is string path)
|
||||
vm.RequestFileUpload(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
xmlns:vm="using:Decho.ViewModels"
|
||||
x:Class="Decho.Views.MessageItemView"
|
||||
x:DataType="vm:MessageViewModel">
|
||||
<Grid Margin="5" ColumnDefinitions="Auto, Auto, *">
|
||||
<Ellipse Grid.RowSpan="2"
|
||||
VerticalAlignment="Top"
|
||||
<Grid Margin="5" ColumnDefinitions="Auto, *">
|
||||
<Ellipse VerticalAlignment="Top"
|
||||
Width="40"
|
||||
Height="40"
|
||||
Fill="{DynamicResource UiTheme00}"
|
||||
@@ -15,7 +14,8 @@
|
||||
<StackPanel Orientation="Horizontal" Margin="0 5 0 0">
|
||||
<TextBlock VerticalAlignment="Top"
|
||||
Text="{Binding AuthorName}"
|
||||
FontWeight="Bold" />
|
||||
FontWeight="Bold"
|
||||
Foreground="{Binding AuthorColor}" />
|
||||
<TextBlock VerticalAlignment="Top"
|
||||
Text="{Binding TimeText}"
|
||||
FontSize="10"
|
||||
@@ -23,7 +23,24 @@
|
||||
</StackPanel>
|
||||
<TextBlock VerticalAlignment="Top"
|
||||
Text="{Binding Content}"
|
||||
TextWrapping="Wrap" />
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ShowContent}" />
|
||||
<Image x:Name="MessageImage"
|
||||
MaxHeight="300"
|
||||
MaxWidth="400"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{Binding IsImage}"
|
||||
Margin="0 4 0 0" />
|
||||
<StackPanel IsVisible="{Binding HasAttachment}" Margin="0 4 0 0" Spacing="2">
|
||||
<TextBlock Text="{Binding AttachmentFileName}"
|
||||
FontSize="11"
|
||||
Opacity="0.7" />
|
||||
<Button Content="Download"
|
||||
Click="OnDownloadClicked"
|
||||
FontSize="11"
|
||||
Padding="5 1"
|
||||
HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,11 +1,94 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Decho.ViewModels;
|
||||
|
||||
namespace Decho.Views;
|
||||
|
||||
public partial class MessageItemView : UserControl
|
||||
{
|
||||
private CancellationTokenSource? _loadCts;
|
||||
private bool _pendingLoad;
|
||||
|
||||
public MessageItemView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
Loaded += OnLoaded;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs args)
|
||||
{
|
||||
_loadCts?.Cancel();
|
||||
_loadCts = new CancellationTokenSource();
|
||||
_pendingLoad = DataContext is MessageViewModel msg && msg.IsImage && msg.AttachmentUrl is not null;
|
||||
}
|
||||
|
||||
private void OnLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_pendingLoad) return;
|
||||
_pendingLoad = false;
|
||||
var msg = (MessageViewModel)DataContext!;
|
||||
_ = LoadImageAsync(msg, _loadCts!.Token);
|
||||
}
|
||||
|
||||
private async Task LoadImageAsync(MessageViewModel msg, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel?.DataContext is not MainWindowViewModel mainVm) return;
|
||||
|
||||
var bytes = await mainVm.ConnectionService.DownloadImageBytesAsync(
|
||||
msg.ServerUrl ?? "", msg.AttachmentUrl!);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (bytes is null || bytes.Length == 0) return;
|
||||
|
||||
using var stream = new MemoryStream(bytes);
|
||||
var bitmap = new Bitmap(stream);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var image = this.FindControl<Image>("MessageImage");
|
||||
if (image is not null)
|
||||
image.Source = bitmap;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Image failed to load — filename is shown as fallback
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnDownloadClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MessageViewModel msg) return;
|
||||
if (msg.AttachmentUrl is null) return;
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel?.StorageProvider is null) return;
|
||||
|
||||
var mainVm = topLevel.DataContext as MainWindowViewModel;
|
||||
if (mainVm is null) return;
|
||||
|
||||
var tempPath = await mainVm.ConnectionService.DownloadAttachmentAsync(
|
||||
msg.ServerUrl ?? "", msg.AttachmentUrl, msg.AttachmentFileName ?? "download");
|
||||
|
||||
if (tempPath is null) return;
|
||||
|
||||
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
SuggestedFileName = msg.AttachmentFileName ?? "download",
|
||||
});
|
||||
|
||||
if (file?.TryGetLocalPath() is string savePath)
|
||||
{
|
||||
File.Copy(tempPath, savePath, overwrite: true);
|
||||
}
|
||||
|
||||
try { File.Delete(tempPath); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
xmlns:views="using:Decho.Views"
|
||||
x:Class="Decho.Views.MessageListView"
|
||||
x:DataType="vm:ChatViewModel">
|
||||
<ScrollViewer>
|
||||
<ScrollViewer Name="MessageScrollViewer">
|
||||
<ItemsControl ItemsSource="{Binding Messages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MessageViewModel">
|
||||
|
||||
@@ -1,11 +1,96 @@
|
||||
using System.Collections.Specialized;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Decho.ViewModels;
|
||||
|
||||
namespace Decho.Views;
|
||||
|
||||
public partial class MessageListView : UserControl
|
||||
{
|
||||
private ChatViewModel? _chat;
|
||||
private bool _hasPendingScroll;
|
||||
private bool _wasAtBottom;
|
||||
|
||||
public MessageListView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs args)
|
||||
{
|
||||
if (_chat is not null)
|
||||
{
|
||||
_chat.Messages.CollectionChanged -= OnMessagesChanged;
|
||||
_chat.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
}
|
||||
|
||||
var scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
|
||||
if (scroll is not null)
|
||||
scroll.ScrollChanged -= OnScrollChanged;
|
||||
|
||||
_chat = DataContext as ChatViewModel;
|
||||
if (_chat is not null)
|
||||
{
|
||||
_chat.Messages.CollectionChanged += OnMessagesChanged;
|
||||
_chat.PropertyChanged += OnViewModelPropertyChanged;
|
||||
if (scroll is not null)
|
||||
scroll.ScrollChanged += OnScrollChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
|
||||
{
|
||||
if (e.ExtentDelta.Y > 0 && _wasAtBottom)
|
||||
{
|
||||
var scroll = (ScrollViewer)sender!;
|
||||
Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
var scrollViewer = (ScrollViewer)sender!;
|
||||
_wasAtBottom = scrollViewer.Offset.Y + scrollViewer.Viewport.Height >= scrollViewer.Extent.Height - 30;
|
||||
}
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ChatViewModel.Messages))
|
||||
{
|
||||
_chat!.Messages.CollectionChanged += OnMessagesChanged;
|
||||
ScrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMessagesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action == NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
TryAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
private void TryAutoScroll()
|
||||
{
|
||||
var scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
|
||||
if (scroll is null) return;
|
||||
|
||||
_wasAtBottom = scroll.Offset.Y + scroll.Viewport.Height >= scroll.Extent.Height - 30;
|
||||
if (_wasAtBottom && !_hasPendingScroll)
|
||||
{
|
||||
_hasPendingScroll = true;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
scroll.ScrollToEnd();
|
||||
_hasPendingScroll = false;
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScrollToBottom()
|
||||
{
|
||||
var scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
|
||||
if (scroll is null) return;
|
||||
|
||||
_wasAtBottom = true;
|
||||
Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Decho.ViewModels"
|
||||
xmlns:views="using:Decho.Views"
|
||||
x:Class="Decho.Views.SidebarView"
|
||||
x:DataType="vm:SidebarViewModel">
|
||||
<Border Background="{DynamicResource UiTheme00}" Width="250" CornerRadius="0 6 0 0">
|
||||
<ScrollViewer Padding="5">
|
||||
<ItemsControl ItemsSource="{Binding Servers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ServerViewModel">
|
||||
<Expander Padding="0"
|
||||
Margin="0 0 0 6"
|
||||
HorizontalAlignment="Stretch"
|
||||
IsExpanded="{Binding IsExpanded}">
|
||||
<Expander.Header>
|
||||
<TextBlock FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxWidth="190"
|
||||
Padding="0"
|
||||
Text="{Binding Name}" />
|
||||
</Expander.Header>
|
||||
<views:ChannelListView />
|
||||
</Expander>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</UserControl>
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Decho.ViewModels"
|
||||
xmlns:views="using:Decho.Views"
|
||||
x:Class="Decho.Views.SidebarView"
|
||||
x:DataType="vm:SidebarViewModel">
|
||||
<Border Background="{DynamicResource UiTheme00}" Width="250" CornerRadius="0 6 0 0">
|
||||
<ScrollViewer Padding="5">
|
||||
<ItemsControl ItemsSource="{Binding Servers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ServerViewModel">
|
||||
<Expander Padding="0"
|
||||
Margin="0 0 0 6"
|
||||
HorizontalAlignment="Stretch"
|
||||
IsExpanded="{Binding IsExpanded}">
|
||||
<Expander.Header>
|
||||
<StackPanel Spacing="5">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<Ellipse Width="8" Height="8"
|
||||
Fill="{Binding ConnectionStatusColor}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxWidth="160"
|
||||
Padding="0"
|
||||
Text="{Binding Name}" />
|
||||
</StackPanel>
|
||||
<TextBlock FontSize="10"
|
||||
Text="{Binding ConnectionStatusText}"
|
||||
Opacity="0.6"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Expander.Header>
|
||||
<StackPanel>
|
||||
<Button Content="Connect"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsVisible="{Binding ShowConnectionControls}"
|
||||
Margin="0 0 0 4"
|
||||
Padding="5 2"
|
||||
HorizontalAlignment="Stretch" />
|
||||
<Button Content="Disconnect"
|
||||
Command="{Binding DisconnectCommand}"
|
||||
IsVisible="{Binding IsConnected}"
|
||||
Margin="0 0 0 4"
|
||||
Padding="5 2"
|
||||
HorizontalAlignment="Stretch" />
|
||||
<views:ChannelListView />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</UserControl>
|
||||
Reference in New Issue
Block a user