using System.Collections.ObjectModel;
using System.IO;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using XFEToolBox.Client.Models.Chat;
using XFEToolBox.Client.Profiles.CrossVersionProfiles;
using XFEToolBox.Client.Utilities;
using XFEToolBox.Client.Utilities.Chat;
using XFEToolBox.Client.Utilities.Server;
using XFEToolBox.Core.Chat;
using XFEToolBox.WpfCore.Controls;
namespace XFEToolBox.Client.ViewModel.Chat;
public partial class ChatPageViewModel : ObservableObject
{
private readonly ChatApiClient apiClient = new();
private readonly SemaphoreSlim realtimeEventLock = new(1, 1);
private static readonly JsonSerializerOptions RealtimeJsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private bool initialized;
private long? nextBeforeSequence;
private long messageLoadGeneration;
private CancellationTokenSource sessionCancellation = new();
public ObservableCollection<ChatConversationItem> Conversations { get; } = [];
public ObservableCollection<ChatNavigationItem> NavigationItems { get; } = [];
public ObservableCollection<ChatGroupItem> RecommendedGroups { get; } = [];
public ObservableCollection<ChatGroupItem> MyGroups { get; } = [];
public ObservableCollection<ChatFriendItem> Friends { get; } = [];
public ObservableCollection<ChatFriendRequestItem> FriendRequests { get; } = [];
public ObservableCollection<ChatInvitationItem> GroupInvitations { get; } = [];
public ObservableCollection<ChatUserSearchItem> UserSearchResults { get; } = [];
public ObservableCollection<ChatMessageItem> Messages { get; } = [];
public ObservableCollection<SelectableFriendItem> InviteCandidates { get; } = [];
public ObservableCollection<ChatGroupMemberItem> ManagedGroupMembers { get; } = [];
public event EventHandler<bool>? MessagesChanged;
public event EventHandler<ChatConversationInfo>? CallRequested;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLoggedOut))]
private bool isLoggedIn = ClientSession.IsLoggedIn;
public bool IsLoggedOut => !IsLoggedIn;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNotBusy))]
[NotifyPropertyChangedFor(nameof(CanCompose))]
private bool isBusy;
public bool IsNotBusy => !IsBusy;
[ObservableProperty]
private bool isStatusOpen;
[ObservableProperty]
private string statusTitle = string.Empty;
[ObservableProperty]
private string statusMessage = string.Empty;
[ObservableProperty]
private InfoBarSeverity statusSeverity = InfoBarSeverity.Informational;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsConversationSection))]
[NotifyPropertyChangedFor(nameof(IsLobbySection))]
[NotifyPropertyChangedFor(nameof(IsFriendsSection))]
[NotifyPropertyChangedFor(nameof(IsGroupsSection))]
private ChatSection selectedSection = ChatSection.Conversations;
public bool IsConversationSection => SelectedSection == ChatSection.Conversations;
public bool IsLobbySection => SelectedSection == ChatSection.Lobby;
public bool IsFriendsSection => SelectedSection == ChatSection.Friends;
public bool IsGroupsSection => SelectedSection == ChatSection.Groups;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasSelectedConversation))]
[NotifyPropertyChangedFor(nameof(HasNoSelectedConversation))]
[NotifyPropertyChangedFor(nameof(ConversationTitle))]
[NotifyPropertyChangedFor(nameof(ConversationSubtitle))]
[NotifyPropertyChangedFor(nameof(IsSelectedConversationGroup))]
[NotifyPropertyChangedFor(nameof(CanCompose))]
[NotifyPropertyChangedFor(nameof(HasOpenDetail))]
[NotifyPropertyChangedFor(nameof(ShowListPane))]
[NotifyPropertyChangedFor(nameof(ShowDetailPane))]
[NotifyPropertyChangedFor(nameof(ShowBackButton))]
private ChatConversationItem? selectedConversation;
[ObservableProperty]
private ChatNavigationItem? selectedNavigationItem;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsMessageAreaOpen))]
[NotifyPropertyChangedFor(nameof(HasOpenDetail))]
[NotifyPropertyChangedFor(nameof(ShowListPane))]
[NotifyPropertyChangedFor(nameof(ShowDetailPane))]
[NotifyPropertyChangedFor(nameof(ShowBackButton))]
private bool isLobbyOpen;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowListPane))]
[NotifyPropertyChangedFor(nameof(ShowDetailPane))]
[NotifyPropertyChangedFor(nameof(ShowBackButton))]
private bool isSinglePaneMode = SystemProfile.ChatSinglePaneMode;
public bool HasSelectedConversation => SelectedConversation is not null;
public bool HasNoSelectedConversation => SelectedConversation is null;
public bool IsSelectedConversationGroup => SelectedConversation?.Conversation.Kind == ChatConversationKind.Group;
public bool IsMessageAreaOpen => !IsLobbyOpen;
public bool HasOpenDetail => IsLobbyOpen || HasSelectedConversation;
public bool ShowListPane => !IsSinglePaneMode || !HasOpenDetail;
public bool ShowDetailPane => !IsSinglePaneMode || HasOpenDetail;
public bool ShowBackButton => IsSinglePaneMode && HasOpenDetail;
public string ConversationTitle => SelectedConversation?.Title ?? "选择一个会话";
public string ConversationSubtitle => SelectedConversation is null
? "从左侧选择好友、群聊或最近会话"
: SelectedConversation.Conversation.Kind == ChatConversationKind.Group
? $"{SelectedConversation.Conversation.Group?.MemberCount ?? 0} 位成员 · 群号 {SelectedConversation.Conversation.Group?.GroupNumber}"
: $"@{SelectedConversation.Conversation.Friend?.UserName}";
public bool CanCompose => IsLoggedIn && HasSelectedConversation && !IsBusy && !IsTransferring;
[ObservableProperty]
private string composerText = string.Empty;
[ObservableProperty]
private string groupSearchQuery = string.Empty;
[ObservableProperty]
private string groupNumberQuery = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasLookupGroupResult))]
private ChatGroupItem? lookupGroupResult;
public bool HasLookupGroupResult => LookupGroupResult is not null;
[ObservableProperty]
private string userSearchQuery = string.Empty;
[ObservableProperty]
private string friendRequestMessage = "你好,我想添加你为好友。";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanCompose))]
private bool isTransferring;
[ObservableProperty]
private double transferProgress;
[ObservableProperty]
private string transferText = string.Empty;
[ObservableProperty]
private bool hasEarlierMessages;
[ObservableProperty]
private bool isCreateGroupDialogOpen;
[ObservableProperty]
private bool isContactManagementDialogOpen;
[ObservableProperty]
private string newGroupName = string.Empty;
[ObservableProperty]
private string newGroupDescription = string.Empty;
[ObservableProperty]
private bool newGroupIsPublic = true;
[ObservableProperty]
private string newGroupInvitationMessage = "欢迎加入我的群聊。";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanManageSelectedGroup))]
[NotifyPropertyChangedFor(nameof(IsManagedGroupOwner))]
private ChatGroupItem? managedGroup;
[ObservableProperty]
private bool isGroupManagementDialogOpen;
[ObservableProperty]
private string managedGroupName = string.Empty;
[ObservableProperty]
private string managedGroupDescription = string.Empty;
[ObservableProperty]
private bool managedGroupIsPublic;
[ObservableProperty]
private bool managedGroupIsMuted;
public bool CanManageSelectedGroup => ManagedGroup?.CanManage == true;
public bool IsManagedGroupOwner => ManagedGroup?.IsOwner == true;
partial void OnManagedGroupIsMutedChanged(bool value)
{
if (ManagedGroup is null) return;
ChatNotificationPreferences.SetGroupMuted(ManagedGroup.Group.Id, value);
RebuildNavigationItems();
}
public void RefreshLayoutPreference() => IsSinglePaneMode = SystemProfile.ChatSinglePaneMode;
public async Task InitializeAsync()
{
IsLoggedIn = ClientSession.IsLoggedIn;
if (!IsLoggedIn)
{
ResetState();
return;
}
if (initialized) return;
initialized = true;
await RefreshAllAsync();
}
public async Task OnSessionChangedAsync()
{
IsLoggedIn = ClientSession.IsLoggedIn;
if (!IsLoggedIn)
{
initialized = false;
sessionCancellation.Cancel();
sessionCancellation.Dispose();
sessionCancellation = new CancellationTokenSource();
ResetState();
ShowStatus("已退出聊天", "登录后可继续使用好友、群聊与消息功能。", InfoBarSeverity.Informational);
return;
}
initialized = true;
await RefreshAllAsync();
}
public async Task HandleRealtimeEnvelopeAsync(ChatClientRealtimeEnvelope envelope)
{
if (!IsLoggedIn) return;
await realtimeEventLock.WaitAsync();
try
{
if (envelope.Type == "realtime.connected")
{
await RefreshAllCoreAsync(allowWhileBusy: true);
if (SelectedConversation is not null)
await LoadMessagesAsync(reset: true, allowWhileBusy: true);
return;
}
if (!envelope.Type.StartsWith("chat.", StringComparison.Ordinal)) return;
switch (envelope.Type)
{
case "chat.message.created":
{
var message = envelope.Payload.Deserialize<ChatMessageInfo>(RealtimeJsonOptions);
if (message is null) return;
if (string.Equals(SelectedConversation?.Id, message.ConversationId, StringComparison.Ordinal))
{
AddOrReplaceMessage(message);
MessagesChanged?.Invoke(this, true);
}
await RefreshConversationsAsync();
break;
}
case "chat.friend.requested":
case "chat.friend.request.updated":
case "chat.friend.deleted":
await RefreshFriendDataAsync();
await RefreshConversationsAsync();
break;
case "chat.group.invited":
case "chat.group.invitation.updated":
case "chat.group.updated":
case "chat.group.members.updated":
await RefreshGroupsAsync();
await RefreshConversationsAsync();
if (ManagedGroup is not null)
await LoadManagedGroupMembersAsync();
break;
}
}
catch (Exception exception)
{
ShowException("实时同步失败", exception);
}
finally
{
realtimeEventLock.Release();
}
}
[RelayCommand]
private void SelectSection(ChatSection section) => SelectedSection = section;
[RelayCommand]
private async Task OpenLobbyAsync()
{
IsLobbyOpen = true;
SelectedNavigationItem = null;
SelectedConversation = null;
Messages.Clear();
await RefreshRecommendedGroupsAsync();
}
[RelayCommand]
private void CloseDetail()
{
IsLobbyOpen = false;
SelectedConversation = null;
SelectedNavigationItem = null;
Messages.Clear();
nextBeforeSequence = null;
HasEarlierMessages = false;
}
[RelayCommand]
private void OpenContactManagementDialog()
{
IsCreateGroupDialogOpen = false;
IsContactManagementDialogOpen = true;
}
[RelayCommand]
private void CloseContactManagementDialog() => IsContactManagementDialogOpen = false;
[RelayCommand]
private async Task OpenNavigationItemAsync(ChatNavigationItem? item)
{
if (item is null) return;
SelectedNavigationItem = item;
if (item.Conversation is not null)
{
await OpenConversationAsync(new ChatConversationItem(item.Conversation));
return;
}
if (item.Friend is not null)
{
await OpenFriendConversationAsync(new ChatFriendItem(item.Friend));
return;
}
if (item.Group is not null)
await OpenGroupConversationCoreAsync(item.Group);
}
[RelayCommand]
private Task RefreshAllAsync() => RefreshAllCoreAsync(allowWhileBusy: false);
private async Task RefreshAllCoreAsync(bool allowWhileBusy)
{
if (!IsLoggedIn || (IsBusy && !allowWhileBusy)) return;
var ownsBusyState = !IsBusy;
if (ownsBusyState) IsBusy = true;
try
{
var conversations = await apiClient.GetConversationsAsync();
var friends = await apiClient.GetFriendsAsync();
var requests = await apiClient.GetFriendRequestsAsync();
var myGroups = await apiClient.GetMyGroupsAsync();
var invitations = await apiClient.GetGroupInvitationsAsync();
var recommended = await apiClient.GetRecommendedGroupsAsync(GroupSearchQuery);
if (!ClientSession.IsLoggedIn) return;
Replace(Conversations, conversations.Select(item => new ChatConversationItem(item)));
Replace(Friends, friends.Select(item => new ChatFriendItem(item)));
Replace(FriendRequests, requests.Select(item => new ChatFriendRequestItem(item)));
Replace(MyGroups, myGroups.Select(item => new ChatGroupItem(item)));
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
Replace(GroupInvitations, invitations.Select(item => new ChatInvitationItem(item, currentUserId)));
Replace(RecommendedGroups, recommended.Select(item => new ChatGroupItem(item)));
RebuildInviteCandidates(friends);
if (SelectedConversation is not null)
{
var refreshed = Conversations.FirstOrDefault(item => item.Id == SelectedConversation.Id);
if (refreshed is not null) SelectedConversation = refreshed;
}
RebuildNavigationItems();
ShowStatus("聊天已同步", "好友、群聊与最近会话已刷新。", InfoBarSeverity.Success, autoClose: true);
}
catch (Exception exception)
{
ShowException("无法刷新聊天", exception);
}
finally
{
if (ownsBusyState) IsBusy = false;
}
}
[RelayCommand]
private async Task RefreshRecommendedGroupsAsync()
{
if (!IsLoggedIn || IsBusy) return;
IsBusy = true;
try
{
var groups = await apiClient.GetRecommendedGroupsAsync(GroupSearchQuery);
Replace(RecommendedGroups, groups.Select(item => new ChatGroupItem(item)));
}
catch (Exception exception)
{
ShowException("无法加载大厅", exception);
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task LookupGroupAsync()
{
if (string.IsNullOrWhiteSpace(GroupNumberQuery))
{
ShowStatus("请输入群号", "私密群聊不会出现在大厅中,需要输入准确群号。", InfoBarSeverity.Warning);
return;
}
await RunBusyAsync("查找群聊失败", async () =>
{
LookupGroupResult = new ChatGroupItem(await apiClient.LookupGroupAsync(GroupNumberQuery));
});
}
[RelayCommand]
private async Task JoinOrOpenGroupAsync(ChatGroupItem? item)
{
if (item is null) return;
try
{
var group = item.Group;
if (!item.IsJoined)
{
IsBusy = true;
group = await apiClient.JoinGroupAsync(group.GroupNumber);
await RefreshGroupsAsync();
ShowStatus("已加入群聊", $"你现在是“{group.Name}”的成员。", InfoBarSeverity.Success);
}
await OpenGroupConversationCoreAsync(group);
}
catch (Exception exception)
{
ShowException("无法进入群聊", exception);
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task OpenConversationAsync(ChatConversationItem? item)
{
if (item is null) return;
SelectedConversation = item;
IsLobbyOpen = false;
SelectedNavigationItem = NavigationItems.FirstOrDefault(entry =>
string.Equals(entry.Conversation?.Id, item.Id, StringComparison.Ordinal));
await LoadMessagesAsync(reset: true, allowWhileBusy: true);
}
[RelayCommand]
private async Task OpenFriendConversationAsync(ChatFriendItem? item)
{
if (item is null) return;
await RunBusyAsync("无法打开好友会话", async () =>
{
var conversation = await apiClient.OpenDirectConversationAsync(item.Friend.User.Id);
UpsertConversation(conversation);
SelectedConversation = new ChatConversationItem(conversation);
IsLobbyOpen = false;
RebuildNavigationItems();
SelectedNavigationItem = NavigationItems.FirstOrDefault(entry =>
string.Equals(entry.Conversation?.Id, conversation.Id, StringComparison.Ordinal));
SelectedSection = ChatSection.Conversations;
await LoadMessagesAsync(reset: true, allowWhileBusy: true);
});
}
private async Task OpenGroupConversationCoreAsync(ChatGroupSummary group)
{
if (string.IsNullOrWhiteSpace(group.ConversationId))
throw new ChatApiException("服务器尚未为该群创建会话。");
var existing = Conversations.FirstOrDefault(item => item.Id == group.ConversationId);
SelectedConversation = existing ?? new ChatConversationItem(new ChatConversationInfo
{
Id = group.ConversationId,
Kind = ChatConversationKind.Group,
Group = group,
CreatedAtUtc = group.CreatedAtUtc,
UpdatedAtUtc = group.UpdatedAtUtc
});
IsLobbyOpen = false;
RebuildNavigationItems();
SelectedNavigationItem = NavigationItems.FirstOrDefault(entry =>
string.Equals(entry.Conversation?.Id, SelectedConversation.Id, StringComparison.Ordinal));
SelectedSection = ChatSection.Conversations;
await LoadMessagesAsync(reset: true, allowWhileBusy: true);
}
[RelayCommand]
private async Task RefreshConversationAsync()
{
if (SelectedConversation is null) return;
await LoadMessagesAsync(reset: true);
}
[RelayCommand]
private async Task LoadEarlierMessagesAsync()
{
if (!HasEarlierMessages || SelectedConversation is null || IsBusy) return;
await LoadMessagesAsync(reset: false);
}
[RelayCommand]
private async Task SendTextAsync()
{
if (SelectedConversation is null || string.IsNullOrWhiteSpace(ComposerText) || IsBusy) return;
var conversationId = SelectedConversation.Id;
var text = ComposerText.Trim();
IsBusy = true;
try
{
var message = await apiClient.SendMessageAsync(conversationId, ChatMessageType.Text, text);
if (string.Equals(ComposerText.Trim(), text, StringComparison.Ordinal)) ComposerText = string.Empty;
if (string.Equals(SelectedConversation?.Id, conversationId, StringComparison.Ordinal))
{
AddOrReplaceMessage(message);
MessagesChanged?.Invoke(this, true);
}
await RefreshConversationsAsync();
}
catch (Exception exception)
{
ShowException("消息发送失败", exception);
}
finally
{
IsBusy = false;
}
}
public async Task SendFileAsync(string path, CancellationToken cancellationToken = default)
{
if (SelectedConversation is null || IsTransferring) return;
var conversationId = SelectedConversation.Id;
IsTransferring = true;
TransferProgress = 0;
TransferText = $"正在上传 {Path.GetFileName(path)}";
try
{
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, sessionCancellation.Token);
var progress = new Progress<double>(value => TransferProgress = value * 100);
var attachment = await apiClient.UploadAttachmentAsync(path, progress, linkedCancellation.Token);
TransferText = "正在发送文件消息";
var message = await apiClient.SendMessageAsync(
conversationId,
ChatApiClient.ResolveMessageType(attachment),
attachmentId: attachment.Id);
if (string.Equals(SelectedConversation?.Id, conversationId, StringComparison.Ordinal))
{
AddOrReplaceMessage(message);
MessagesChanged?.Invoke(this, true);
}
ShowStatus("文件已发送", attachment.FileName, InfoBarSeverity.Success, autoClose: true);
await RefreshConversationsAsync();
}
catch (OperationCanceledException)
{
ShowStatus("已取消文件发送", Path.GetFileName(path), InfoBarSeverity.Warning);
}
catch (Exception exception)
{
ShowException("文件发送失败", exception);
}
finally
{
IsTransferring = false;
TransferProgress = 0;
TransferText = string.Empty;
}
}
public async Task DownloadAttachmentAsync(ChatAttachmentInfo attachment, string destinationPath, CancellationToken cancellationToken = default)
{
if (IsTransferring) return;
IsTransferring = true;
TransferProgress = 0;
TransferText = $"正在下载 {attachment.FileName}";
try
{
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, sessionCancellation.Token);
var progress = new Progress<double>(value => TransferProgress = value * 100);
await apiClient.DownloadAttachmentAsync(attachment, destinationPath, progress, linkedCancellation.Token);
ShowStatus("文件已下载", destinationPath, InfoBarSeverity.Success);
}
catch (OperationCanceledException)
{
ShowStatus("已取消下载", attachment.FileName, InfoBarSeverity.Warning);
}
catch (Exception exception)
{
ShowException("文件下载失败", exception);
}
finally
{
IsTransferring = false;
TransferProgress = 0;
TransferText = string.Empty;
}
}
[RelayCommand]
private async Task SearchUsersAsync()
{
if (string.IsNullOrWhiteSpace(UserSearchQuery))
{
UserSearchResults.Clear();
return;
}
await RunBusyAsync("搜索用户失败", async () =>
{
var users = await apiClient.SearchUsersAsync(UserSearchQuery);
Replace(UserSearchResults, users.Select(item => new ChatUserSearchItem(item)));
});
}
[RelayCommand]
private async Task SendFriendRequestAsync(ChatUserSearchItem? item)
{
if (item is null) return;
await RunBusyAsync("好友申请发送失败", async () =>
{
await apiClient.SendFriendRequestAsync(item.User.Id, FriendRequestMessage);
ShowStatus("好友申请已发送", $"等待 {item.Name} 处理。", InfoBarSeverity.Success);
await RefreshFriendDataAsync();
});
}
[RelayCommand]
private async Task AcceptFriendRequestAsync(ChatFriendRequestItem? item) =>
await RespondToFriendRequestAsync(item, true);
[RelayCommand]
private async Task RejectFriendRequestAsync(ChatFriendRequestItem? item) =>
await RespondToFriendRequestAsync(item, false);
private async Task RespondToFriendRequestAsync(ChatFriendRequestItem? item, bool accept)
{
if (item is null || !item.CanRespond) return;
await RunBusyAsync("好友申请处理失败", async () =>
{
await apiClient.RespondToFriendRequestAsync(item.Request.Id, accept);
await RefreshFriendDataAsync();
await RefreshConversationsAsync();
ShowStatus(accept ? "已添加好友" : "已拒绝申请", item.Name, accept ? InfoBarSeverity.Success : InfoBarSeverity.Informational);
});
}
public async Task DeleteFriendAsync(ChatFriendItem item)
{
await RunBusyAsync("删除好友失败", async () =>
{
await apiClient.DeleteFriendAsync(item.Friend.User.Id);
await RefreshFriendDataAsync();
await RefreshConversationsAsync();
if (SelectedConversation?.Conversation.Friend?.Id == item.Friend.User.Id)
{
SelectedConversation = null;
Messages.Clear();
}
ShowStatus("已删除好友", item.Name, InfoBarSeverity.Informational);
});
}
[RelayCommand]
private async Task AcceptGroupInvitationAsync(ChatInvitationItem? item) =>
await RespondToGroupInvitationAsync(item, true);
[RelayCommand]
private async Task RejectGroupInvitationAsync(ChatInvitationItem? item) =>
await RespondToGroupInvitationAsync(item, false);
[RelayCommand]
private async Task AcceptMessageInvitationAsync(ChatMessageItem? item)
{
if (item?.CanRespondToInvitation != true || item.Message.GroupInvitation is not { } invitation) return;
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
await RespondToGroupInvitationAsync(new ChatInvitationItem(invitation, currentUserId), true);
await LoadMessagesAsync(reset: true);
}
private async Task RespondToGroupInvitationAsync(ChatInvitationItem? item, bool accept)
{
if (item is null || !item.CanRespond) return;
await RunBusyAsync("群邀请处理失败", async () =>
{
await apiClient.RespondToGroupInvitationAsync(item.Invitation.Id, accept);
await RefreshGroupsAsync();
await RefreshConversationsAsync();
ShowStatus(accept ? "已加入群聊" : "已拒绝群邀请", item.GroupName,
accept ? InfoBarSeverity.Success : InfoBarSeverity.Informational);
});
}
[RelayCommand]
private void OpenCreateGroupDialog()
{
IsContactManagementDialogOpen = false;
NewGroupName = string.Empty;
NewGroupDescription = string.Empty;
NewGroupIsPublic = true;
NewGroupInvitationMessage = "欢迎加入我的群聊。";
foreach (var item in InviteCandidates) item.IsSelected = false;
IsCreateGroupDialogOpen = true;
}
[RelayCommand]
private void CloseCreateGroupDialog() => IsCreateGroupDialogOpen = false;
[RelayCommand]
private async Task CreateGroupAsync()
{
if (string.IsNullOrWhiteSpace(NewGroupName))
{
ShowStatus("群名称不能为空", "请为新群聊填写一个名称。", InfoBarSeverity.Warning);
return;
}
await RunBusyAsync("创建群聊失败", async () =>
{
var group = await apiClient.CreateGroupAsync(NewGroupName, NewGroupDescription, NewGroupIsPublic);
var invited = 0;
foreach (var friend in InviteCandidates.Where(item => item.IsSelected))
{
try
{
await apiClient.InviteToGroupAsync(group.Id, friend.Friend.User.Id, NewGroupInvitationMessage);
invited++;
}
catch
{
// The group is already valid; individual invitation failures are reported in the final status.
}
}
IsCreateGroupDialogOpen = false;
await RefreshGroupsAsync();
await RefreshConversationsAsync();
ShowStatus("群聊已创建", invited == 0 ? $"群号 {group.GroupNumber}" : $"群号 {group.GroupNumber} · 已邀请 {invited} 位好友", InfoBarSeverity.Success);
await OpenGroupConversationCoreAsync(group);
});
}
[RelayCommand]
private async Task OpenGroupManagementAsync()
{
var group = SelectedConversation?.Conversation.Group;
if (group is null) return;
ManagedGroup = new ChatGroupItem(group);
ManagedGroupName = group.Name;
ManagedGroupDescription = group.Description;
ManagedGroupIsPublic = group.Visibility == ChatGroupVisibility.Public;
ManagedGroupIsMuted = ChatNotificationPreferences.IsGroupMuted(group.Id);
IsGroupManagementDialogOpen = true;
await RunBusyAsync("群成员加载失败", LoadManagedGroupMembersAsync);
}
[RelayCommand]
private void CloseGroupManagementDialog()
{
IsGroupManagementDialogOpen = false;
ManagedGroupMembers.Clear();
ManagedGroup = null;
}
[RelayCommand]
private async Task UpdateManagedGroupAsync()
{
if (ManagedGroup is null || !CanManageSelectedGroup) return;
await RunBusyAsync("群资料保存失败", async () =>
{
var updated = await apiClient.UpdateGroupAsync(
ManagedGroup.Group.Id, ManagedGroupName, ManagedGroupDescription, ManagedGroupIsPublic);
ManagedGroup = new ChatGroupItem(updated);
await RefreshGroupsAsync();
await RefreshConversationsAsync();
ShowStatus("群资料已保存", updated.Name, InfoBarSeverity.Success);
});
}
public async Task LeaveManagedGroupAsync()
{
if (ManagedGroup is null) return;
await RunBusyAsync("退出群聊失败", async () =>
{
var id = ManagedGroup.Group.Id;
await apiClient.LeaveGroupAsync(id);
ChatNotificationPreferences.SetGroupMuted(id, false);
IsGroupManagementDialogOpen = false;
ManagedGroup = null;
ManagedGroupMembers.Clear();
SelectedConversation = null;
Messages.Clear();
await RefreshGroupsAsync();
await RefreshConversationsAsync();
ShowStatus("已退出群聊", "该群已从你的群聊列表中移除。", InfoBarSeverity.Informational);
});
}
public async Task RemoveManagedGroupMemberAsync(ChatGroupMemberItem member)
{
if (ManagedGroup is null || !CanManageSelectedGroup || member.IsCurrentUser || member.IsOwner) return;
await RunBusyAsync("移除成员失败", async () =>
{
await apiClient.RemoveGroupMemberAsync(ManagedGroup.Group.Id, member.Member.User.Id);
await LoadManagedGroupMembersAsync();
});
}
[RelayCommand]
private async Task ToggleManagedGroupMemberRoleAsync(ChatGroupMemberItem? member)
{
if (ManagedGroup is null || !IsManagedGroupOwner || member is null || member.IsCurrentUser || member.IsOwner) return;
var targetRole = member.Member.Role == ChatGroupRole.Administrator
? ChatGroupRole.Member
: ChatGroupRole.Administrator;
await RunBusyAsync("成员角色修改失败", async () =>
{
await apiClient.SetGroupMemberRoleAsync(ManagedGroup.Group.Id, member.Member.User.Id, targetRole);
await LoadManagedGroupMembersAsync();
});
}
public async Task TransferManagedGroupOwnershipAsync(ChatGroupMemberItem member)
{
if (ManagedGroup is null || !IsManagedGroupOwner || member.IsCurrentUser) return;
await RunBusyAsync("转让群聊失败", async () =>
{
await apiClient.TransferGroupOwnershipAsync(ManagedGroup.Group.Id, member.Member.User.Id);
await RefreshGroupsAsync();
IsGroupManagementDialogOpen = false;
ShowStatus("群主已转让", $"{member.Name} 现在是群主。", InfoBarSeverity.Success);
});
}
[RelayCommand]
private void StartCall()
{
if (SelectedConversation is not null) CallRequested?.Invoke(this, SelectedConversation.Conversation);
}
private async Task LoadMessagesAsync(bool reset, bool allowWhileBusy = false)
{
if (SelectedConversation is null || !IsLoggedIn || (IsBusy && !allowWhileBusy)) return;
var conversationId = SelectedConversation.Id;
var loadGeneration = reset ? ++messageLoadGeneration : messageLoadGeneration;
if (reset)
{
Messages.Clear();
nextBeforeSequence = null;
HasEarlierMessages = false;
}
var ownsBusyState = !IsBusy;
if (ownsBusyState) IsBusy = true;
try
{
var page = await apiClient.GetMessageHistoryAsync(conversationId, reset ? null : nextBeforeSequence);
if (messageLoadGeneration != loadGeneration ||
!string.Equals(SelectedConversation?.Id, conversationId, StringComparison.Ordinal)) return;
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
var incoming = page.Items
.OrderBy(item => item.Sequence)
.Select(item => new ChatMessageItem(item, currentUserId))
.ToArray();
if (reset)
{
Replace(Messages, incoming);
}
else
{
for (var index = incoming.Length - 1; index >= 0; index--)
{
if (Messages.All(existing => existing.Id != incoming[index].Id))
Messages.Insert(0, incoming[index]);
}
}
nextBeforeSequence = page.NextBeforeSequence;
HasEarlierMessages = page.HasMore;
MessagesChanged?.Invoke(this, reset);
}
catch (Exception exception)
{
ShowException("消息加载失败", exception);
}
finally
{
if (ownsBusyState) IsBusy = false;
}
}
private async Task RefreshConversationsAsync()
{
var selectedId = SelectedConversation?.Id;
var conversations = await apiClient.GetConversationsAsync();
Replace(Conversations, conversations.Select(item => new ChatConversationItem(item)));
if (selectedId is not null)
{
SelectedConversation = Conversations.FirstOrDefault(item => item.Id == selectedId);
if (SelectedConversation is null)
{
Messages.Clear();
nextBeforeSequence = null;
HasEarlierMessages = false;
}
}
RebuildNavigationItems();
}
private async Task RefreshFriendDataAsync()
{
var friends = await apiClient.GetFriendsAsync();
var requests = await apiClient.GetFriendRequestsAsync();
Replace(Friends, friends.Select(item => new ChatFriendItem(item)));
Replace(FriendRequests, requests.Select(item => new ChatFriendRequestItem(item)));
RebuildInviteCandidates(friends);
RebuildNavigationItems();
}
private async Task RefreshGroupsAsync()
{
var groups = await apiClient.GetMyGroupsAsync();
var invitations = await apiClient.GetGroupInvitationsAsync();
var recommended = await apiClient.GetRecommendedGroupsAsync(GroupSearchQuery);
Replace(MyGroups, groups.Select(item => new ChatGroupItem(item)));
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
Replace(GroupInvitations, invitations.Select(item => new ChatInvitationItem(item, currentUserId)));
Replace(RecommendedGroups, recommended.Select(item => new ChatGroupItem(item)));
if (LookupGroupResult is not null)
{
var refreshed = groups.FirstOrDefault(item => item.Id == LookupGroupResult.Group.Id);
if (refreshed is not null) LookupGroupResult = new ChatGroupItem(refreshed);
}
RebuildNavigationItems();
}
private async Task LoadManagedGroupMembersAsync()
{
if (ManagedGroup is null) return;
var members = await apiClient.GetGroupMembersAsync(ManagedGroup.Group.Id);
var userId = ClientSession.CurrentUser?.Id ?? string.Empty;
Replace(ManagedGroupMembers, members
.OrderByDescending(item => item.Role)
.ThenBy(item => item.User.NickName, StringComparer.CurrentCultureIgnoreCase)
.Select(item => new ChatGroupMemberItem(item, userId)));
}
private void RebuildInviteCandidates(IEnumerable<ChatFriendInfo> friends)
{
var selectedIds = InviteCandidates.Where(item => item.IsSelected).Select(item => item.Friend.User.Id).ToHashSet();
Replace(InviteCandidates, friends.Select(item => new SelectableFriendItem(item)
{
IsSelected = selectedIds.Contains(item.User.Id)
}));
}
private void UpsertConversation(ChatConversationInfo conversation)
{
var existing = Conversations.FirstOrDefault(item => item.Id == conversation.Id);
if (existing is not null) Conversations.Remove(existing);
Conversations.Insert(0, new ChatConversationItem(conversation));
RebuildNavigationItems();
}
private void RebuildNavigationItems()
{
var selectedKey = SelectedNavigationItem?.Key;
var conversationByFriend = Conversations
.Where(item => item.Conversation.Kind == ChatConversationKind.Direct && item.Conversation.Friend is not null)
.GroupBy(item => item.Conversation.Friend!.Id, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.First().Conversation, StringComparer.Ordinal);
var conversationByGroup = Conversations
.Where(item => item.Conversation.Kind == ChatConversationKind.Group && item.Conversation.Group is not null)
.GroupBy(item => item.Conversation.Group!.Id, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.First().Conversation, StringComparer.Ordinal);
var values = new List<ChatNavigationItem>();
var usedConversationIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var friend in Friends)
{
conversationByFriend.TryGetValue(friend.Friend.User.Id, out var conversation);
values.Add(new ChatNavigationItem(conversation, friend.Friend));
if (conversation is not null) usedConversationIds.Add(conversation.Id);
}
foreach (var group in MyGroups)
{
conversationByGroup.TryGetValue(group.Group.Id, out var conversation);
values.Add(new ChatNavigationItem(conversation, group.Group));
if (conversation is not null) usedConversationIds.Add(conversation.Id);
}
values.AddRange(Conversations
.Where(item => !usedConversationIds.Contains(item.Id))
.Select(item => new ChatNavigationItem(item.Conversation)));
Replace(NavigationItems, values
.OrderByDescending(item => item.LastMessageAtUtc.HasValue)
.ThenByDescending(item => item.LastMessageAtUtc)
.ThenBy(item => item.Title, StringComparer.CurrentCultureIgnoreCase));
SelectedNavigationItem = selectedKey is null
? NavigationItems.FirstOrDefault(item =>
string.Equals(item.Conversation?.Id, SelectedConversation?.Id, StringComparison.Ordinal))
: NavigationItems.FirstOrDefault(item => string.Equals(item.Key, selectedKey, StringComparison.Ordinal));
}
private void AddOrReplaceMessage(ChatMessageInfo message)
{
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
var existing = Messages.FirstOrDefault(item => item.Id == message.Id ||
(!string.IsNullOrWhiteSpace(message.ClientMessageId) && item.Message.ClientMessageId == message.ClientMessageId));
if (existing is not null) Messages.Remove(existing);
var item = new ChatMessageItem(message, currentUserId);
var insertAt = Messages.TakeWhile(existingItem => existingItem.Sequence <= item.Sequence).Count();
Messages.Insert(insertAt, item);
}
private async Task RunBusyAsync(string errorTitle, Func<Task> action)
{
if (!IsLoggedIn || IsBusy) return;
IsBusy = true;
try
{
await action();
}
catch (Exception exception)
{
ShowException(errorTitle, exception);
}
finally
{
IsBusy = false;
}
}
private void ResetState()
{
messageLoadGeneration++;
Conversations.Clear();
NavigationItems.Clear();
RecommendedGroups.Clear();
MyGroups.Clear();
Friends.Clear();
FriendRequests.Clear();
GroupInvitations.Clear();
UserSearchResults.Clear();
Messages.Clear();
InviteCandidates.Clear();
ManagedGroupMembers.Clear();
SelectedConversation = null;
SelectedNavigationItem = null;
IsLobbyOpen = false;
LookupGroupResult = null;
ManagedGroup = null;
IsCreateGroupDialogOpen = false;
IsContactManagementDialogOpen = false;
IsGroupManagementDialogOpen = false;
IsTransferring = false;
TransferProgress = 0;
TransferText = string.Empty;
HasEarlierMessages = false;
nextBeforeSequence = null;
}
private void ShowException(string title, Exception exception) =>
ShowStatus(title, exception.Message, InfoBarSeverity.Error);
private static void ShowStatus(
string title,
string message,
InfoBarSeverity severity,
bool autoClose = false)
{
if (autoClose) return;
DesktopNotificationService.Show(title, message, severity switch
{
InfoBarSeverity.Warning => DesktopNotificationLevel.Warning,
InfoBarSeverity.Error => DesktopNotificationLevel.Error,
_ => DesktopNotificationLevel.Information
});
}
private static void Replace<T>(ObservableCollection<T> target, IEnumerable<T> values)
{
target.Clear();
foreach (var value in values) target.Add(value);
}
}
using System.Collections.ObjectModel;
using System.IO;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using XFEToolBox.Client.Models.Chat;
using XFEToolBox.Client.Profiles.CrossVersionProfiles;
using XFEToolBox.Client.Utilities;
using XFEToolBox.Client.Utilities.Chat;
using XFEToolBox.Client.Utilities.Server;
using XFEToolBox.Core.Chat;
using XFEToolBox.WpfCore.Controls;
namespace XFEToolBox.Client.ViewModel.Chat;
public partial class ChatPageViewModel : ObservableObject
{
private readonly ChatApiClient apiClient = new();
private readonly SemaphoreSlim realtimeEventLock = new(1, 1);
private static readonly JsonSerializerOptions RealtimeJsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private bool initialized;
private long? nextBeforeSequence;
private long messageLoadGeneration;
private CancellationTokenSource sessionCancellation = new();
public ObservableCollection<ChatConversationItem> Conversations { get; } = [];
public ObservableCollection<ChatNavigationItem> NavigationItems { get; } = [];
public ObservableCollection<ChatGroupItem> RecommendedGroups { get; } = [];
public ObservableCollection<ChatGroupItem> MyGroups { get; } = [];
public ObservableCollection<ChatFriendItem> Friends { get; } = [];
public ObservableCollection<ChatFriendRequestItem> FriendRequests { get; } = [];
public ObservableCollection<ChatInvitationItem> GroupInvitations { get; } = [];
public ObservableCollection<ChatUserSearchItem> UserSearchResults { get; } = [];
public ObservableCollection<ChatMessageItem> Messages { get; } = [];
public ObservableCollection<SelectableFriendItem> InviteCandidates { get; } = [];
public ObservableCollection<ChatGroupMemberItem> ManagedGroupMembers { get; } = [];
public event EventHandler<bool>? MessagesChanged;
public event EventHandler<ChatConversationInfo>? CallRequested;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsLoggedOut))]
private bool isLoggedIn = ClientSession.IsLoggedIn;
public bool IsLoggedOut => !IsLoggedIn;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsNotBusy))]
[NotifyPropertyChangedFor(nameof(CanCompose))]
private bool isBusy;
public bool IsNotBusy => !IsBusy;
[ObservableProperty]
private bool isStatusOpen;
[ObservableProperty]
private string statusTitle = string.Empty;
[ObservableProperty]
private string statusMessage = string.Empty;
[ObservableProperty]
private InfoBarSeverity statusSeverity = InfoBarSeverity.Informational;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsConversationSection))]
[NotifyPropertyChangedFor(nameof(IsLobbySection))]
[NotifyPropertyChangedFor(nameof(IsFriendsSection))]
[NotifyPropertyChangedFor(nameof(IsGroupsSection))]
private ChatSection selectedSection = ChatSection.Conversations;
public bool IsConversationSection => SelectedSection == ChatSection.Conversations;
public bool IsLobbySection => SelectedSection == ChatSection.Lobby;
public bool IsFriendsSection => SelectedSection == ChatSection.Friends;
public bool IsGroupsSection => SelectedSection == ChatSection.Groups;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasSelectedConversation))]
[NotifyPropertyChangedFor(nameof(HasNoSelectedConversation))]
[NotifyPropertyChangedFor(nameof(ConversationTitle))]
[NotifyPropertyChangedFor(nameof(ConversationSubtitle))]
[NotifyPropertyChangedFor(nameof(IsSelectedConversationGroup))]
[NotifyPropertyChangedFor(nameof(CanCompose))]
[NotifyPropertyChangedFor(nameof(HasOpenDetail))]
[NotifyPropertyChangedFor(nameof(ShowListPane))]
[NotifyPropertyChangedFor(nameof(ShowDetailPane))]
[NotifyPropertyChangedFor(nameof(ShowBackButton))]
private ChatConversationItem? selectedConversation;
[ObservableProperty]
private ChatNavigationItem? selectedNavigationItem;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsMessageAreaOpen))]
[NotifyPropertyChangedFor(nameof(HasOpenDetail))]
[NotifyPropertyChangedFor(nameof(ShowListPane))]
[NotifyPropertyChangedFor(nameof(ShowDetailPane))]
[NotifyPropertyChangedFor(nameof(ShowBackButton))]
private bool isLobbyOpen;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowListPane))]
[NotifyPropertyChangedFor(nameof(ShowDetailPane))]
[NotifyPropertyChangedFor(nameof(ShowBackButton))]
private bool isSinglePaneMode = SystemProfile.ChatSinglePaneMode;
public bool HasSelectedConversation => SelectedConversation is not null;
public bool HasNoSelectedConversation => SelectedConversation is null;
public bool IsSelectedConversationGroup => SelectedConversation?.Conversation.Kind == ChatConversationKind.Group;
public bool IsMessageAreaOpen => !IsLobbyOpen;
public bool HasOpenDetail => IsLobbyOpen || HasSelectedConversation;
public bool ShowListPane => !IsSinglePaneMode || !HasOpenDetail;
public bool ShowDetailPane => !IsSinglePaneMode || HasOpenDetail;
public bool ShowBackButton => IsSinglePaneMode && HasOpenDetail;
public string ConversationTitle => SelectedConversation?.Title ?? "选择一个会话";
public string ConversationSubtitle => SelectedConversation is null
? "从左侧选择好友、群聊或最近会话"
: SelectedConversation.Conversation.Kind == ChatConversationKind.Group
? $"{SelectedConversation.Conversation.Group?.MemberCount ?? 0} 位成员 · 群号 {SelectedConversation.Conversation.Group?.GroupNumber}"
: $"@{SelectedConversation.Conversation.Friend?.UserName}";
public bool CanCompose => IsLoggedIn && HasSelectedConversation && !IsBusy && !IsTransferring;
[ObservableProperty]
private string composerText = string.Empty;
[ObservableProperty]
private string groupSearchQuery = string.Empty;
[ObservableProperty]
private string groupNumberQuery = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasLookupGroupResult))]
private ChatGroupItem? lookupGroupResult;
public bool HasLookupGroupResult => LookupGroupResult is not null;
[ObservableProperty]
private string userSearchQuery = string.Empty;
[ObservableProperty]
private string friendRequestMessage = "你好,我想添加你为好友。";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanCompose))]
private bool isTransferring;
[ObservableProperty]
private double transferProgress;
[ObservableProperty]
private string transferText = string.Empty;
[ObservableProperty]
private bool hasEarlierMessages;
[ObservableProperty]
private bool isCreateGroupDialogOpen;
[ObservableProperty]
private bool isContactManagementDialogOpen;
[ObservableProperty]
private string newGroupName = string.Empty;
[ObservableProperty]
private string newGroupDescription = string.Empty;
[ObservableProperty]
private bool newGroupIsPublic = true;
[ObservableProperty]
private string newGroupInvitationMessage = "欢迎加入我的群聊。";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanManageSelectedGroup))]
[NotifyPropertyChangedFor(nameof(IsManagedGroupOwner))]
private ChatGroupItem? managedGroup;
[ObservableProperty]
private bool isGroupManagementDialogOpen;
[ObservableProperty]
private string managedGroupName = string.Empty;
[ObservableProperty]
private string managedGroupDescription = string.Empty;
[ObservableProperty]
private bool managedGroupIsPublic;
[ObservableProperty]
private bool managedGroupIsMuted;
public bool CanManageSelectedGroup => ManagedGroup?.CanManage == true;
public bool IsManagedGroupOwner => ManagedGroup?.IsOwner == true;
partial void OnManagedGroupIsMutedChanged(bool value)
{
if (ManagedGroup is null) return;
ChatNotificationPreferences.SetGroupMuted(ManagedGroup.Group.Id, value);
RebuildNavigationItems();
}
public void RefreshLayoutPreference() => IsSinglePaneMode = SystemProfile.ChatSinglePaneMode;
public async Task InitializeAsync()
{
IsLoggedIn = ClientSession.IsLoggedIn;
if (!IsLoggedIn)
{
ResetState();
return;
}
if (initialized) return;
initialized = true;
await RefreshAllAsync();
}
public async Task OnSessionChangedAsync()
{
IsLoggedIn = ClientSession.IsLoggedIn;
if (!IsLoggedIn)
{
initialized = false;
sessionCancellation.Cancel();
sessionCancellation.Dispose();
sessionCancellation = new CancellationTokenSource();
ResetState();
ShowStatus("已退出聊天", "登录后可继续使用好友、群聊与消息功能。", InfoBarSeverity.Informational);
return;
}
initialized = true;
await RefreshAllAsync();
}
public async Task HandleRealtimeEnvelopeAsync(ChatClientRealtimeEnvelope envelope)
{
if (!IsLoggedIn) return;
await realtimeEventLock.WaitAsync();
try
{
if (envelope.Type == "realtime.connected")
{
await RefreshAllCoreAsync(allowWhileBusy: true);
if (SelectedConversation is not null)
await LoadMessagesAsync(reset: true, allowWhileBusy: true);
return;
}
if (!envelope.Type.StartsWith("chat.", StringComparison.Ordinal)) return;
switch (envelope.Type)
{
case "chat.message.created":
{
var message = envelope.Payload.Deserialize<ChatMessageInfo>(RealtimeJsonOptions);
if (message is null) return;
if (string.Equals(SelectedConversation?.Id, message.ConversationId, StringComparison.Ordinal))
{
AddOrReplaceMessage(message);
MessagesChanged?.Invoke(this, true);
}
await RefreshConversationsAsync();
break;
}
case "chat.friend.requested":
case "chat.friend.request.updated":
case "chat.friend.deleted":
await RefreshFriendDataAsync();
await RefreshConversationsAsync();
break;
case "chat.group.invited":
case "chat.group.invitation.updated":
case "chat.group.updated":
case "chat.group.members.updated":
await RefreshGroupsAsync();
await RefreshConversationsAsync();
if (ManagedGroup is not null)
await LoadManagedGroupMembersAsync();
break;
}
}
catch (Exception exception)
{
ShowException("实时同步失败", exception);
}
finally
{
realtimeEventLock.Release();
}
}
[RelayCommand]
private void SelectSection(ChatSection section) => SelectedSection = section;
[RelayCommand]
private async Task OpenLobbyAsync()
{
IsLobbyOpen = true;
SelectedNavigationItem = null;
SelectedConversation = null;
Messages.Clear();
await RefreshRecommendedGroupsAsync();
}
[RelayCommand]
private void CloseDetail()
{
IsLobbyOpen = false;
SelectedConversation = null;
SelectedNavigationItem = null;
Messages.Clear();
nextBeforeSequence = null;
HasEarlierMessages = false;
}
[RelayCommand]
private void OpenContactManagementDialog()
{
IsCreateGroupDialogOpen = false;
IsContactManagementDialogOpen = true;
}
[RelayCommand]
private void CloseContactManagementDialog() => IsContactManagementDialogOpen = false;
[RelayCommand]
private async Task OpenNavigationItemAsync(ChatNavigationItem? item)
{
if (item is null) return;
SelectedNavigationItem = item;
if (item.Conversation is not null)
{
await OpenConversationAsync(new ChatConversationItem(item.Conversation));
return;
}
if (item.Friend is not null)
{
await OpenFriendConversationAsync(new ChatFriendItem(item.Friend));
return;
}
if (item.Group is not null)
await OpenGroupConversationCoreAsync(item.Group);
}
[RelayCommand]
private Task RefreshAllAsync() => RefreshAllCoreAsync(allowWhileBusy: false);
private async Task RefreshAllCoreAsync(bool allowWhileBusy)
{
if (!IsLoggedIn || (IsBusy && !allowWhileBusy)) return;
var ownsBusyState = !IsBusy;
if (ownsBusyState) IsBusy = true;
try
{
var conversations = await apiClient.GetConversationsAsync();
var friends = await apiClient.GetFriendsAsync();
var requests = await apiClient.GetFriendRequestsAsync();
var myGroups = await apiClient.GetMyGroupsAsync();
var invitations = await apiClient.GetGroupInvitationsAsync();
var recommended = await apiClient.GetRecommendedGroupsAsync(GroupSearchQuery);
if (!ClientSession.IsLoggedIn) return;
Replace(Conversations, conversations.Select(item => new ChatConversationItem(item)));
Replace(Friends, friends.Select(item => new ChatFriendItem(item)));
Replace(FriendRequests, requests.Select(item => new ChatFriendRequestItem(item)));
Replace(MyGroups, myGroups.Select(item => new ChatGroupItem(item)));
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
Replace(GroupInvitations, invitations.Select(item => new ChatInvitationItem(item, currentUserId)));
Replace(RecommendedGroups, recommended.Select(item => new ChatGroupItem(item)));
RebuildInviteCandidates(friends);
if (SelectedConversation is not null)
{
var refreshed = Conversations.FirstOrDefault(item => item.Id == SelectedConversation.Id);
if (refreshed is not null) SelectedConversation = refreshed;
}
RebuildNavigationItems();
ShowStatus("聊天已同步", "好友、群聊与最近会话已刷新。", InfoBarSeverity.Success, autoClose: true);
}
catch (Exception exception)
{
ShowException("无法刷新聊天", exception);
}
finally
{
if (ownsBusyState) IsBusy = false;
}
}
[RelayCommand]
private async Task RefreshRecommendedGroupsAsync()
{
if (!IsLoggedIn || IsBusy) return;
IsBusy = true;
try
{
var groups = await apiClient.GetRecommendedGroupsAsync(GroupSearchQuery);
Replace(RecommendedGroups, groups.Select(item => new ChatGroupItem(item)));
}
catch (Exception exception)
{
ShowException("无法加载大厅", exception);
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task LookupGroupAsync()
{
if (string.IsNullOrWhiteSpace(GroupNumberQuery))
{
ShowStatus("请输入群号", "私密群聊不会出现在大厅中,需要输入准确群号。", InfoBarSeverity.Warning);
return;
}
await RunBusyAsync("查找群聊失败", async () =>
{
LookupGroupResult = new ChatGroupItem(await apiClient.LookupGroupAsync(GroupNumberQuery));
});
}
[RelayCommand]
private async Task JoinOrOpenGroupAsync(ChatGroupItem? item)
{
if (item is null) return;
try
{
var group = item.Group;
if (!item.IsJoined)
{
IsBusy = true;
group = await apiClient.JoinGroupAsync(group.GroupNumber);
await RefreshGroupsAsync();
ShowStatus("已加入群聊", $"你现在是“{group.Name}”的成员。", InfoBarSeverity.Success);
}
await OpenGroupConversationCoreAsync(group);
}
catch (Exception exception)
{
ShowException("无法进入群聊", exception);
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task OpenConversationAsync(ChatConversationItem? item)
{
if (item is null) return;
SelectedConversation = item;
IsLobbyOpen = false;
SelectedNavigationItem = NavigationItems.FirstOrDefault(entry =>
string.Equals(entry.Conversation?.Id, item.Id, StringComparison.Ordinal));
await LoadMessagesAsync(reset: true, allowWhileBusy: true);
}
[RelayCommand]
private async Task OpenFriendConversationAsync(ChatFriendItem? item)
{
if (item is null) return;
await RunBusyAsync("无法打开好友会话", async () =>
{
var conversation = await apiClient.OpenDirectConversationAsync(item.Friend.User.Id);
UpsertConversation(conversation);
SelectedConversation = new ChatConversationItem(conversation);
IsLobbyOpen = false;
RebuildNavigationItems();
SelectedNavigationItem = NavigationItems.FirstOrDefault(entry =>
string.Equals(entry.Conversation?.Id, conversation.Id, StringComparison.Ordinal));
SelectedSection = ChatSection.Conversations;
await LoadMessagesAsync(reset: true, allowWhileBusy: true);
});
}
private async Task OpenGroupConversationCoreAsync(ChatGroupSummary group)
{
if (string.IsNullOrWhiteSpace(group.ConversationId))
throw new ChatApiException("服务器尚未为该群创建会话。");
var existing = Conversations.FirstOrDefault(item => item.Id == group.ConversationId);
SelectedConversation = existing ?? new ChatConversationItem(new ChatConversationInfo
{
Id = group.ConversationId,
Kind = ChatConversationKind.Group,
Group = group,
CreatedAtUtc = group.CreatedAtUtc,
UpdatedAtUtc = group.UpdatedAtUtc
});
IsLobbyOpen = false;
RebuildNavigationItems();
SelectedNavigationItem = NavigationItems.FirstOrDefault(entry =>
string.Equals(entry.Conversation?.Id, SelectedConversation.Id, StringComparison.Ordinal));
SelectedSection = ChatSection.Conversations;
await LoadMessagesAsync(reset: true, allowWhileBusy: true);
}
[RelayCommand]
private async Task RefreshConversationAsync()
{
if (SelectedConversation is null) return;
await LoadMessagesAsync(reset: true);
}
[RelayCommand]
private async Task LoadEarlierMessagesAsync()
{
if (!HasEarlierMessages || SelectedConversation is null || IsBusy) return;
await LoadMessagesAsync(reset: false);
}
[RelayCommand]
private async Task SendTextAsync()
{
if (SelectedConversation is null || string.IsNullOrWhiteSpace(ComposerText) || IsBusy) return;
var conversationId = SelectedConversation.Id;
var text = ComposerText.Trim();
IsBusy = true;
try
{
var message = await apiClient.SendMessageAsync(conversationId, ChatMessageType.Text, text);
if (string.Equals(ComposerText.Trim(), text, StringComparison.Ordinal)) ComposerText = string.Empty;
if (string.Equals(SelectedConversation?.Id, conversationId, StringComparison.Ordinal))
{
AddOrReplaceMessage(message);
MessagesChanged?.Invoke(this, true);
}
await RefreshConversationsAsync();
}
catch (Exception exception)
{
ShowException("消息发送失败", exception);
}
finally
{
IsBusy = false;
}
}
public async Task SendFileAsync(string path, CancellationToken cancellationToken = default)
{
if (SelectedConversation is null || IsTransferring) return;
var conversationId = SelectedConversation.Id;
IsTransferring = true;
TransferProgress = 0;
TransferText = $"正在上传 {Path.GetFileName(path)}";
try
{
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, sessionCancellation.Token);
var progress = new Progress<double>(value => TransferProgress = value * 100);
var attachment = await apiClient.UploadAttachmentAsync(path, progress, linkedCancellation.Token);
TransferText = "正在发送文件消息";
var message = await apiClient.SendMessageAsync(
conversationId,
ChatApiClient.ResolveMessageType(attachment),
attachmentId: attachment.Id);
if (string.Equals(SelectedConversation?.Id, conversationId, StringComparison.Ordinal))
{
AddOrReplaceMessage(message);
MessagesChanged?.Invoke(this, true);
}
ShowStatus("文件已发送", attachment.FileName, InfoBarSeverity.Success, autoClose: true);
await RefreshConversationsAsync();
}
catch (OperationCanceledException)
{
ShowStatus("已取消文件发送", Path.GetFileName(path), InfoBarSeverity.Warning);
}
catch (Exception exception)
{
ShowException("文件发送失败", exception);
}
finally
{
IsTransferring = false;
TransferProgress = 0;
TransferText = string.Empty;
}
}
public async Task DownloadAttachmentAsync(ChatAttachmentInfo attachment, string destinationPath, CancellationToken cancellationToken = default)
{
if (IsTransferring) return;
IsTransferring = true;
TransferProgress = 0;
TransferText = $"正在下载 {attachment.FileName}";
try
{
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, sessionCancellation.Token);
var progress = new Progress<double>(value => TransferProgress = value * 100);
await apiClient.DownloadAttachmentAsync(attachment, destinationPath, progress, linkedCancellation.Token);
ShowStatus("文件已下载", destinationPath, InfoBarSeverity.Success);
}
catch (OperationCanceledException)
{
ShowStatus("已取消下载", attachment.FileName, InfoBarSeverity.Warning);
}
catch (Exception exception)
{
ShowException("文件下载失败", exception);
}
finally
{
IsTransferring = false;
TransferProgress = 0;
TransferText = string.Empty;
}
}
[RelayCommand]
private async Task SearchUsersAsync()
{
if (string.IsNullOrWhiteSpace(UserSearchQuery))
{
UserSearchResults.Clear();
return;
}
await RunBusyAsync("搜索用户失败", async () =>
{
var users = await apiClient.SearchUsersAsync(UserSearchQuery);
Replace(UserSearchResults, users.Select(item => new ChatUserSearchItem(item)));
});
}
[RelayCommand]
private async Task SendFriendRequestAsync(ChatUserSearchItem? item)
{
if (item is null) return;
await RunBusyAsync("好友申请发送失败", async () =>
{
await apiClient.SendFriendRequestAsync(item.User.Id, FriendRequestMessage);
ShowStatus("好友申请已发送", $"等待 {item.Name} 处理。", InfoBarSeverity.Success);
await RefreshFriendDataAsync();
});
}
[RelayCommand]
private async Task AcceptFriendRequestAsync(ChatFriendRequestItem? item) =>
await RespondToFriendRequestAsync(item, true);
[RelayCommand]
private async Task RejectFriendRequestAsync(ChatFriendRequestItem? item) =>
await RespondToFriendRequestAsync(item, false);
private async Task RespondToFriendRequestAsync(ChatFriendRequestItem? item, bool accept)
{
if (item is null || !item.CanRespond) return;
await RunBusyAsync("好友申请处理失败", async () =>
{
await apiClient.RespondToFriendRequestAsync(item.Request.Id, accept);
await RefreshFriendDataAsync();
await RefreshConversationsAsync();
ShowStatus(accept ? "已添加好友" : "已拒绝申请", item.Name, accept ? InfoBarSeverity.Success : InfoBarSeverity.Informational);
});
}
public async Task DeleteFriendAsync(ChatFriendItem item)
{
await RunBusyAsync("删除好友失败", async () =>
{
await apiClient.DeleteFriendAsync(item.Friend.User.Id);
await RefreshFriendDataAsync();
await RefreshConversationsAsync();
if (SelectedConversation?.Conversation.Friend?.Id == item.Friend.User.Id)
{
SelectedConversation = null;
Messages.Clear();
}
ShowStatus("已删除好友", item.Name, InfoBarSeverity.Informational);
});
}
[RelayCommand]
private async Task AcceptGroupInvitationAsync(ChatInvitationItem? item) =>
await RespondToGroupInvitationAsync(item, true);
[RelayCommand]
private async Task RejectGroupInvitationAsync(ChatInvitationItem? item) =>
await RespondToGroupInvitationAsync(item, false);
[RelayCommand]
private async Task AcceptMessageInvitationAsync(ChatMessageItem? item)
{
if (item?.CanRespondToInvitation != true || item.Message.GroupInvitation is not { } invitation) return;
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
await RespondToGroupInvitationAsync(new ChatInvitationItem(invitation, currentUserId), true);
await LoadMessagesAsync(reset: true);
}
private async Task RespondToGroupInvitationAsync(ChatInvitationItem? item, bool accept)
{
if (item is null || !item.CanRespond) return;
await RunBusyAsync("群邀请处理失败", async () =>
{
await apiClient.RespondToGroupInvitationAsync(item.Invitation.Id, accept);
await RefreshGroupsAsync();
await RefreshConversationsAsync();
ShowStatus(accept ? "已加入群聊" : "已拒绝群邀请", item.GroupName,
accept ? InfoBarSeverity.Success : InfoBarSeverity.Informational);
});
}
[RelayCommand]
private void OpenCreateGroupDialog()
{
IsContactManagementDialogOpen = false;
NewGroupName = string.Empty;
NewGroupDescription = string.Empty;
NewGroupIsPublic = true;
NewGroupInvitationMessage = "欢迎加入我的群聊。";
foreach (var item in InviteCandidates) item.IsSelected = false;
IsCreateGroupDialogOpen = true;
}
[RelayCommand]
private void CloseCreateGroupDialog() => IsCreateGroupDialogOpen = false;
[RelayCommand]
private async Task CreateGroupAsync()
{
if (string.IsNullOrWhiteSpace(NewGroupName))
{
ShowStatus("群名称不能为空", "请为新群聊填写一个名称。", InfoBarSeverity.Warning);
return;
}
await RunBusyAsync("创建群聊失败", async () =>
{
var group = await apiClient.CreateGroupAsync(NewGroupName, NewGroupDescription, NewGroupIsPublic);
var invited = 0;
foreach (var friend in InviteCandidates.Where(item => item.IsSelected))
{
try
{
await apiClient.InviteToGroupAsync(group.Id, friend.Friend.User.Id, NewGroupInvitationMessage);
invited++;
}
catch
{
// The group is already valid; individual invitation failures are reported in the final status.
}
}
IsCreateGroupDialogOpen = false;
await RefreshGroupsAsync();
await RefreshConversationsAsync();
ShowStatus("群聊已创建", invited == 0 ? $"群号 {group.GroupNumber}" : $"群号 {group.GroupNumber} · 已邀请 {invited} 位好友", InfoBarSeverity.Success);
await OpenGroupConversationCoreAsync(group);
});
}
[RelayCommand]
private async Task OpenGroupManagementAsync()
{
var group = SelectedConversation?.Conversation.Group;
if (group is null) return;
ManagedGroup = new ChatGroupItem(group);
ManagedGroupName = group.Name;
ManagedGroupDescription = group.Description;
ManagedGroupIsPublic = group.Visibility == ChatGroupVisibility.Public;
ManagedGroupIsMuted = ChatNotificationPreferences.IsGroupMuted(group.Id);
IsGroupManagementDialogOpen = true;
await RunBusyAsync("群成员加载失败", LoadManagedGroupMembersAsync);
}
[RelayCommand]
private void CloseGroupManagementDialog()
{
IsGroupManagementDialogOpen = false;
ManagedGroupMembers.Clear();
ManagedGroup = null;
}
[RelayCommand]
private async Task UpdateManagedGroupAsync()
{
if (ManagedGroup is null || !CanManageSelectedGroup) return;
await RunBusyAsync("群资料保存失败", async () =>
{
var updated = await apiClient.UpdateGroupAsync(
ManagedGroup.Group.Id, ManagedGroupName, ManagedGroupDescription, ManagedGroupIsPublic);
ManagedGroup = new ChatGroupItem(updated);
await RefreshGroupsAsync();
await RefreshConversationsAsync();
ShowStatus("群资料已保存", updated.Name, InfoBarSeverity.Success);
});
}
public async Task LeaveManagedGroupAsync()
{
if (ManagedGroup is null) return;
await RunBusyAsync("退出群聊失败", async () =>
{
var id = ManagedGroup.Group.Id;
await apiClient.LeaveGroupAsync(id);
ChatNotificationPreferences.SetGroupMuted(id, false);
IsGroupManagementDialogOpen = false;
ManagedGroup = null;
ManagedGroupMembers.Clear();
SelectedConversation = null;
Messages.Clear();
await RefreshGroupsAsync();
await RefreshConversationsAsync();
ShowStatus("已退出群聊", "该群已从你的群聊列表中移除。", InfoBarSeverity.Informational);
});
}
public async Task RemoveManagedGroupMemberAsync(ChatGroupMemberItem member)
{
if (ManagedGroup is null || !CanManageSelectedGroup || member.IsCurrentUser || member.IsOwner) return;
await RunBusyAsync("移除成员失败", async () =>
{
await apiClient.RemoveGroupMemberAsync(ManagedGroup.Group.Id, member.Member.User.Id);
await LoadManagedGroupMembersAsync();
});
}
[RelayCommand]
private async Task ToggleManagedGroupMemberRoleAsync(ChatGroupMemberItem? member)
{
if (ManagedGroup is null || !IsManagedGroupOwner || member is null || member.IsCurrentUser || member.IsOwner) return;
var targetRole = member.Member.Role == ChatGroupRole.Administrator
? ChatGroupRole.Member
: ChatGroupRole.Administrator;
await RunBusyAsync("成员角色修改失败", async () =>
{
await apiClient.SetGroupMemberRoleAsync(ManagedGroup.Group.Id, member.Member.User.Id, targetRole);
await LoadManagedGroupMembersAsync();
});
}
public async Task TransferManagedGroupOwnershipAsync(ChatGroupMemberItem member)
{
if (ManagedGroup is null || !IsManagedGroupOwner || member.IsCurrentUser) return;
await RunBusyAsync("转让群聊失败", async () =>
{
await apiClient.TransferGroupOwnershipAsync(ManagedGroup.Group.Id, member.Member.User.Id);
await RefreshGroupsAsync();
IsGroupManagementDialogOpen = false;
ShowStatus("群主已转让", $"{member.Name} 现在是群主。", InfoBarSeverity.Success);
});
}
[RelayCommand]
private void StartCall()
{
if (SelectedConversation is not null) CallRequested?.Invoke(this, SelectedConversation.Conversation);
}
private async Task LoadMessagesAsync(bool reset, bool allowWhileBusy = false)
{
if (SelectedConversation is null || !IsLoggedIn || (IsBusy && !allowWhileBusy)) return;
var conversationId = SelectedConversation.Id;
var loadGeneration = reset ? ++messageLoadGeneration : messageLoadGeneration;
if (reset)
{
Messages.Clear();
nextBeforeSequence = null;
HasEarlierMessages = false;
}
var ownsBusyState = !IsBusy;
if (ownsBusyState) IsBusy = true;
try
{
var page = await apiClient.GetMessageHistoryAsync(conversationId, reset ? null : nextBeforeSequence);
if (messageLoadGeneration != loadGeneration ||
!string.Equals(SelectedConversation?.Id, conversationId, StringComparison.Ordinal)) return;
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
var incoming = page.Items
.OrderBy(item => item.Sequence)
.Select(item => new ChatMessageItem(item, currentUserId))
.ToArray();
if (reset)
{
Replace(Messages, incoming);
}
else
{
for (var index = incoming.Length - 1; index >= 0; index--)
{
if (Messages.All(existing => existing.Id != incoming[index].Id))
Messages.Insert(0, incoming[index]);
}
}
nextBeforeSequence = page.NextBeforeSequence;
HasEarlierMessages = page.HasMore;
MessagesChanged?.Invoke(this, reset);
}
catch (Exception exception)
{
ShowException("消息加载失败", exception);
}
finally
{
if (ownsBusyState) IsBusy = false;
}
}
private async Task RefreshConversationsAsync()
{
var selectedId = SelectedConversation?.Id;
var conversations = await apiClient.GetConversationsAsync();
Replace(Conversations, conversations.Select(item => new ChatConversationItem(item)));
if (selectedId is not null)
{
SelectedConversation = Conversations.FirstOrDefault(item => item.Id == selectedId);
if (SelectedConversation is null)
{
Messages.Clear();
nextBeforeSequence = null;
HasEarlierMessages = false;
}
}
RebuildNavigationItems();
}
private async Task RefreshFriendDataAsync()
{
var friends = await apiClient.GetFriendsAsync();
var requests = await apiClient.GetFriendRequestsAsync();
Replace(Friends, friends.Select(item => new ChatFriendItem(item)));
Replace(FriendRequests, requests.Select(item => new ChatFriendRequestItem(item)));
RebuildInviteCandidates(friends);
RebuildNavigationItems();
}
private async Task RefreshGroupsAsync()
{
var groups = await apiClient.GetMyGroupsAsync();
var invitations = await apiClient.GetGroupInvitationsAsync();
var recommended = await apiClient.GetRecommendedGroupsAsync(GroupSearchQuery);
Replace(MyGroups, groups.Select(item => new ChatGroupItem(item)));
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
Replace(GroupInvitations, invitations.Select(item => new ChatInvitationItem(item, currentUserId)));
Replace(RecommendedGroups, recommended.Select(item => new ChatGroupItem(item)));
if (LookupGroupResult is not null)
{
var refreshed = groups.FirstOrDefault(item => item.Id == LookupGroupResult.Group.Id);
if (refreshed is not null) LookupGroupResult = new ChatGroupItem(refreshed);
}
RebuildNavigationItems();
}
private async Task LoadManagedGroupMembersAsync()
{
if (ManagedGroup is null) return;
var members = await apiClient.GetGroupMembersAsync(ManagedGroup.Group.Id);
var userId = ClientSession.CurrentUser?.Id ?? string.Empty;
Replace(ManagedGroupMembers, members
.OrderByDescending(item => item.Role)
.ThenBy(item => item.User.NickName, StringComparer.CurrentCultureIgnoreCase)
.Select(item => new ChatGroupMemberItem(item, userId)));
}
private void RebuildInviteCandidates(IEnumerable<ChatFriendInfo> friends)
{
var selectedIds = InviteCandidates.Where(item => item.IsSelected).Select(item => item.Friend.User.Id).ToHashSet();
Replace(InviteCandidates, friends.Select(item => new SelectableFriendItem(item)
{
IsSelected = selectedIds.Contains(item.User.Id)
}));
}
private void UpsertConversation(ChatConversationInfo conversation)
{
var existing = Conversations.FirstOrDefault(item => item.Id == conversation.Id);
if (existing is not null) Conversations.Remove(existing);
Conversations.Insert(0, new ChatConversationItem(conversation));
RebuildNavigationItems();
}
private void RebuildNavigationItems()
{
var selectedKey = SelectedNavigationItem?.Key;
var conversationByFriend = Conversations
.Where(item => item.Conversation.Kind == ChatConversationKind.Direct && item.Conversation.Friend is not null)
.GroupBy(item => item.Conversation.Friend!.Id, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.First().Conversation, StringComparer.Ordinal);
var conversationByGroup = Conversations
.Where(item => item.Conversation.Kind == ChatConversationKind.Group && item.Conversation.Group is not null)
.GroupBy(item => item.Conversation.Group!.Id, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.First().Conversation, StringComparer.Ordinal);
var values = new List<ChatNavigationItem>();
var usedConversationIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var friend in Friends)
{
conversationByFriend.TryGetValue(friend.Friend.User.Id, out var conversation);
values.Add(new ChatNavigationItem(conversation, friend.Friend));
if (conversation is not null) usedConversationIds.Add(conversation.Id);
}
foreach (var group in MyGroups)
{
conversationByGroup.TryGetValue(group.Group.Id, out var conversation);
values.Add(new ChatNavigationItem(conversation, group.Group));
if (conversation is not null) usedConversationIds.Add(conversation.Id);
}
values.AddRange(Conversations
.Where(item => !usedConversationIds.Contains(item.Id))
.Select(item => new ChatNavigationItem(item.Conversation)));
Replace(NavigationItems, values
.OrderByDescending(item => item.LastMessageAtUtc.HasValue)
.ThenByDescending(item => item.LastMessageAtUtc)
.ThenBy(item => item.Title, StringComparer.CurrentCultureIgnoreCase));
SelectedNavigationItem = selectedKey is null
? NavigationItems.FirstOrDefault(item =>
string.Equals(item.Conversation?.Id, SelectedConversation?.Id, StringComparison.Ordinal))
: NavigationItems.FirstOrDefault(item => string.Equals(item.Key, selectedKey, StringComparison.Ordinal));
}
private void AddOrReplaceMessage(ChatMessageInfo message)
{
var currentUserId = ClientSession.CurrentUser?.Id ?? string.Empty;
var existing = Messages.FirstOrDefault(item => item.Id == message.Id ||
(!string.IsNullOrWhiteSpace(message.ClientMessageId) && item.Message.ClientMessageId == message.ClientMessageId));
if (existing is not null) Messages.Remove(existing);
var item = new ChatMessageItem(message, currentUserId);
var insertAt = Messages.TakeWhile(existingItem => existingItem.Sequence <= item.Sequence).Count();
Messages.Insert(insertAt, item);
}
private async Task RunBusyAsync(string errorTitle, Func<Task> action)
{
if (!IsLoggedIn || IsBusy) return;
IsBusy = true;
try
{
await action();
}
catch (Exception exception)
{
ShowException(errorTitle, exception);
}
finally
{
IsBusy = false;
}
}
private void ResetState()
{
messageLoadGeneration++;
Conversations.Clear();
NavigationItems.Clear();
RecommendedGroups.Clear();
MyGroups.Clear();
Friends.Clear();
FriendRequests.Clear();
GroupInvitations.Clear();
UserSearchResults.Clear();
Messages.Clear();
InviteCandidates.Clear();
ManagedGroupMembers.Clear();
SelectedConversation = null;
SelectedNavigationItem = null;
IsLobbyOpen = false;
LookupGroupResult = null;
ManagedGroup = null;
IsCreateGroupDialogOpen = false;
IsContactManagementDialogOpen = false;
IsGroupManagementDialogOpen = false;
IsTransferring = false;
TransferProgress = 0;
TransferText = string.Empty;
HasEarlierMessages = false;
nextBeforeSequence = null;
}
private void ShowException(string title, Exception exception) =>
ShowStatus(title, exception.Message, InfoBarSeverity.Error);
private static void ShowStatus(
string title,
string message,
InfoBarSeverity severity,
bool autoClose = false)
{
if (autoClose) return;
DesktopNotificationService.Show(title, message, severity switch
{
InfoBarSeverity.Warning => DesktopNotificationLevel.Warning,
InfoBarSeverity.Error => DesktopNotificationLevel.Error,
_ => DesktopNotificationLevel.Information
});
}
private static void Replace<T>(ObservableCollection<T> target, IEnumerable<T> values)
{
target.Clear();
foreach (var value in values) target.Add(value);
}
}