From cd9c4b961cef781edcd1361db7b7e40d2ec281e3 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Tue, 19 May 2026 22:55:44 +0700 Subject: [PATCH 01/26] docs: add UI fixes design spec Covers avatar fallback, image staging pending panel, username display, and message alignment changes. --- .../2026-05-19-chatbox-ui-fixes-design.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-chatbox-ui-fixes-design.md diff --git a/docs/superpowers/specs/2026-05-19-chatbox-ui-fixes-design.md b/docs/superpowers/specs/2026-05-19-chatbox-ui-fixes-design.md new file mode 100644 index 0000000..d108441 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-chatbox-ui-fixes-design.md @@ -0,0 +1,186 @@ +# ChatBox UI Fixes Design Spec +**Date:** 2026-05-19 +**Topic:** Avatar fallback, Image staging, Username display, Message alignment + +--- + +## 1. Issue Summary + +| # | Issue | Current | Desired | +|---|-------|---------|---------| +| 1 | Avatar fallback missing | Empty circle when no avatar uploaded | Show first letter of display name as initials | +| 2 | Image paste sends immediately | Ctrl+V uploads and sends right away | Stage up to 10 images inline as "drafts" until Enter is pressed | +| 3 | Sender name hardcoded as "Me" | Messages from self show "Me" | Show user's actual display name from profile | +| 4 | My messages not on left | My messages appear same row as others | My messages should align left, others align right | + +--- + +## 2. Avatar Fallback + +### Current State +- `AvatarBase64` is empty string when no avatar uploaded +- XAML binds `ImageBrush` to `AvatarBase64` → shows nothing when empty +- No fallback UI element for initials + +### Design +- Use `Ellipse` with solid color fill `#5865F2` as base layer +- Overlay `TextBlock` with first letter of display name (uppercase, white, bold) +- When `AvatarBase64` is non-empty → hide initials, show image instead +- Logic via `IValueConverter` or XAML triggers + +### Implementation +- Create `AvatarInitialsConverter : IValueConverter` that: + - Input: `AvatarBase64` string + - Output: `Visibility` → `Collapsed` if Base64 non-empty, `Visible` if empty +- Also need a converter for the image visibility (inverse) +- Initials text bound to sender name's first letter + +--- + +## 3. Image Staging (Pending Panel Approach) + +### Current State +- `HandleImagePaste()` in `MainWindow.xaml.cs` (line 651): + 1. Saves image to TempPaste folder + 2. Creates `ChatMessage` immediately + 3. Calls `_fileClient.UploadFileAsync()` immediately + 4. Calls `_connectionManager.SendMessageAsync("FILE_READY|...")` immediately + +### Design (Approach A - Pending Panel) +- **Pending panel**: A dedicated panel at the bottom of chat area, above the message input +- **Draft list**: `_pendingImages : List` (max 10) +- When Ctrl+V image detected: + 1. Save image to TempPaste (same as before) + 2. Create `ChatMessage` with `IsDraft = true` and `Sender = _displayName` + 3. Add to `_pendingImages` list and display in pending panel (with preview thumbnails) + 4. **DO NOT** upload or send yet +- When Enter pressed in message input: + 1. If `_pendingImages.Count > 0`, iterate and upload+send each + 2. Clear `_pendingImages` after sending +- Pending panel UI: + - Horizontal `WrapPanel` with small thumbnail previews (48x48 or 64x64) + - Small "X" button on each thumbnail to remove from queue + - Count indicator: "3 images pending" + - Panel background: subtle gray `#F2F3F5` + - Panel height: auto, max ~100px with scroll if needed + +### Max 10 Images +- Before adding new draft, check `_pendingImages.Count >= 10` +- If full, show toast: "Maximum 10 images pending. Send or remove some first." + +### Visual Difference: Draft vs Sent +| State | Border | Opacity | Progress | Send Indicator | +|-------|--------|---------|----------|---------------| +| Draft (thumbnail) | Solid purple | 1.0 | None | Small pending icon | +| Uploading | Solid | 1.0 | ProgressBar on thumbnail | Spinner overlay | +| Sent | Normal | 1.0 | Hidden | Normal message in chat | + +### Pending Panel Layout (XAML concept) +``` + + + + + +``` + +### Draft Thumbnail Item +- Small `Image` (64x64) with rounded corners +- Purple border when draft +- Red "X" button overlay top-right +- Progress ring when uploading +- Tooltip: filename + +--- + +## 4. Username Display + +### Current State +- `HandleImagePaste()` line 677: `Sender = "Me"` hardcoded +- `txtUsername` TextBox exists with default "User" +- No persistent user profile with display name + +### Design +- Store display name from `txtUsername.Text` when it changes +- Use stored display name for all new messages (`Sender = _displayName`) +- `HandleImagePaste()`: change `Sender = "Me"` → `Sender = _displayName` +- For incoming messages: use sender name from server packet + +### Implementation +- `_displayName : string` field in MainWindow +- `TxtUsername_TextChanged()`: update `_displayName` +- `HandleImagePaste()`: use `_displayName` instead of "Me" +- `BtnSendChat_Click()`: already uses bound data, just needs `_displayName` for avatar context + +--- + +## 5. Message Alignment (My Messages Left, Others Right) + +### Current State +- All messages in same `ListBox` template, same alignment +- Messages not differentiated by IsMe for horizontal positioning +- "My messages" look the same as others (just colored name) + +### Desired +- My messages (`IsMe = true`) → align to **LEFT** of chat area +- Others' messages (`IsMe = false`) → align to **RIGHT** of chat area +- Avatar position also flips: my avatar on left, other's avatar on right +- This matches standard messenger convention + +### Implementation - Message Wrapper Grid +- Use a wrapper `Grid` for each message item with two columns: + - Col 0: "Start" alignment zone (my messages fill here, others empty) + - Col 1: "End" alignment zone (others' messages fill here, my messages empty) +- For my messages (`IsMe = true`): + - Content `HorizontalAlignment = Left` + - Avatar on left (existing position) + - `Grid.Column = 0` +- For others (`IsMe = false`): + - Content `HorizontalAlignment = Right` + - Avatar on right (move to right side of Grid) + - `Grid.Column = 1` + +### XAML DataTrigger Approach +```xml + + + + + + +``` + +### Visual Change +| Element | Current | New (My Msg) | New (Other Msg) | +|---------|---------|--------------|------------------| +| Horizontal Align | Left (all) | Left | Right | +| Avatar Position | Left | Left | Right | +| Content Align | Left | Left | Right | + +--- + +## 6. File Changes + +### Files to Modify +| File | Changes | +|------|---------| +| `ChatBox.Client/MainWindow.xaml.cs` | Image staging logic, username field, HandleImagePaste update | +| `ChatBox.Client/MainWindow.xaml` | Alignment triggers, avatar fallback, draft visual style | +| `ChatBox.Client/ViewModels/ChatMessage.cs` | Add `IsDraft` property, maybe `PendingIndex` | +| `ChatBox.Client/Converters/Base64ImageConverter.cs` | May need to add/extend converters for avatar fallback | + +### New Files +| File | Purpose | +|------|---------| +| `ChatBox.Client/Converters/AvatarInitialsVisibilityConverter.cs` | Show initials when no avatar | +| `ChatBox.Client/Converters/MessageAlignmentConverter.cs` | Align my messages left, others right | +| `ChatBox.Client/Converters/DraftOpacityConverter.cs` | Dim draft images | + +--- + +## 7. Dependencies +- No database changes +- No network protocol changes (image staging is client-side only) +- Backward compatible with existing messages \ No newline at end of file From 4493a6d70b2304cd45a77d20a8444856453cee75 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Tue, 19 May 2026 22:57:45 +0700 Subject: [PATCH 02/26] docs: add implementation plan for ChatBox UI fixes --- .../plans/2026-05-19-chatbox-ui-fixes-plan.md | 578 ++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-chatbox-ui-fixes-plan.md diff --git a/docs/superpowers/plans/2026-05-19-chatbox-ui-fixes-plan.md b/docs/superpowers/plans/2026-05-19-chatbox-ui-fixes-plan.md new file mode 100644 index 0000000..7127de9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-chatbox-ui-fixes-plan.md @@ -0,0 +1,578 @@ +# ChatBox UI Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 4 UI issues: (1) avatar fallback showing empty circle, (2) image paste sends immediately instead of staging, (3) sender name hardcoded "Me", (4) my messages not on left + +**Architecture:** WPF application with MVVM-light pattern. Avatar fallback via XAML converter. Image staging via pending list + panel. Message alignment via DataTriggers. Username from profile. + +**Tech Stack:** WPF, C#, XAML, IValueConverter, ChatMessage ViewModel + +--- + +## File Map + +### New Files +- `ChatBox.Client/Converters/AvatarInitialsVisibilityConverter.cs` — shows initials when AvatarBase64 is empty +- `ChatBox.Client/Converters/AvatarImageVisibilityConverter.cs` — inverse of above (show image when Base64 non-empty) +- `ChatBox.Client/Converters/MessageAlignmentConverter.cs` — HorizontalAlignment Left for IsMe=true, Right for IsMe=false + +### Modified Files +- `ChatBox.Client/ViewModels/ChatMessage.cs` — add `IsDraft` bool property +- `ChatBox.Client/MainWindow.xaml` — add pending images panel, avatar converters, alignment triggers +- `ChatBox.Client/MainWindow.xaml.cs` — add `_pendingImages` list, staging logic, fix "Me" → username + +--- + +## Task 1: Add IsDraft Property to ChatMessage + +**Files:** +- Modify: `ChatBox.Client/ViewModels/ChatMessage.cs:8-71` + +- [ ] **Step 1: Add IsDraft bool property** + +In `ChatMessage.cs`, add after line 18 (`public bool IsMe { get; set; }`): + +```csharp +private bool _isDraft; +public bool IsDraft +{ + get => _isDraft; + set { _isDraft = value; OnPropertyChanged(nameof(IsDraft)); } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ChatBox.Client/ViewModels/ChatMessage.cs +git commit -m "feat: add IsDraft property to ChatMessage" +``` + +--- + +## Task 2: Create Avatar Converters + +**Files:** +- Create: `ChatBox.Client/Converters/AvatarInitialsVisibilityConverter.cs` +- Create: `ChatBox.Client/Converters/AvatarImageVisibilityConverter.cs` + +- [ ] **Step 1: Create AvatarInitialsVisibilityConverter** + +```csharp +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace ChatBox.Client.Converters +{ + public class AvatarInitialsVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string base64 && !string.IsNullOrEmpty(base64)) + return Visibility.Collapsed; + return Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} +``` + +- [ ] **Step 2: Create AvatarImageVisibilityConverter (inverse)** + +```csharp +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace ChatBox.Client.Converters +{ + public class AvatarImageVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string base64 && !string.IsNullOrEmpty(base64)) + return Visibility.Visible; + return Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add ChatBox.Client/Converters/AvatarInitialsVisibilityConverter.cs ChatBox.Client/Converters/AvatarImageVisibilityConverter.cs +git commit -m "feat: add avatar visibility converters for initials fallback" +``` + +--- + +## Task 3: Create MessageAlignmentConverter + +**Files:** +- Create: `ChatBox.Client/Converters/MessageAlignmentConverter.cs` + +- [ ] **Step 1: Create converter** + +```csharp +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; + +namespace ChatBox.Client.Converters +{ + public class MessageAlignmentConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool isMe && isMe) + return HorizontalAlignment.Left; + return HorizontalAlignment.Right; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ChatBox.Client/Converters/MessageAlignmentConverter.cs +git commit -m "feat: add MessageAlignmentConverter for left/right message positioning" +``` + +--- + +## Task 4: Update MainWindow.xaml with Avatar Fallback + +**Files:** +- Modify: `ChatBox.Client/MainWindow.xaml` — add converters to Window resources, update avatar ellipses + +- [ ] **Step 1: Register converters in Window.Resources** + +Find the `Window.Resources` section in MainWindow.xaml and add: + +```xml + + +``` + +(Note: `converters` namespace prefix likely `xmlns:converters="clr-namespace:ChatBox.Client.Converters"` already exists if Base64ImageConverter is there) + +- [ ] **Step 2: Update message avatar ellipse** + +Find the Ellipse `imgMsgAvatar` in the message template (around line 789). Add an overlay TextBlock for initials: + +```xml + + + + + + + + + + +``` + +Note: We need a `FirstLetterConverter` that takes a string and returns its first character uppercase. Add this to Avatar converters file. + +- [ ] **Step 3: Update footer avatar ellipse** + +Find `imgFooterAvatar` ellipse and apply same pattern with initials overlay. The footer avatar initials already exist as `lblFooterInitials` — keep that pattern but also ensure visibility toggle works. + +- [ ] **Step 4: Add FirstLetterConverter** + +```csharp +public class FirstLetterConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string name && !string.IsNullOrEmpty(name)) + { + char initial = char.ToUpper(name[0]); + return initial.ToString(); + } + return "?"; + } + public object ConvertBack(...) { throw new NotImplementedException(); } +} +``` + +- [ ] **Step 5: Register FirstLetterConverter in Window.Resources** + +```xml + + +--- + +## Task 5: Update MainWindow.xaml with Message Alignment + +**Files:** +- Modify: `ChatBox.Client/MainWindow.xaml` — add DataTriggers for alignment + +- [ ] **Step 1: Add alignment triggers to message ListBox item** + +Find `DataTemplate.Triggers` section (around line 857) and add after existing triggers: + +```xml + + + + + + + +``` + +- [ ] **Step 2: Also add avatar column flip for others** + +In the `DataTrigger` for `IsMe = False`, also flip avatar to right column: + +```xml + + + + + +``` + +Note: The message grid has `Grid.Column="0"` for avatar and `Grid.Column="1"` for content. For others' messages, avatar needs to move to right side. This may require restructuring the message template to use a wrapper Grid with two columns. + +- [ ] **Step 3: Commit** + +--- + +## Task 6: Add Pending Images Panel to XAML + +**Files:** +- Modify: `ChatBox.Client/MainWindow.xaml` — add pending images panel above input area + +- [ ] **Step 1: Find the chat input area** + +Find the `borderInputArea` or `txtChatInput` area where the message input is located. Add a pending panel ABOVE it (inside the chat area, below the message list). + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 2: Commit** + +--- + +## Task 7: Implement Image Staging Logic in MainWindow.xaml.cs + +**Files:** +- Modify: `ChatBox.Client/MainWindow.xaml.cs` + +- [ ] **Step 1: Add fields for pending images** + +After line 128 (`private string _avatarBase64 = "";`), add: + +```csharp +private List _pendingImages = new(); +private string _displayName = ""; +``` + +- [ ] **Step 2: Update LoadOrGenerateConfig to set _displayName** + +In `LoadOrGenerateConfig()`, after line 158-162 (where name handling happens): + +```csharp +_displayName = string.IsNullOrWhiteSpace(txtUsername.Text) ? "User" : txtUsername.Text.Trim(); +``` + +- [ ] **Step 3: Update TxtUsername_TextChanged to also set _displayName** + +In `TxtUsername_TextChanged()` (around line 595), add: + +```csharp +_displayName = string.IsNullOrWhiteSpace(txtUsername.Text) ? "User" : txtUsername.Text.Trim(); +``` + +- [ ] **Step 4: Add pending count update helper method** + +Add near the bottom of the class: + +```csharp +private void UpdatePendingImagesPanel() +{ + int count = _pendingImages.Count; + lblPendingCount.Text = $"{count} image{(count != 1 ? "s" : "")} pending"; + PendingImagesPanel.Visibility = count > 0 ? Visibility.Visible : Visibility.Collapsed; + itemsPendingImages.ItemsSource = null; + itemsPendingImages.ItemsSource = _pendingImages; +} +``` + +- [ ] **Step 5: Add RemovePending handler** + +Add near other button handlers: + +```csharp +private void BtnRemovePending_Click(object sender, RoutedEventArgs e) +{ + if (sender is Button btn && btn.Tag is ChatMessage msg) + { + _pendingImages.Remove(msg); + UpdatePendingImagesPanel(); + } +} +``` + +- [ ] **Step 6: Update HandleImagePaste to stage instead of send** + +Replace `HandleImagePaste()` method body (lines 651-704) to: + +```csharp +private async void HandleImagePaste() +{ + try + { + if (_pendingImages.Count >= 10) + { + MessageBox.Show("Maximum 10 images pending. Send or remove some first.", "Limit Reached", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var image = Clipboard.GetImage(); + if (image == null) return; + + string tempDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TempPaste"); + if (!Directory.Exists(tempDir)) + Directory.CreateDirectory(tempDir); + + string fileName = $"ClipboardImage_{DateTime.Now:yyyyMMdd_HHmmss}.png"; + string filePath = Path.Combine(tempDir, fileName); + + using (var fileStream = new FileStream(filePath, FileMode.Create)) + { + var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); + encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(image)); + encoder.Save(fileStream); + } + + Guid fileId = Guid.NewGuid(); + var fileInfo = new FileInfo(filePath); + + var msg = new ChatMessage + { + Sender = _displayName, + Content = fileInfo.Name, + IsFile = true, + FileId = fileId.ToString(), + FileSize = fileInfo.Length, + AvatarBase64 = _avatarBase64, + IsTransferring = false, + IsMe = true, + Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")), + LocalFilePath = filePath, + IsInImageChannel = true, + IsDraft = true + }; + + _pendingImages.Add(msg); + UpdatePendingImagesPanel(); + } + catch (Exception ex) + { + MessageBox.Show("Failed to paste image: " + ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } +} +``` + +- [ ] **Step 7: Add SendPendingImages method** + +```csharp +private async void SendPendingImages() +{ + if (_pendingImages.Count == 0) return; + + var toSend = _pendingImages.ToList(); + _pendingImages.Clear(); + UpdatePendingImagesPanel(); + + foreach (var msg in toSend) + { + msg.IsDraft = false; + msg.IsTransferring = true; + _channelManager.AllMessages.Add(msg); + _channelManager.RefreshMessageList(); + + try + { + await _fileClient.UploadFileAsync(_connectionManager.ServerIp, msg.LocalFilePath, Guid.Parse(msg.FileId)); + msg.IsTransferring = false; + await _connectionManager.SendMessageAsync($"FILE_READY|{_userId}|{msg.FileId}|{msg.Content}|{msg.FileSize}"); + } + catch (Exception ex) + { + msg.IsTransferring = false; + } + } + + _channelManager.RefreshMessageList(); +} +``` + +- [ ] **Step 8: Hook SendPendingImages to Enter key in message input** + +Find `txtChatInput_KeyDown` or the key handler. When Enter is pressed (without Shift), call `SendPendingImages()` before sending the text message. + +In the key handler around line 600: +```csharp +if (e.Key == Key.Enter && !isShift) +{ + e.Handled = true; + SendPendingImages(); // ADD THIS LINE + string text = txtChatInput.Text.Trim(); + if (!string.IsNullOrEmpty(text)) + { + await BtnSendChat_ClickInternal(text); + } + txtChatInput.Clear(); + return; +} +``` + +- [ ] **Step 9: Fix "Me" → _displayName in HandleImagePaste** + +Already done in Step 6 above. + +- [ ] **Step 10: Commit** + +--- + +## Task 8: Fix "Me" → Display Name in All Message Sending + +**Files:** +- Modify: `ChatBox.Client/MainWindow.xaml.cs` + +- [ ] **Step 1: Find all places where `Sender = "Me"` is set** + +Grep for `Sender = "Me"` — should be in `HandleImagePaste()` which we already fixed, and possibly other file send methods. + +- [ ] **Step 2: Replace any remaining `Sender = "Me"` with `Sender = _displayName`** + +Also check `HandleFileDragDrop()` and `HandleFileSelect()` if they exist. + +- [ ] **Step 3: Commit** + +--- + +## Task 9: Full Integration and Test + +**Files:** +- All modified files + +- [ ] **Step 1: Build the solution** + +Run: `dotnet build ChatBox.sln` (or open in Visual Studio and build) + +- [ ] **Step 2: Fix any compilation errors** + +- [ ] **Step 3: Test avatar fallback** + +1. Launch app without avatar set +2. Verify initials show in avatar circles +3. Set an avatar and verify image shows instead + +- [ ] **Step 4: Test image staging** + +1. Ctrl+V an image — verify it appears in pending panel, NOT in chat +2. Add 2 more images — verify count shows "3 images pending" +3. Press Enter — verify all 3 images send together as real messages + +- [ ] **Step 5: Test username display** + +1. Verify your name shows instead of "Me" on your messages + +- [ ] **Step 6: Test message alignment** + +1. Send a message from another account or check alignment behavior + +- [ ] **Step 7: Commit final** + +--- + +## Spec Coverage Checklist + +| Spec Section | Task(s) | Status | +|---|---|---| +| Avatar fallback (initials) | Task 2, Task 4 | | +| Image staging pending panel (max 10) | Task 6, Task 7 | | +| Username "Me" → display name | Task 7, Task 8 | | +| Message alignment left/right | Task 3, Task 5 | | +| Pending panel removal (X button) | Task 7 (BtnRemovePending) | | +| Send on Enter | Task 7 (SendPendingImages) | | + +## Placeholder Scan + +- [x] No "TBD" or "TODO" in task steps +- [x] All code blocks show actual implementation +- [x] Exact file paths with line number hints +- [x] No "similar to X" references without repeating code \ No newline at end of file From db912f0046cfd175eb265e031f6606d26756a292 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Tue, 19 May 2026 22:59:47 +0700 Subject: [PATCH 03/26] docs: remove message alignment from UI fixes spec and plan User requested to keep messages aligned left only. Updated spec and plan to cover only: - Avatar fallback with initials - Image staging with pending panel (max 10) - Username display name instead of "Me" --- .../plans/2026-05-19-chatbox-ui-fixes-plan.md | 223 ++++++------------ .../2026-05-19-chatbox-ui-fixes-design.md | 53 +---- 2 files changed, 69 insertions(+), 207 deletions(-) diff --git a/docs/superpowers/plans/2026-05-19-chatbox-ui-fixes-plan.md b/docs/superpowers/plans/2026-05-19-chatbox-ui-fixes-plan.md index 7127de9..315ecc4 100644 --- a/docs/superpowers/plans/2026-05-19-chatbox-ui-fixes-plan.md +++ b/docs/superpowers/plans/2026-05-19-chatbox-ui-fixes-plan.md @@ -2,9 +2,9 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Fix 4 UI issues: (1) avatar fallback showing empty circle, (2) image paste sends immediately instead of staging, (3) sender name hardcoded "Me", (4) my messages not on left +**Goal:** Fix 3 UI issues: (1) avatar fallback showing empty circle, (2) image paste sends immediately instead of staging, (3) sender name hardcoded "Me" -**Architecture:** WPF application with MVVM-light pattern. Avatar fallback via XAML converter. Image staging via pending list + panel. Message alignment via DataTriggers. Username from profile. +**Architecture:** WPF application with MVVM-light pattern. Avatar fallback via XAML converter with layered Ellipse+TextBlock. Image staging via pending list + panel above input. Username from profile field. **Tech Stack:** WPF, C#, XAML, IValueConverter, ChatMessage ViewModel @@ -14,12 +14,11 @@ ### New Files - `ChatBox.Client/Converters/AvatarInitialsVisibilityConverter.cs` — shows initials when AvatarBase64 is empty -- `ChatBox.Client/Converters/AvatarImageVisibilityConverter.cs` — inverse of above (show image when Base64 non-empty) -- `ChatBox.Client/Converters/MessageAlignmentConverter.cs` — HorizontalAlignment Left for IsMe=true, Right for IsMe=false +- `ChatBox.Client/Converters/FirstLetterConverter.cs` — returns first character uppercase of a name string ### Modified Files - `ChatBox.Client/ViewModels/ChatMessage.cs` — add `IsDraft` bool property -- `ChatBox.Client/MainWindow.xaml` — add pending images panel, avatar converters, alignment triggers +- `ChatBox.Client/MainWindow.xaml` — add pending images panel, avatar converters, footer avatar toggle - `ChatBox.Client/MainWindow.xaml.cs` — add `_pendingImages` list, staging logic, fix "Me" → username --- @@ -27,11 +26,11 @@ ## Task 1: Add IsDraft Property to ChatMessage **Files:** -- Modify: `ChatBox.Client/ViewModels/ChatMessage.cs:8-71` +- Modify: `ChatBox.Client/ViewModels/ChatMessage.cs` - [ ] **Step 1: Add IsDraft bool property** -In `ChatMessage.cs`, add after line 18 (`public bool IsMe { get; set; }`): +In `ChatMessage.cs`, add after `public bool IsMe { get; set; }`: ```csharp private bool _isDraft; @@ -55,7 +54,7 @@ git commit -m "feat: add IsDraft property to ChatMessage" **Files:** - Create: `ChatBox.Client/Converters/AvatarInitialsVisibilityConverter.cs` -- Create: `ChatBox.Client/Converters/AvatarImageVisibilityConverter.cs` +- Create: `ChatBox.Client/Converters/FirstLetterConverter.cs` - [ ] **Step 1: Create AvatarInitialsVisibilityConverter** @@ -84,23 +83,25 @@ namespace ChatBox.Client.Converters } ``` -- [ ] **Step 2: Create AvatarImageVisibilityConverter (inverse)** +- [ ] **Step 2: Create FirstLetterConverter** ```csharp using System; using System.Globalization; -using System.Windows; using System.Windows.Data; namespace ChatBox.Client.Converters { - public class AvatarImageVisibilityConverter : IValueConverter + public class FirstLetterConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - if (value is string base64 && !string.IsNullOrEmpty(base64)) - return Visibility.Visible; - return Visibility.Collapsed; + if (value is string name && !string.IsNullOrEmpty(name)) + { + char initial = char.ToUpper(name[0]); + return initial.ToString(); + } + return "?"; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) @@ -114,55 +115,13 @@ namespace ChatBox.Client.Converters - [ ] **Step 3: Commit** ```bash -git add ChatBox.Client/Converters/AvatarInitialsVisibilityConverter.cs ChatBox.Client/Converters/AvatarImageVisibilityConverter.cs -git commit -m "feat: add avatar visibility converters for initials fallback" -``` - ---- - -## Task 3: Create MessageAlignmentConverter - -**Files:** -- Create: `ChatBox.Client/Converters/MessageAlignmentConverter.cs` - -- [ ] **Step 1: Create converter** - -```csharp -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; - -namespace ChatBox.Client.Converters -{ - public class MessageAlignmentConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is bool isMe && isMe) - return HorizontalAlignment.Left; - return HorizontalAlignment.Right; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add ChatBox.Client/Converters/MessageAlignmentConverter.cs -git commit -m "feat: add MessageAlignmentConverter for left/right message positioning" +git add ChatBox.Client/Converters/AvatarInitialsVisibilityConverter.cs ChatBox.Client/Converters/FirstLetterConverter.cs +git commit -m "feat: add avatar converters for initials fallback and first letter extraction" ``` --- -## Task 4: Update MainWindow.xaml with Avatar Fallback +## Task 3: Update MainWindow.xaml with Avatar Fallback **Files:** - Modify: `ChatBox.Client/MainWindow.xaml` — add converters to Window resources, update avatar ellipses @@ -173,23 +132,22 @@ Find the `Window.Resources` section in MainWindow.xaml and add: ```xml - + ``` -(Note: `converters` namespace prefix likely `xmlns:converters="clr-namespace:ChatBox.Client.Converters"` already exists if Base64ImageConverter is there) - - [ ] **Step 2: Update message avatar ellipse** -Find the Ellipse `imgMsgAvatar` in the message template (around line 789). Add an overlay TextBlock for initials: +Find the Ellipse `imgMsgAvatar` in the message template (around line 789). Wrap it with a Grid and add initials fallback layer: ```xml + - + @@ -200,82 +158,41 @@ Find the Ellipse `imgMsgAvatar` in the message template (around line 789). Add a ``` -Note: We need a `FirstLetterConverter` that takes a string and returns its first character uppercase. Add this to Avatar converters file. - - [ ] **Step 3: Update footer avatar ellipse** -Find `imgFooterAvatar` ellipse and apply same pattern with initials overlay. The footer avatar initials already exist as `lblFooterInitials` — keep that pattern but also ensure visibility toggle works. - -- [ ] **Step 4: Add FirstLetterConverter** - -```csharp -public class FirstLetterConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is string name && !string.IsNullOrEmpty(name)) - { - char initial = char.ToUpper(name[0]); - return initial.ToString(); - } - return "?"; - } - public object ConvertBack(...) { throw new NotImplementedException(); } -} -``` - -- [ ] **Step 5: Register FirstLetterConverter in Window.Resources** +Find the footer avatar area. The footer already has `lblFooterInitials` and `lblAvatarInitials` — ensure the initials TextBlocks are shown when AvatarBase64 is empty by adding visibility binding: ```xml - - ---- - -## Task 5: Update MainWindow.xaml with Message Alignment - -**Files:** -- Modify: `ChatBox.Client/MainWindow.xaml` — add DataTriggers for alignment - -- [ ] **Step 1: Add alignment triggers to message ListBox item** - -Find `DataTemplate.Triggers` section (around line 857) and add after existing triggers: - -```xml - - - - - - - + ``` -- [ ] **Step 2: Also add avatar column flip for others** - -In the `DataTrigger` for `IsMe = False`, also flip avatar to right column: +Actually, for footer avatar, since the initials already exist as separate labels (`lblAvatarInitials`, `lblFooterInitials`), we just need to ensure they are visible when `_avatarBase64` is empty and hidden when it has a value. The existing code already sets initials from username — we just need to add the visibility toggle. +For the footer Ellipse fill (avatar image), add `Visibility` binding: ```xml - - - - - + + + + + ``` -Note: The message grid has `Grid.Column="0"` for avatar and `Grid.Column="1"` for content. For others' messages, avatar needs to move to right side. This may require restructuring the message template to use a wrapper Grid with two columns. +And the corresponding initials Ellipse + TextBlock overlay should have visibility toggled. -- [ ] **Step 3: Commit** +- [ ] **Step 4: Commit** --- -## Task 6: Add Pending Images Panel to XAML +## Task 4: Add Pending Images Panel to XAML **Files:** - Modify: `ChatBox.Client/MainWindow.xaml` — add pending images panel above input area - [ ] **Step 1: Find the chat input area** -Find the `borderInputArea` or `txtChatInput` area where the message input is located. Add a pending panel ABOVE it (inside the chat area, below the message list). +Find the `borderInputArea` or input stack area. Add a pending panel ABOVE the input but inside the chat container. ```xml @@ -302,10 +219,10 @@ Find the `borderInputArea` or `txtChatInput` area where the message input is loc Width="64" Height="64" RenderOptions.BitmapScalingMode="HighQuality"/> - + + + + + + + - + diff --git a/ChatBox.Client/MainWindow.xaml.cs b/ChatBox.Client/MainWindow.xaml.cs index 22df9f3..0772f59 100644 --- a/ChatBox.Client/MainWindow.xaml.cs +++ b/ChatBox.Client/MainWindow.xaml.cs @@ -89,6 +89,22 @@ public partial class MainWindow : Window private System.Collections.Generic.List _allMessages = new System.Collections.Generic.List(); private string _currentChannel = "chat"; + public class PendingImage + { + public string LocalFilePath { get; set; } = ""; + public string FileName { get; set; } = ""; + public long FileSize { get; set; } + } + + private System.Collections.ObjectModel.ObservableCollection PendingImages { get; } = new(); + + private void UpdatePendingImagesPanel() + { + itemsPendingImages.ItemsSource = PendingImages.ToList(); + lblPendingCount.Text = $"{PendingImages.Count} image{(PendingImages.Count == 1 ? "" : "s")} pending"; + PendingImagesPanel.Visibility = PendingImages.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + } + public MainWindow() { InitializeComponent(); @@ -1362,6 +1378,15 @@ private void BtnClose_Click(object sender, RoutedEventArgs e) this.Close(); } + private void BtnRemovePending_Click(object sender, RoutedEventArgs e) + { + if (sender is Button btn && btn.Tag is PendingImage pending) + { + PendingImages.Remove(pending); + UpdatePendingImagesPanel(); + } + } + private void ToggleMaximize() { if (this.WindowState == WindowState.Maximized) From d450d964fe733ca70caa1bea0d791a901eb1f857 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Tue, 19 May 2026 23:16:46 +0700 Subject: [PATCH 09/26] feat: implement image staging logic with pending panel --- ChatBox.Client/MainWindow.xaml.cs | 130 +++++++++++++++++------------- 1 file changed, 72 insertions(+), 58 deletions(-) diff --git a/ChatBox.Client/MainWindow.xaml.cs b/ChatBox.Client/MainWindow.xaml.cs index 0772f59..9100724 100644 --- a/ChatBox.Client/MainWindow.xaml.cs +++ b/ChatBox.Client/MainWindow.xaml.cs @@ -72,6 +72,13 @@ public bool IsInImageChannel set { _isInImageChannel = value; OnPropertyChanged(nameof(IsInImageChannel)); } } + private bool _isDraft; + public bool IsDraft + { + get => _isDraft; + set { _isDraft = value; OnPropertyChanged(nameof(IsDraft)); } + } + public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; protected void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName)); } @@ -83,26 +90,21 @@ public partial class MainWindow : Window private string _serverIp = ""; private string _userId = ""; private string _avatarBase64 = ""; + private string _displayName = ""; private CancellationTokenSource _cts = new CancellationTokenSource(); private ChatMessage? _currentTransferMessage; - + private System.Collections.Generic.List _pendingImages = new System.Collections.Generic.List(); + private System.Collections.Generic.List _allMessages = new System.Collections.Generic.List(); private string _currentChannel = "chat"; - public class PendingImage - { - public string LocalFilePath { get; set; } = ""; - public string FileName { get; set; } = ""; - public long FileSize { get; set; } - } - - private System.Collections.ObjectModel.ObservableCollection PendingImages { get; } = new(); - private void UpdatePendingImagesPanel() { - itemsPendingImages.ItemsSource = PendingImages.ToList(); - lblPendingCount.Text = $"{PendingImages.Count} image{(PendingImages.Count == 1 ? "" : "s")} pending"; - PendingImagesPanel.Visibility = PendingImages.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + int count = _pendingImages.Count; + lblPendingCount.Text = $"{count} image{(count != 1 ? "s" : "")} pending"; + PendingImagesPanel.Visibility = count > 0 ? Visibility.Visible : Visibility.Collapsed; + itemsPendingImages.ItemsSource = null; + itemsPendingImages.ItemsSource = _pendingImages; } public MainWindow() @@ -166,6 +168,7 @@ private void LoadOrGenerateConfig() // Set footer details & initials fallback string name = string.IsNullOrWhiteSpace(txtUsername.Text) ? "User" : txtUsername.Text.Trim(); lblFooterUsername.Text = name; + _displayName = name; char initial = name.Length > 0 ? char.ToUpper(name[0]) : 'U'; lblAvatarInitials.Text = initial.ToString(); lblFooterInitials.Text = initial.ToString(); @@ -799,11 +802,12 @@ private void BtnCloseSettings_Click(object sender, RoutedEventArgs e) private void TxtUsername_TextChanged(object sender, TextChangedEventArgs e) { + _displayName = string.IsNullOrWhiteSpace(txtUsername.Text) ? "User" : txtUsername.Text.Trim(); if (lblFooterUsername != null) { - string name = string.IsNullOrWhiteSpace(txtUsername.Text) ? "User" : txtUsername.Text.Trim(); + string name = _displayName; lblFooterUsername.Text = name; - + char initial = name.Length > 0 ? char.ToUpper(name[0]) : 'U'; if (lblAvatarInitials != null) lblAvatarInitials.Text = initial.ToString(); if (lblFooterInitials != null) lblFooterInitials.Text = initial.ToString(); @@ -1260,6 +1264,7 @@ private void TxtInput_PreviewKeyDown(object sender, System.Windows.Input.KeyEven return; } e.Handled = true; + SendPendingImages(); BtnSendChat_Click(this, new RoutedEventArgs()); } else if (e.Key == System.Windows.Input.Key.V && isControl) @@ -1272,25 +1277,29 @@ private void TxtInput_PreviewKeyDown(object sender, System.Windows.Input.KeyEven } } - private async void HandleImagePaste() + private void HandleImagePaste() { try { + if (_pendingImages.Count >= 10) + { + MessageBox.Show("Maximum 10 images pending. Send or remove some first.", "Limit Reached", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + var image = Clipboard.GetImage(); if (image == null) return; - string tempDir = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TempPaste"); - if (!System.IO.Directory.Exists(tempDir)) - { - System.IO.Directory.CreateDirectory(tempDir); - } + string tempDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TempPaste"); + if (!Directory.Exists(tempDir)) + Directory.CreateDirectory(tempDir); string fileName = $"ClipboardImage_{DateTime.Now:yyyyMMdd_HHmmss}.png"; - string filePath = System.IO.Path.Combine(tempDir, fileName); + string filePath = Path.Combine(tempDir, fileName); using (var fileStream = new FileStream(filePath, FileMode.Create)) { - BitmapEncoder encoder = new PngBitmapEncoder(); + var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(image)); encoder.Save(fileStream); } @@ -1298,54 +1307,59 @@ private async void HandleImagePaste() Guid fileId = Guid.NewGuid(); var fileInfo = new FileInfo(filePath); - var msg = new ChatMessage - { - Sender = "Me", - Content = fileInfo.Name, - IsFile = true, - FileId = fileId.ToString(), + var msg = new ChatMessage + { + Sender = _displayName, + Content = fileInfo.Name, + IsFile = true, + FileId = fileId.ToString(), FileSize = fileInfo.Length, AvatarBase64 = _avatarBase64, - IsTransferring = true, - TransferProgress = 0, + IsTransferring = false, IsMe = true, Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")), LocalFilePath = filePath, - IsInImageChannel = true + IsInImageChannel = true, + IsDraft = true }; - - _allMessages.Add(msg); - if (_currentChannel == "images") - { - RefreshImageGallery(); - } - else - { - RefreshMessageList(); - } - _currentTransferMessage = msg; + _pendingImages.Add(msg); + UpdatePendingImagesPanel(); + } + catch (Exception ex) + { + MessageBox.Show("Failed to paste image: " + ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } - await _fileClient.UploadFileAsync(_serverIp, filePath, fileId); + private async void SendPendingImages() + { + if (_pendingImages.Count == 0) return; - msg.IsTransferring = false; - _currentTransferMessage = null; + var toSend = _pendingImages.ToList(); + _pendingImages.Clear(); + UpdatePendingImagesPanel(); - await _chatClient.SendMessageAsync($"FILE_READY|{_userId}|{fileId}|{fileInfo.Name}|{fileInfo.Length}"); - - if (_currentChannel == "images") + foreach (var msg in toSend) + { + msg.IsDraft = false; + msg.IsTransferring = true; + _allMessages.Add(msg); + RefreshMessageList(); + + try { - RefreshImageGallery(); + await _fileClient.UploadFileAsync(_serverIp, msg.LocalFilePath, Guid.Parse(msg.FileId)); + msg.IsTransferring = false; + await _chatClient.SendMessageAsync($"FILE_READY|{_userId}|{msg.FileId}|{msg.Content}|{msg.FileSize}"); } - else + catch { - RefreshMessageList(); + msg.IsTransferring = false; } } - catch (Exception ex) - { - MessageBox.Show("Failed to paste image: " + ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); - } + + RefreshMessageList(); } private void Topbar_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e) @@ -1380,9 +1394,9 @@ private void BtnClose_Click(object sender, RoutedEventArgs e) private void BtnRemovePending_Click(object sender, RoutedEventArgs e) { - if (sender is Button btn && btn.Tag is PendingImage pending) + if (sender is Button btn && btn.Tag is ChatMessage msg) { - PendingImages.Remove(pending); + _pendingImages.Remove(msg); UpdatePendingImagesPanel(); } } From 1ad72b0ff12bce449bd916bddfa49a85ef501317 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Tue, 19 May 2026 23:18:37 +0700 Subject: [PATCH 10/26] feat: replace hardcoded 'Me' with _displayName in message sender --- ChatBox.Client/MainWindow.xaml.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ChatBox.Client/MainWindow.xaml.cs b/ChatBox.Client/MainWindow.xaml.cs index 9100724..6921793 100644 --- a/ChatBox.Client/MainWindow.xaml.cs +++ b/ChatBox.Client/MainWindow.xaml.cs @@ -644,7 +644,7 @@ private async void BtnSendChat_Click(object sender, RoutedEventArgs e) string text = cleanText; txtInput.Text = ""; - var newMsg = new ChatMessage { Sender = "Me", Content = text, AvatarBase64 = _avatarBase64, IsMe = true, Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")) }; + var newMsg = new ChatMessage { Sender = string.IsNullOrWhiteSpace(_displayName) ? "User" : _displayName, Content = text, AvatarBase64 = _avatarBase64, IsMe = true, Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")) }; _allMessages.Add(newMsg); RefreshMessageList(); @@ -668,9 +668,9 @@ private async Task UploadFileAsync(string filePath) try { - var msg = new ChatMessage - { - Sender = "Me", + var msg = new ChatMessage + { + Sender = string.IsNullOrWhiteSpace(_displayName) ? "User" : _displayName, Content = fileInfo.Name, IsFile = true, FileId = fileId.ToString(), From 2e626e7f60925be5002d4136fac222415b791561 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Tue, 19 May 2026 23:30:15 +0700 Subject: [PATCH 11/26] feat: show draft images inline in chat list with dashed border Draft images now appear directly in the message list (like Discord) instead of in a separate pending panel above the input. - Ctrl+V pastes image as draft inline with purple dashed border - Enter sends all pending drafts together - X button on draft removes it from chat list - Removed separate PendingImagesPanel XAML --- ChatBox.Client/MainWindow.xaml | 48 +++++-------------------------- ChatBox.Client/MainWindow.xaml.cs | 19 ++++-------- 2 files changed, 13 insertions(+), 54 deletions(-) diff --git a/ChatBox.Client/MainWindow.xaml b/ChatBox.Client/MainWindow.xaml index a1891dc..b104e70 100644 --- a/ChatBox.Client/MainWindow.xaml +++ b/ChatBox.Client/MainWindow.xaml @@ -884,6 +884,13 @@ + + + + + + + @@ -1067,47 +1074,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ChatBox.Client/MainWindow.xaml.cs b/ChatBox.Client/MainWindow.xaml.cs index 6921793..53b456f 100644 --- a/ChatBox.Client/MainWindow.xaml.cs +++ b/ChatBox.Client/MainWindow.xaml.cs @@ -98,15 +98,6 @@ public partial class MainWindow : Window private System.Collections.Generic.List _allMessages = new System.Collections.Generic.List(); private string _currentChannel = "chat"; - private void UpdatePendingImagesPanel() - { - int count = _pendingImages.Count; - lblPendingCount.Text = $"{count} image{(count != 1 ? "s" : "")} pending"; - PendingImagesPanel.Visibility = count > 0 ? Visibility.Visible : Visibility.Collapsed; - itemsPendingImages.ItemsSource = null; - itemsPendingImages.ItemsSource = _pendingImages; - } - public MainWindow() { InitializeComponent(); @@ -1323,8 +1314,10 @@ private void HandleImagePaste() IsDraft = true }; + // Add directly to messages list (inline draft) _pendingImages.Add(msg); - UpdatePendingImagesPanel(); + _allMessages.Add(msg); + RefreshMessageList(); } catch (Exception ex) { @@ -1338,13 +1331,12 @@ private async void SendPendingImages() var toSend = _pendingImages.ToList(); _pendingImages.Clear(); - UpdatePendingImagesPanel(); + // UpdatePendingImagesPanel no longer needed - panel removed foreach (var msg in toSend) { msg.IsDraft = false; msg.IsTransferring = true; - _allMessages.Add(msg); RefreshMessageList(); try @@ -1397,7 +1389,8 @@ private void BtnRemovePending_Click(object sender, RoutedEventArgs e) if (sender is Button btn && btn.Tag is ChatMessage msg) { _pendingImages.Remove(msg); - UpdatePendingImagesPanel(); + _allMessages.Remove(msg); + RefreshMessageList(); } } From 1b991e4944625b262de15cf53b041c87e60a80a8 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Tue, 19 May 2026 23:56:36 +0700 Subject: [PATCH 12/26] feat: add MessageReaction entity for emoji reactions --- LocalChat.Core/Data/ChatDbContext.cs | 15 +++++++++++- LocalChat.Core/Models/ChatMessage.cs | 2 ++ LocalChat.Core/Models/MessageReaction.cs | 30 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 LocalChat.Core/Models/MessageReaction.cs diff --git a/LocalChat.Core/Data/ChatDbContext.cs b/LocalChat.Core/Data/ChatDbContext.cs index ece5b88..cf6a55b 100644 --- a/LocalChat.Core/Data/ChatDbContext.cs +++ b/LocalChat.Core/Data/ChatDbContext.cs @@ -7,13 +7,26 @@ public class ChatDbContext : DbContext { public DbSet Users { get; set; } = null!; public DbSet ChatMessages { get; set; } = null!; + public DbSet MessageReactions { get; set; } = null!; protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) { - optionsBuilder.UseNpgsql("Host=localhost;Database=ChatBoxDb;Username=postgres;Password=12345"); + optionsBuilder.UseNpgsql("Host=localhost;Database=ChatBoxDb;Username=postgres;Password=postgres"); } } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => new { e.MessageId, e.UserId, e.Emoji }) + .IsUnique() + .HasDatabaseName("IX_MessageReactions_MessageId_UserId_Emoji"); + }); + } } } diff --git a/LocalChat.Core/Models/ChatMessage.cs b/LocalChat.Core/Models/ChatMessage.cs index ac43d74..e4c3ab7 100644 --- a/LocalChat.Core/Models/ChatMessage.cs +++ b/LocalChat.Core/Models/ChatMessage.cs @@ -24,5 +24,7 @@ public class ChatMessage public string? FileId { get; set; } public long FileSize { get; set; } + + public ICollection Reactions { get; set; } = new List(); } } diff --git a/LocalChat.Core/Models/MessageReaction.cs b/LocalChat.Core/Models/MessageReaction.cs new file mode 100644 index 0000000..e66b751 --- /dev/null +++ b/LocalChat.Core/Models/MessageReaction.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace LocalChat.Core.Models +{ + public class MessageReaction + { + [Key] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + [Required] + public string MessageId { get; set; } = string.Empty; + + [ForeignKey(nameof(MessageId))] + public ChatMessage? Message { get; set; } + + [Required] + public string UserId { get; set; } = string.Empty; + + [ForeignKey(nameof(UserId))] + public User? User { get; set; } + + [Required] + [MaxLength(50)] + public string Emoji { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} From 20b859bc7f921f3bb6ed31cb57ecea81a92750a2 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Wed, 20 May 2026 00:00:53 +0700 Subject: [PATCH 13/26] feat: add reaction handling to ChatServer --- LocalChat.Core/Services/ChatService.cs | 97 +++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/LocalChat.Core/Services/ChatService.cs b/LocalChat.Core/Services/ChatService.cs index 4fc90b2..a4a09fe 100644 --- a/LocalChat.Core/Services/ChatService.cs +++ b/LocalChat.Core/Services/ChatService.cs @@ -5,6 +5,7 @@ using System.Net; using System.Net.Sockets; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using LocalChat.Core.Data; @@ -145,6 +146,13 @@ private async Task ProcessMessageAsync(string rawMessage, string sourceClientId) history.Reverse(); + // Batch-load all reactions for all messages upfront to avoid N+1 queries + var messageIds = history.Select(m => m.Id).ToList(); + var allReactions = await db.MessageReactions + .Include(r => r.User) + .Where(r => messageIds.Contains(r.MessageId)) + .ToListAsync(); + if (_clients.TryGetValue(sourceClientId, out var client)) { var writer = new StreamWriter(client.GetStream(), Encoding.UTF8) { AutoFlush = true }; @@ -153,13 +161,18 @@ private async Task ProcessMessageAsync(string rawMessage, string sourceClientId) foreach (var msg in history) { if (msg.Sender == null) continue; + + // Get reactions for this message from preloaded data + var msgReactions = allReactions.Where(r => r.MessageId == msg.Id).ToList(); + var reactionsJson = JsonSerializer.Serialize(GroupReactions(msgReactions)); + if (msg.IsFile) { - await writer.WriteLineAsync($"FILE_READY|{msg.FileId}|{msg.Content}|{msg.FileSize}|{msg.Sender.Username}|{msg.Sender.AvatarBase64}|{msg.Timestamp:O}"); + await writer.WriteLineAsync($"FILE_READY|{msg.FileId}|{msg.Content}|{msg.FileSize}|{msg.Sender.Username}|{msg.Sender.AvatarBase64}|{msg.Timestamp:O}|{reactionsJson}"); } else { - await writer.WriteLineAsync($"MSG|{msg.Sender.Username}|{msg.Content}|{msg.Sender.AvatarBase64}|{msg.Timestamp:O}"); + await writer.WriteLineAsync($"MSG|{msg.Sender.Username}|{msg.Content}|{msg.Sender.AvatarBase64}|{msg.Timestamp:O}|{reactionsJson}"); } } } @@ -227,6 +240,86 @@ private async Task ProcessMessageAsync(string rawMessage, string sourceClientId) await BroadcastOnlineUsersAsync(); } } + else if (type == "REACT") // REACT|UserId|MessageId|Emoji + { + if (parts.Length < 4) return; + string userId = parts[1]; + string messageId = parts[2]; + string emoji = parts[3]; + + // Validate emoji parameter + if (string.IsNullOrWhiteSpace(emoji) || emoji.Length > 32) + { + OnLog?.Invoke($"[REACT] Invalid emoji received from user {userId}: '{emoji}'"); + return; + } + + var user = await db.Users.FindAsync(userId); + var chatMessage = await db.ChatMessages.FindAsync(messageId); + + if (user == null) + { + OnLog?.Invoke($"[REACT] Reaction failed - user not found: {userId}"); + return; + } + if (chatMessage == null) + { + OnLog?.Invoke($"[REACT] Reaction failed - message not found: {messageId}"); + return; + } + + // Check if user already reacted with this emoji (toggle behavior) + var existingReaction = await db.MessageReactions + .FirstOrDefaultAsync(r => r.MessageId == messageId && r.UserId == userId && r.Emoji == emoji); + + if (existingReaction != null) + { + // Remove reaction (toggle off) + db.MessageReactions.Remove(existingReaction); + await db.SaveChangesAsync(); + OnLog?.Invoke($"[REACT] {user.Username} removed reaction {emoji} from message {messageId}"); + } + else + { + // Add reaction + var reaction = new MessageReaction + { + MessageId = messageId, + UserId = userId, + Emoji = emoji, + CreatedAt = DateTime.UtcNow + }; + db.MessageReactions.Add(reaction); + await db.SaveChangesAsync(); + OnLog?.Invoke($"[REACT] {user.Username} reacted {emoji} to message {messageId}"); + } + + await BroadcastReactionUpdate(messageId, db); + } + } + + private string SerializeReactions(IEnumerable reactions) + { + return JsonSerializer.Serialize(GroupReactions(reactions)); + } + + private List GroupReactions(IEnumerable reactions) + { + return reactions + .GroupBy(r => r.Emoji) + .Select(g => new { emoji = g.Key, users = g.Where(r => r.User != null).Select(r => r.User!.Username).ToList() } as object) + .ToList(); + } + + private async Task BroadcastReactionUpdate(string messageId, ChatDbContext db) + { + var reactions = await db.MessageReactions + .Include(r => r.User) + .Where(r => r.MessageId == messageId) + .ToListAsync(); + + var reactionsJson = SerializeReactions(reactions); + await BroadcastAsync($"REACTION_UPDATE|{messageId}|{reactionsJson}"); } public async Task BroadcastAsync(string message, string excludeClientId = "") From 8fb68ba71a26657257dcee964451c11adeadd3e1 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Wed, 20 May 2026 00:10:42 +0700 Subject: [PATCH 14/26] feat: add emoji reaction popup UI to messages --- ChatBox.Client/MainWindow.xaml | 50 +++++++++++++++++++++++ ChatBox.Client/MainWindow.xaml.cs | 66 +++++++++++++++++++++++++------ 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/ChatBox.Client/MainWindow.xaml b/ChatBox.Client/MainWindow.xaml index b104e70..b4d278e 100644 --- a/ChatBox.Client/MainWindow.xaml +++ b/ChatBox.Client/MainWindow.xaml @@ -61,6 +61,37 @@ + + + + + + + + + - + + + + + + + + + + + + + + + + + - + + + @@ -820,7 +856,7 @@ - + @@ -907,7 +943,7 @@ - + @@ -919,12 +955,12 @@ - + + - diff --git a/ChatBox.Client/MainWindow.xaml.cs b/ChatBox.Client/MainWindow.xaml.cs index f491990..048c0f2 100644 --- a/ChatBox.Client/MainWindow.xaml.cs +++ b/ChatBox.Client/MainWindow.xaml.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Media; using System.Windows.Media.Imaging; @@ -96,8 +97,11 @@ public System.Collections.Generic.List Reactions public class Reaction { + [System.Text.Json.Serialization.JsonPropertyName("emoji")] public string Emoji { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("count")] public int Count { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("userNames")] public string UserNames { get; set; } = ""; } @@ -137,6 +141,7 @@ public class UserConfig private string GetConfigPath() { + // Use %AppData% for config so it persists across app updates string appData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LANChatBox"); if (!Directory.Exists(appData)) { @@ -390,24 +395,26 @@ private void HandleIncomingMessage(string rawMessage) string type = parts[0]; - if (type == "MSG") // MSG|Sender|Content|AvatarBase64|Timestamp + if (type == "MSG") // MSG|Sender|Content|AvatarBase64|Timestamp|ReactionsJson { string sender = parts[1]; string content = parts[2]; string avatar = parts.Length > 3 ? parts[3] : ""; string time = parts.Length > 4 ? FormatTimestamp(parts[4]) : FormatTimestamp(DateTime.UtcNow.ToString("O")); + string reactionsJson = parts.Length > 5 ? parts[5] : "[]"; bool isMe = (sender == txtUsername.Text || sender == "Me"); var chatMsg = new ChatMessage { MessageId = Guid.NewGuid().ToString(), - Sender = sender, - Content = content, - IsFile = false, - AvatarBase64 = avatar, - IsMe = isMe, + Sender = sender, + Content = content, + IsFile = false, + AvatarBase64 = avatar, + IsMe = isMe, Timestamp = time, RawDate = DateTime.TryParse(parts.Length > 4 ? parts[4] : "", out DateTime rdt) ? rdt : DateTime.UtcNow }; + ParseReactionsToMessage(chatMsg, reactionsJson); _allMessages.Add(chatMsg); if (IsMessageInCurrentChannel(chatMsg)) @@ -416,7 +423,7 @@ private void HandleIncomingMessage(string rawMessage) lstChatMessages.ScrollIntoView(chatMsg); } } - else if (type == "FILE_READY") // FILE_READY|FileId|FileName|Size|Sender|AvatarBase64|Timestamp + else if (type == "FILE_READY") // FILE_READY|FileId|FileName|Size|Sender|AvatarBase64|Timestamp|ReactionsJson { if (parts.Length < 6) return; string fileId = parts[1]; @@ -425,15 +432,16 @@ private void HandleIncomingMessage(string rawMessage) string sender = parts[4]; string avatar = parts[5]; string time = parts.Length > 6 ? FormatTimestamp(parts[6]) : FormatTimestamp(DateTime.UtcNow.ToString("O")); + string reactionsJson = parts.Length > 7 ? parts[7] : "[]"; bool isMe = (sender == txtUsername.Text || sender == "Me"); var fileMsg = new ChatMessage { MessageId = Guid.NewGuid().ToString(), - Sender = sender, - Content = fileName, - IsFile = true, - FileId = fileId, + Sender = sender, + Content = fileName, + IsFile = true, + FileId = fileId, FileSize = size, AvatarBase64 = avatar, IsMe = isMe, @@ -441,6 +449,7 @@ private void HandleIncomingMessage(string rawMessage) IsInImageChannel = (_currentChannel == "images"), RawDate = DateTime.TryParse(parts.Length > 6 ? parts[6] : "", out DateTime rdt2) ? rdt2 : DateTime.UtcNow }; + ParseReactionsToMessage(fileMsg, reactionsJson); _allMessages.Add(fileMsg); if (fileMsg.IsImage) @@ -499,6 +508,29 @@ private void HandleIncomingMessage(string rawMessage) lstOnlineUsers.ItemsSource = users; } } + else if (type == "UPDATE_PROFILE") + { + // UPDATE_PROFILE|UserId|Username|AvatarBase64 + // When another user updates their profile, update all their messages + if (parts.Length >= 4) + { + string updatedUserId = parts[1]; + string newUsername = parts[2]; + string newAvatar = parts[3]; + + // Update all messages from this user + foreach (var msg in _allMessages.Where(m => m.Sender == newUsername)) + { + msg.AvatarBase64 = newAvatar; + } + + // Refresh UI if in chat channel + if (_currentChannel == "chat") + { + RefreshMessageList(); + } + } + } else if (type == "REACTION_UPDATE") { // REACTION_UPDATE|MessageId|ReactionsJson @@ -1442,8 +1474,8 @@ private async void ReactionButton_Click(object sender, RoutedEventArgs e) { if (sender is Button btn && btn.Tag is string emoji) { - // Get the clicked message from the context menu placement - if (sender is FrameworkElement fe && fe.Parent is System.Windows.Controls.ContextMenu cm && cm.PlacementTarget is ListBoxItem item && item.DataContext is ChatMessage msg) + // Get the message from button's DataContext (bound to ChatMessage) + if (btn.DataContext is ChatMessage msg) { // Toggle reaction - add or remove var existingReaction = msg.Reactions.FirstOrDefault(r => r.Emoji == emoji); @@ -1462,6 +1494,12 @@ private async void ReactionButton_Click(object sender, RoutedEventArgs e) } msg.RefreshReactions(); + // Close popup after click + if (btn.Parent is System.Windows.Controls.Panel panel && panel.Parent is Popup popup) + { + popup.IsOpen = false; + } + // Send REACT message to server try { @@ -1475,6 +1513,44 @@ private async void ReactionButton_Click(object sender, RoutedEventArgs e) } } + private void ParseReactionsToMessage(ChatMessage msg, string reactionsJson) + { + try + { + var reactionsList = JsonSerializer.Deserialize>(reactionsJson); + if (reactionsList != null) + { + msg.Reactions = reactionsList; + } + } + catch + { + // Invalid JSON, ignore + } + } + + private void ReactionTrigger_Click(object sender, RoutedEventArgs e) + { + if (sender is Button btn && btn.DataContext is ChatMessage msg) + { + // Find the popup in the visual tree and open it + var parent = btn.Parent; + while (parent != null && !(parent is Grid)) + { + parent = VisualTreeHelper.GetParent(parent) as FrameworkElement; + } + if (parent is Grid grid) + { + var popup = grid.FindName("ReactionPopup") as Popup; + if (popup != null) + { + popup.DataContext = msg; + popup.IsOpen = true; + } + } + } + } + private void BtnRemovePending_Click(object sender, RoutedEventArgs e) { if (sender is Button btn && btn.Tag is ChatMessage msg) diff --git a/ChatBox.Client/Managers/ChannelManager.cs b/ChatBox.Client/Managers/ChannelManager.cs new file mode 100644 index 0000000..41e6068 --- /dev/null +++ b/ChatBox.Client/Managers/ChannelManager.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using ChatBox.Client.ViewModels; + +namespace ChatBox.Client.Managers +{ + public class ChannelManager + { + private readonly ListBox _lstChatMessages; + private readonly ScrollViewer _scrollImageGallery; + private readonly ScrollViewer _scrollFileGallery; + private readonly Border _borderInputArea; + private readonly TextBlock _lblInputPlaceholder; + private readonly TextBlock _txtChanChat; + private readonly Border _borderChanChat; + private readonly TextBlock _txtChanImages; + private readonly Border _borderChanImages; + private readonly TextBlock _txtChanFiles; + private readonly Border _borderChanFiles; + + private readonly GalleryManager _galleryManager; + private readonly LightboxManager _lightboxManager; + + public string CurrentChannel { get; private set; } = "chat"; + public List AllMessages { get; } = new(); + + public ChannelManager( + ListBox lstChatMessages, + ScrollViewer scrollImageGallery, + ScrollViewer scrollFileGallery, + Border borderInputArea, + TextBlock lblInputPlaceholder, + TextBlock txtChanChat, + Border borderChanChat, + TextBlock txtChanImages, + Border borderChanImages, + TextBlock txtChanFiles, + Border borderChanFiles, + GalleryManager galleryManager, + LightboxManager lightboxManager) + { + _lstChatMessages = lstChatMessages; + _scrollImageGallery = scrollImageGallery; + _scrollFileGallery = scrollFileGallery; + _borderInputArea = borderInputArea; + _lblInputPlaceholder = lblInputPlaceholder; + _txtChanChat = txtChanChat; + _borderChanChat = borderChanChat; + _txtChanImages = txtChanImages; + _borderChanImages = borderChanImages; + _txtChanFiles = txtChanFiles; + _borderChanFiles = borderChanFiles; + _galleryManager = galleryManager; + _lightboxManager = lightboxManager; + } + + public void SelectChannel(string channelName, object txtInput) + { + CurrentChannel = channelName; + + var activeBg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E3E5E8")); + var inactiveBg = Brushes.Transparent; + var activeText = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#060607")); + var inactiveText = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4F5660")); + + _borderChanChat.Background = channelName == "chat" ? activeBg : inactiveBg; + _txtChanChat.Foreground = channelName == "chat" ? activeText : inactiveText; + _txtChanChat.FontWeight = channelName == "chat" ? FontWeights.Bold : FontWeights.Normal; + + _borderChanImages.Background = channelName == "images" ? activeBg : inactiveBg; + _txtChanImages.Foreground = channelName == "images" ? activeText : inactiveText; + _txtChanImages.FontWeight = channelName == "images" ? FontWeights.Bold : FontWeights.Normal; + + _borderChanFiles.Background = channelName == "files" ? activeBg : inactiveBg; + _txtChanFiles.Foreground = channelName == "files" ? activeText : inactiveText; + _txtChanFiles.FontWeight = channelName == "files" ? FontWeights.Bold : FontWeights.Normal; + + foreach (var msg in AllMessages) + { + msg.IsInImageChannel = channelName == "images"; + } + + switch (channelName) + { + case "chat": + _scrollImageGallery.Visibility = Visibility.Collapsed; + _scrollFileGallery.Visibility = Visibility.Collapsed; + _lstChatMessages.Visibility = Visibility.Visible; + _borderInputArea.IsEnabled = true; + _borderInputArea.Opacity = 1.0; + _lblInputPlaceholder.Text = "Message #lan-global-chat"; + _lblInputPlaceholder.Visibility = Visibility.Visible; + RefreshMessageList(); + break; + + case "images": + _scrollImageGallery.Visibility = Visibility.Visible; + _scrollFileGallery.Visibility = Visibility.Collapsed; + _lstChatMessages.Visibility = Visibility.Collapsed; + _borderInputArea.IsEnabled = false; + _borderInputArea.Opacity = 0.55; + _lblInputPlaceholder.Text = "Only images can be viewed in this channel"; + _lblInputPlaceholder.Visibility = Visibility.Visible; + _galleryManager.RefreshImageGallery(AllMessages); + break; + + case "files": + _scrollImageGallery.Visibility = Visibility.Collapsed; + _scrollFileGallery.Visibility = Visibility.Visible; + _lstChatMessages.Visibility = Visibility.Collapsed; + _borderInputArea.IsEnabled = false; + _borderInputArea.Opacity = 0.55; + _lblInputPlaceholder.Text = "Only files can be viewed in this channel"; + _lblInputPlaceholder.Visibility = Visibility.Visible; + _galleryManager.RefreshFileGallery(AllMessages); + break; + } + } + + public void RefreshMessageList() + { + _lstChatMessages.Items.Clear(); + foreach (var msg in AllMessages) + { + if (IsMessageInCurrentChannel(msg)) + { + _lstChatMessages.Items.Add(msg); + } + } + if (_lstChatMessages.Items.Count > 0) + { + _lstChatMessages.ScrollIntoView(_lstChatMessages.Items[_lstChatMessages.Items.Count - 1]); + } + } + + public bool IsMessageInCurrentChannel(ChatMessage msg) + { + if (CurrentChannel == "chat") return true; + if (CurrentChannel == "images") return msg.IsFile && msg.IsImage; + if (CurrentChannel == "files") return msg.IsFile && !msg.IsImage; + return false; + } + } +} \ No newline at end of file diff --git a/ChatBox.Client/Managers/ClipboardPasteHandler.cs b/ChatBox.Client/Managers/ClipboardPasteHandler.cs new file mode 100644 index 0000000..3f5e909 --- /dev/null +++ b/ChatBox.Client/Managers/ClipboardPasteHandler.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using ChatBox.Client.ViewModels; + +namespace ChatBox.Client.Managers +{ + public class ClipboardPasteHandler + { + private readonly FileTransferManager _fileTransferManager; + private readonly ChannelManager _channelManager; + private readonly string _serverIp; + private readonly string _userId; + private readonly string _avatarBase64; + + public event Action? OnMessageAdded; + + public ClipboardPasteHandler( + FileTransferManager fileTransferManager, + ChannelManager channelManager, + string serverIp, + string userId, + string avatarBase64) + { + _fileTransferManager = fileTransferManager; + _channelManager = channelManager; + _serverIp = serverIp; + _userId = userId; + _avatarBase64 = avatarBase64; + } + + public async Task HandleImagePasteAsync() + { + try + { + var image = Clipboard.GetImage(); + if (image == null) return; + + string tempDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TempPaste"); + if (!Directory.Exists(tempDir)) + Directory.CreateDirectory(tempDir); + + string fileName = $"ClipboardImage_{DateTime.Now:yyyyMMdd_HHmmss}.png"; + string filePath = Path.Combine(tempDir, fileName); + + using (var fileStream = new FileStream(filePath, FileMode.Create)) + { + var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder(); + encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(image)); + encoder.Save(fileStream); + } + + Guid fileId = Guid.NewGuid(); + var fileInfo = new FileInfo(filePath); + + var msg = new ChatMessage + { + Sender = "Me", + Content = fileInfo.Name, + IsFile = true, + FileId = fileId.ToString(), + FileSize = fileInfo.Length, + AvatarBase64 = _avatarBase64, + IsTransferring = true, + TransferProgress = 0, + IsMe = true, + Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")), + LocalFilePath = filePath, + IsInImageChannel = _channelManager.CurrentChannel == "images" + }; + + _channelManager.AllMessages.Add(msg); + + if (_channelManager.CurrentChannel == "images") + { + // refresh gallery + } + else + { + _channelManager.RefreshMessageList(); + } + + await _fileTransferManager.UploadFileAsync(_serverIp, filePath, fileId); + + msg.IsTransferring = false; + OnMessageAdded?.Invoke(msg); + } + catch (Exception ex) + { + MessageBox.Show("Failed to paste image: " + ex.Message, "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private static string FormatTimestamp(string timeString) + { + if (DateTime.TryParse(timeString, out DateTime dt)) + { + var local = dt.ToLocalTime(); + if (local.Date == DateTime.Now.Date) + return local.ToString("HH:mm"); + if (local.Year == DateTime.Now.Year) + return local.ToString("dd/MM HH:mm"); + return local.ToString("dd/MM/yyyy HH:mm"); + } + return timeString; + } + } +} \ No newline at end of file diff --git a/ChatBox.Client/Managers/ConnectionManager.cs b/ChatBox.Client/Managers/ConnectionManager.cs new file mode 100644 index 0000000..451bdcb --- /dev/null +++ b/ChatBox.Client/Managers/ConnectionManager.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using LocalChat.Core.Contracts; + +namespace ChatBox.Client.Managers +{ + public class ConnectionManager + { + private readonly IChatClient _chatClient; + private readonly IFileClient _fileClient; + private readonly MessageRouter _messageRouter; + private readonly ChannelManager _channelManager; + + public string ServerIp { get; private set; } = ""; + public string UserId { get; private set; } = ""; + public string AvatarBase64 { get; private set; } = ""; + + private CancellationTokenSource _cts = new(); + + public event Action? OnStatusChanged; + public event Action? OnConnectionStateChanged; + + public ConnectionManager( + IChatClient chatClient, + IFileClient fileClient, + MessageRouter messageRouter, + ChannelManager channelManager) + { + _chatClient = chatClient; + _fileClient = fileClient; + _messageRouter = messageRouter; + _channelManager = channelManager; + } + + public void SetUserIdentity(string userId, string avatarBase64) + { + UserId = userId; + AvatarBase64 = avatarBase64; + } + + public async Task ConnectAsync(string serverIp, string username) + { + ServerIp = serverIp; + + if (_cts.IsCancellationRequested) _cts = new CancellationTokenSource(); + + try + { + await _chatClient.ConnectAsync(serverIp, _cts.Token); + await _chatClient.SendMessageAsync($"JOIN|{UserId}|{username}|{AvatarBase64}"); + OnStatusChanged?.Invoke("Connected"); + OnConnectionStateChanged?.Invoke(true); + } + catch (OperationCanceledException) + { + OnStatusChanged?.Invoke("Cancelled"); + } + catch (Exception ex) + { + OnStatusChanged?.Invoke("Connection Failed"); + OnConnectionStateChanged?.Invoke(false); + throw; + } + } + + public void CancelConnect() + { + _cts.Cancel(); + _chatClient.Disconnect(); + } + + public void Disconnect() + { + _chatClient.Disconnect(); + _cts.Cancel(); + _channelManager.AllMessages.Clear(); + _channelManager.RefreshMessageList(); + OnStatusChanged?.Invoke("Disconnected"); + OnConnectionStateChanged?.Invoke(false); + } + + public async Task SendMessageAsync(string message) + { + await _chatClient.SendMessageAsync(message); + } + + public void LoadUserConfig(string userId, string avatarBase64) + { + UserId = userId; + AvatarBase64 = avatarBase64; + } + } +} \ No newline at end of file diff --git a/ChatBox.Client/Managers/EmojiManager.cs b/ChatBox.Client/Managers/EmojiManager.cs new file mode 100644 index 0000000..5759f70 --- /dev/null +++ b/ChatBox.Client/Managers/EmojiManager.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Input; + +namespace ChatBox.Client.Managers +{ + public class EmojiManager + { + private readonly WrapPanel _pnlSmileys; + private readonly WrapPanel _pnlAnimals; + private readonly WrapPanel _pnlFood; + private readonly WrapPanel _pnlActivities; + private readonly WrapPanel _pnlTravel; + private readonly WrapPanel _pnlObjects; + private object? _txtInput; + private object? _lblInputPlaceholder; + + public event Action? OnEmojiSelected; + + public EmojiManager( + WrapPanel pnlSmileys, + WrapPanel pnlAnimals, + WrapPanel pnlFood, + WrapPanel pnlActivities, + WrapPanel pnlTravel, + WrapPanel pnlObjects) + { + _pnlSmileys = pnlSmileys; + _pnlAnimals = pnlAnimals; + _pnlFood = pnlFood; + _pnlActivities = pnlActivities; + _pnlTravel = pnlTravel; + _pnlObjects = pnlObjects; + } + + public void SetInputControls(object txtInput, object lblInputPlaceholder) + { + _txtInput = txtInput; + _lblInputPlaceholder = lblInputPlaceholder; + } + + public void Initialize() + { + AddEmojisToPanel(_pnlSmileys, Smileys); + AddEmojisToPanel(_pnlAnimals, Animals); + AddEmojisToPanel(_pnlFood, Food); + AddEmojisToPanel(_pnlActivities, Activities); + AddEmojisToPanel(_pnlTravel, Travel); + AddEmojisToPanel(_pnlObjects, Objects); + } + + private void AddEmojisToPanel(WrapPanel panel, string[] emojis) + { + panel.Children.Clear(); + foreach (var emoji in emojis) + { + string trimmed = emoji.Trim(); + if (trimmed.Length == 0 || trimmed.Any(c => char.IsLetterOrDigit(c))) continue; + + var btn = new Button + { + Content = new Emoji.Wpf.TextBlock { Text = trimmed, FontSize = 22, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Margin = new Thickness(1), + Cursor = Cursors.Hand, + Width = 38, + Height = 38 + }; + + btn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse( + @" + + + + "); + + btn.Click += (s, e) => + { + if (_txtInput == null || _lblInputPlaceholder == null) return; + if (_txtInput is TextBox tb) tb.Text += trimmed; + else if (_txtInput is Emoji.Wpf.RichTextBox rtb) rtb.Text += trimmed; + string cleanText = (_txtInput.ToString() ?? "").Replace("\r", "").Replace("\n", "").Trim(); + if (_lblInputPlaceholder is TextBlock lbl) + lbl.Visibility = string.IsNullOrEmpty(cleanText) ? Visibility.Visible : Visibility.Collapsed; + OnEmojiSelected?.Invoke(trimmed); + }; + panel.Children.Add(btn); + } + } + + private static readonly string[] Smileys = { + "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "😊", "😇", + "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", "😗", "😙", "😚", + "😋", "😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓", "😎", "🥸", + "🤩", "🥳", "😏", "😒", "😞", "😔", "😟", "😕", "🙁", "☹️", + "😣", "😖", "😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡", + "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰", "😥", "😓", + "🤗", "🤔", "🫣", "🤭", "🤫", "🤥", "😶", "😐", "😑", "😬", + "🫠", "🫥", "😴", "🥱", "🤢", "🤮", "🤧", "😷", "🤒", "🤕", + "😈", "👿", "👹", "👺", "💀", "☠️", "👻", "👽", "👾", "🤖", + "💩", "👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", + "🤞", "🫰", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", + "☝️", "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "👐", + "🫶", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🧠", "🫀", + "🫁", "🦷", "🦴", "👀", "👁️", "👅", "👄", "💋", "❤️", "🧡", + "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "💖", "💗", + "💓", "💞", "💕", "💟", "❣️", "💘", "💝" + }; + + private static readonly string[] Animals = { + "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", + "🦁", "🐮", "🐷", "🐽", "🐸", "🐵", "🙈", "🙉", "🙊", "🐒", + "🐔", "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", + "🐺", "🐗", "🐴", "🦄", "🐝", "🪱", "🐛", "🦋", "🐌", "🐞", + "🐜", "🪰", "🪲", "🪳", "🦂", "🕸️", "🕷️", "🐢", "🐍", "🦎", + "🐙", "🦑", "🦞", "🦀", "🐡", "🐠", "🐟", "🐬", "🐳", "🐋", + "🦈", "🐊", "🐅", "🐆", "🦓", "🦍", "🦧", "🐘", "🦛", "🦏", + "🐪", "🐫", "🦒", "🦘", "🦬", "🐃", "🐂", "🐄", "🐎", "🐖", + "🐏", "🐑", "🦙", "🐐", "🦌", "🐕", "🐩", "🐈", "🐈‍⬛", "🐇", + "🐿️", "🦫", "🦔", "🦦", "🦥", "🦡", "🍁", "🍂", "🍃", "🍄", + "🌸", "💮", "🪷", "🌹", "🥀", "🌺", "🌻", "🌼", "🌷", "🌱", + "🪴", "🌲", "🌳", "🌴", "🌵", "🌾", "🌿", "🍀" + }; + + private static readonly string[] Food = { + "🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", + "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", + "🥬", "🥒", "🌶️", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", "🥔", + "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", "🧈", + "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🌭", "🍔", "🍟", "🍕", + "🥪", "🌮", "🌯", "🍲", "🥘", "🥣", "🥗", "🍿", "🧂", "🥫", + "🍱", "🍘", "🍙", "🍚", "🍛", "🍜", "🍝", "🍢", "🍣", "🍤", + "🍥", "🥮", "🍡", "🥟", "🥠", "🥡", "🍦", "🍧", "🍨", "🍩", + "🍪", "🎂", "🍰", "🧁", "🥧", "🍫", "🍬", "🍭", "🍮", "🍯", + "🍼", "🥛", "☕", "🍵", "🍶", "🍾", "🍷", "🍸", "🍹", "🍺", + "🍻", "🥂", "🥃" + }; + + private static readonly string[] Activities = { + "⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱", + "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏹", "🎣", "🤿", "🥊", + "🥋", "🎽", "🛹", "🛼", "🛷", "⛸️", "🥌", "🎿", "🏂", "🪂", + "🏋️", "🤼", "🤸", "⛹️", "🤺", "🤾", "🏌️", "🏇", "🧘", "🏄", + "🏊", "🤽", "🚣", "🧗", "🚴", "🚵", "🏆", "🥇", "🥈", "🥉", + "🏅", "🎖️", "🎫", "🎟️", "🎭", "🎨", "🎬", "🎤", "🎧", "🎼", + "🎹", "🥁", "🪘", "🎷", "🎺", "🎸", "🪕", "🎻", "🎲", "🧩", + "🎯", "🎮", "🕹️", "🎰", "👾", "♟️", "🪁", "🏰", "🗼", "🗽", + "⛩️", "🕋", "🕌", "🛕", "🕍", "🛰️", "🇻🇳", "🇺🇸", "🇬🇧", "🇯🇵" + }; + + private static readonly string[] Travel = { + "🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", + "🛻", "🚚", "🚛", "🚜", "🛵", "🏍️", "🛺", "🚲", "🛴", "🚏", + "🛤️", "⚓", "⛵", "🛶", "🚤", "🛳️", "⛴️", "🚢", "✈️", "🛩️", + "🛫", "🛬", "🚡", "🚠", "🚟", "🚀", "🛸", "🚁", "🌍", "🌎", + "🌏", "🌐", "🗺️", "🗾", "🧭", "🏔️", "⛰️", "🗻", "🏕️", "🏖️", + "🏜️", "🏝️", "🏞️", "🏟️", "🏛️", "🏗️", "🧱", "🪨", "🪵", "🏠", + "🏡", "🏢", "🏣", "🏤", "🏥", "🏦", "🏨", "🏩", "🏪", "🏫", + "🏬", "🏭", "🏯", "💒", "🗼", "🗽", "⛩️", "🕋", "🕌", "🛕" + }; + + private static readonly string[] Objects = { + "💡", "🔦", "🕯️", "🪔", "🔌", "🔋", "💻", "🖥️", "🖨️", "⌨️", + "🖱️", "🖲️", "💽", "💾", "💿", "📀", "🧮", "🎥", "🎞️", "📽️", + "📺", "📷", "📸", "📹", "📼", "🔍", "🔎", "🔬", "🔭", "📡", + "✉️", "📩", "📨", "📧", "📥", "📤", "📦", "🏷️", "🪪", "📯", + "📮", "🗳️", "✏️", "🖋️", "🖊️", "🖌️", "🖍️", "📝", "📁", "📂", + "📅", "📆", "🗒️", "🗓️", "📊", "📈", "📉", "📋", "📌", "📍", + "📎", "🖇️", "📏", "📐", "✂️", "🗃️", "🗄️", "🗑️", "🔒", "🔓", + "🔏", "🔐", "🔑", "🗝️", "🔨", "🪓", "⛏️", "🛠️", "🗡️", "⚔️", + "🔫", "🛡️", "🔧", "🪛", "⚙️", "🗜️", "⚖️", "🔗", "⛓️", "🔮", + "📿", "🧿", "🔔", "🔕", "🩹", "🧬", "🌡️", "🧪", "🧫", "⭐", + "🌟", "✨", "💥" + }; + } +} \ No newline at end of file diff --git a/ChatBox.Client/Managers/FileTransferManager.cs b/ChatBox.Client/Managers/FileTransferManager.cs new file mode 100644 index 0000000..5aaa7f3 --- /dev/null +++ b/ChatBox.Client/Managers/FileTransferManager.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using ChatBox.Client.ViewModels; +using LocalChat.Core.Contracts; + +namespace ChatBox.Client.Managers +{ + public class FileTransferManager + { + private readonly IFileClient _fileClient; + private readonly ChannelManager _channelManager; + + public event Action? OnProgress; + + private ChatMessage? _currentTransferMessage; + + public FileTransferManager(IFileClient fileClient, ChannelManager channelManager) + { + _fileClient = fileClient; + _channelManager = channelManager; + } + + public async Task UploadFileAsync(string serverIp, string filePath, Guid fileId) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) return; + + var fileInfo = new FileInfo(filePath); + + var msg = new ChatMessage + { + Sender = "Me", + Content = fileInfo.Name, + IsFile = true, + FileId = fileId.ToString(), + FileSize = fileInfo.Length, + IsTransferring = true, + TransferProgress = 0, + IsMe = true, + Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")), + LocalFilePath = filePath, + IsInImageChannel = _channelManager.CurrentChannel == "images" + }; + + _channelManager.AllMessages.Add(msg); + _channelManager.RefreshMessageList(); + _currentTransferMessage = msg; + + _fileClient.OnUploadProgress += HandleUploadProgress; + + try + { + await _fileClient.UploadFileAsync(serverIp, filePath, fileId); + } + finally + { + _fileClient.OnUploadProgress -= HandleUploadProgress; + } + + msg.IsTransferring = false; + _currentTransferMessage = null; + _channelManager.RefreshMessageList(); + } + + public async Task DownloadFileAsync(string serverIp, string savePath, Guid fileId, long totalSize) + { + _currentTransferMessage = null; + _fileClient.OnDownloadProgress += HandleDownloadProgress; + + try + { + await _fileClient.DownloadFileAsync(serverIp, savePath, fileId, totalSize); + } + finally + { + _fileClient.OnDownloadProgress -= HandleDownloadProgress; + } + } + + private void HandleUploadProgress(double percent) + { + Application.Current.Dispatcher.Invoke(() => + { + if (_currentTransferMessage != null) + { + _currentTransferMessage.TransferProgress = percent; + } + OnProgress?.Invoke(percent); + }); + } + + private void HandleDownloadProgress(double percent) + { + Application.Current.Dispatcher.Invoke(() => + { + if (_currentTransferMessage != null) + { + _currentTransferMessage.TransferProgress = percent; + } + OnProgress?.Invoke(percent); + }); + } + + public void SetTransferringMessage(ChatMessage? msg) + { + _currentTransferMessage = msg; + } + + private static string FormatTimestamp(string timeString) + { + if (DateTime.TryParse(timeString, out DateTime dt)) + { + var local = dt.ToLocalTime(); + if (local.Date == DateTime.Now.Date) + return local.ToString("HH:mm"); + if (local.Year == DateTime.Now.Year) + return local.ToString("dd/MM HH:mm"); + return local.ToString("dd/MM/yyyy HH:mm"); + } + return timeString; + } + } +} \ No newline at end of file diff --git a/ChatBox.Client/Managers/GalleryManager.cs b/ChatBox.Client/Managers/GalleryManager.cs new file mode 100644 index 0000000..296efc7 --- /dev/null +++ b/ChatBox.Client/Managers/GalleryManager.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Controls; +using ChatBox.Client.ViewModels; + +namespace ChatBox.Client.Managers +{ + public class GalleryManager + { + private readonly ItemsControl _itemsImageGallery; + private readonly ItemsControl _itemsFileGallery; + + public GalleryManager(ItemsControl itemsImageGallery, ItemsControl itemsFileGallery) + { + _itemsImageGallery = itemsImageGallery; + _itemsFileGallery = itemsFileGallery; + } + + public void RefreshImageGallery(System.Collections.Generic.List allMessages) + { + var imageMessages = allMessages.Where(m => m.IsImage).ToList(); + var groups = imageMessages.GroupBy(m => m.RawDate.ToLocalTime().Date) + .OrderByDescending(g => g.Key); + + var list = new ObservableCollection(); + foreach (var g in groups) + { + string header = GetDateHeader(g.Key); + var imgGroup = new ImageGroup { DateHeader = header }; + foreach (var m in g.OrderBy(msg => msg.RawDate)) + { + imgGroup.Images.Add(m); + } + list.Add(imgGroup); + } + + _itemsImageGallery.ItemsSource = list; + } + + public void RefreshFileGallery(System.Collections.Generic.List allMessages) + { + var fileMessages = allMessages.Where(m => m.IsFile && !m.IsImage).ToList(); + var groups = fileMessages.GroupBy(m => m.RawDate.ToLocalTime().Date) + .OrderByDescending(g => g.Key); + + var list = new ObservableCollection(); + foreach (var g in groups) + { + string header = GetDateHeader(g.Key); + var fg = new FileGroup { DateHeader = header }; + foreach (var m in g.OrderBy(msg => msg.RawDate)) + { + fg.Files.Add(m); + } + list.Add(fg); + } + + _itemsFileGallery.ItemsSource = list; + } + + private static string GetDateHeader(DateTime date) + { + if (date == DateTime.Today) return "Today"; + if (date == DateTime.Today.AddDays(-1)) return "Yesterday"; + return date.ToString("MMMM dd, yyyy"); + } + + public class ImageGroup + { + public string DateHeader { get; set; } = ""; + public ObservableCollection Images { get; set; } = new(); + } + + public class FileGroup + { + public string DateHeader { get; set; } = ""; + public ObservableCollection Files { get; set; } = new(); + } + } +} \ No newline at end of file diff --git a/ChatBox.Client/Managers/LightboxManager.cs b/ChatBox.Client/Managers/LightboxManager.cs new file mode 100644 index 0000000..a1dee1e --- /dev/null +++ b/ChatBox.Client/Managers/LightboxManager.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using ChatBox.Client.ViewModels; + +namespace ChatBox.Client.Managers +{ + public class LightboxManager + { + private readonly Grid _lightboxOverlay; + private readonly Image _imgLightboxLarge; + private readonly ImageBrush _imgLightboxAvatar; + private readonly TextBlock _lblLightboxSender; + private readonly TextBlock _lblLightboxTime; + private readonly Button _btnLightboxDownload; + + private ChatMessage? _currentMessage; + private readonly Converters.Base64ImageConverter? _avatarConverter; + + public LightboxManager( + Grid lightboxOverlay, + Image imgLightboxLarge, + ImageBrush imgLightboxAvatar, + TextBlock lblLightboxSender, + TextBlock lblLightboxTime, + Button btnLightboxDownload) + { + _lightboxOverlay = lightboxOverlay; + _imgLightboxLarge = imgLightboxLarge; + _imgLightboxAvatar = imgLightboxAvatar; + _lblLightboxSender = lblLightboxSender; + _lblLightboxTime = lblLightboxTime; + _btnLightboxDownload = btnLightboxDownload; + + _avatarConverter = new Converters.Base64ImageConverter(); + } + + public void Open(ChatMessage msg, string currentUsername) + { + if (string.IsNullOrEmpty(msg.LocalFilePath) || !File.Exists(msg.LocalFilePath)) + return; + + _currentMessage = msg; + + try + { + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.UriSource = new Uri(msg.LocalFilePath); + bitmap.EndInit(); + _imgLightboxLarge.Source = bitmap; + } + catch + { + _imgLightboxLarge.Source = null; + } + + try + { + _imgLightboxAvatar.ImageSource = (ImageSource)_avatarConverter!.Convert( + msg.AvatarBase64, typeof(ImageSource), null, System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + _imgLightboxAvatar.ImageSource = null; + } + + _lblLightboxSender.Text = msg.Sender == "Me" + ? (string.IsNullOrWhiteSpace(currentUsername) ? "User" : currentUsername) + : msg.Sender; + _lblLightboxTime.Text = msg.Timestamp; + + _lightboxOverlay.Visibility = Visibility.Visible; + } + + public void Close() + { + _lightboxOverlay.Visibility = Visibility.Collapsed; + _currentMessage = null; + } + + public void Download(Action showMessage) + { + if (_currentMessage == null || string.IsNullOrEmpty(_currentMessage.LocalFilePath)) return; + + var saveDialog = new Microsoft.Win32.SaveFileDialog + { + FileName = _currentMessage.Content, + Filter = "Image Files|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.webp|All Files|*.*" + }; + + if (saveDialog.ShowDialog() == true) + { + try + { + File.Copy(_currentMessage.LocalFilePath, saveDialog.FileName, true); + showMessage("Downloaded successfully!", "Success"); + } + catch (Exception ex) + { + showMessage("Download failed: " + ex.Message, "Error"); + } + } + } + } +} \ No newline at end of file diff --git a/ChatBox.Client/Managers/MessageRouter.cs b/ChatBox.Client/Managers/MessageRouter.cs new file mode 100644 index 0000000..6efe2a2 --- /dev/null +++ b/ChatBox.Client/Managers/MessageRouter.cs @@ -0,0 +1,184 @@ +using System; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.IO; +using ChatBox.Client.ViewModels; +using LocalChat.Core.Contracts; + +namespace ChatBox.Client.Managers +{ + public class MessageRouter + { + private readonly ChannelManager _channelManager; + private readonly GalleryManager _galleryManager; + private readonly LightboxManager _lightboxManager; + private readonly Action _openLightbox; + private readonly Action _showMessage; + + private string _username = ""; + private readonly List _onlineUsers = new(); + + public event Action>? OnOnlineUsersChanged; + + public MessageRouter( + ChannelManager channelManager, + GalleryManager galleryManager, + LightboxManager lightboxManager, + Action openLightbox, + Action showMessage) + { + _channelManager = channelManager; + _galleryManager = galleryManager; + _lightboxManager = lightboxManager; + _openLightbox = openLightbox; + _showMessage = showMessage; + } + + public void SetUsername(string username) + { + _username = username; + } + + public void RouteIncomingMessage(string rawMessage) + { + var parts = rawMessage.Split('|'); + if (parts.Length < 1) return; + + string type = parts[0]; + + switch (type) + { + case "MSG": + HandleTextMessage(parts); + break; + case "FILE_READY": + HandleFileReady(parts); + break; + case "CLEAR_CHAT": + _channelManager.AllMessages.Clear(); + _channelManager.RefreshMessageList(); + break; + case "ROOM_NAME": + // Handled by ConnectionManager + break; + case "GREETING": + // Handled by ConnectionManager + break; + case "ONLINE_USERS": + HandleOnlineUsers(parts); + break; + case "UPDATE_PROFILE": + // Broadcast profile update, no action needed on client + break; + } + } + + private void HandleTextMessage(string[] parts) + { + if (parts.Length < 3) return; + string sender = parts[1]; + string content = parts[2]; + string avatar = parts.Length > 3 ? parts[3] : ""; + string time = parts.Length > 4 ? FormatTimestamp(parts[4]) : FormatTimestamp(DateTime.UtcNow.ToString("O")); + bool isMe = (sender == _username || sender == "Me"); + + var chatMsg = new ChatMessage + { + Sender = sender, + Content = content, + IsFile = false, + AvatarBase64 = avatar, + IsMe = isMe, + Timestamp = time, + RawDate = parts.Length > 4 && DateTime.TryParse(parts[4], out DateTime rdt) ? rdt : DateTime.UtcNow + }; + + _channelManager.AllMessages.Add(chatMsg); + + if (_channelManager.IsMessageInCurrentChannel(chatMsg)) + { + _channelManager.RefreshMessageList(); + } + } + + private void HandleFileReady(string[] parts) + { + if (parts.Length < 6) return; + string fileId = parts[1]; + string fileName = parts[2]; + long size = long.Parse(parts[3]); + string sender = parts[4]; + string avatar = parts[5]; + string time = parts.Length > 6 ? FormatTimestamp(parts[6]) : FormatTimestamp(DateTime.UtcNow.ToString("O")); + bool isMe = (sender == _username || sender == "Me"); + + var fileMsg = new ChatMessage + { + Sender = sender, + Content = fileName, + IsFile = true, + FileId = fileId, + FileSize = size, + AvatarBase64 = avatar, + IsMe = isMe, + Timestamp = time, + IsInImageChannel = _channelManager.CurrentChannel == "images", + RawDate = parts.Length > 6 && DateTime.TryParse(parts[6], out DateTime rdt2) ? rdt2 : DateTime.UtcNow + }; + + _channelManager.AllMessages.Add(fileMsg); + + if (fileMsg.IsImage && string.IsNullOrEmpty(fileMsg.LocalFilePath)) + { + // Trigger auto-download (handled by MainWindow) + _openLightbox?.Invoke(fileMsg); + } + + if (_channelManager.CurrentChannel == "images") + { + _galleryManager.RefreshImageGallery(_channelManager.AllMessages); + } + else if (_channelManager.CurrentChannel == "files") + { + _galleryManager.RefreshFileGallery(_channelManager.AllMessages); + } + else + { + _channelManager.RefreshMessageList(); + } + } + + private void HandleOnlineUsers(string[] parts) + { + if (parts.Length > 1) + { + var users = parts[1].Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); + for (int i = 0; i < users.Count; i++) + { + if (users[i] == _username) + users[i] += " (You)"; + } + _onlineUsers.Clear(); + _onlineUsers.AddRange(users); + OnOnlineUsersChanged?.Invoke(_onlineUsers); + } + } + + private static string FormatTimestamp(string timeString) + { + if (DateTime.TryParse(timeString, out DateTime dt)) + { + var local = dt.ToLocalTime(); + if (local.Date == DateTime.Now.Date) + return local.ToString("HH:mm"); + if (local.Year == DateTime.Now.Year) + return local.ToString("dd/MM HH:mm"); + return local.ToString("dd/MM/yyyy HH:mm"); + } + return timeString; + } + } +} \ No newline at end of file diff --git a/ChatBox.Client/Managers/WindowChromeManager.cs b/ChatBox.Client/Managers/WindowChromeManager.cs new file mode 100644 index 0000000..6f02a59 --- /dev/null +++ b/ChatBox.Client/Managers/WindowChromeManager.cs @@ -0,0 +1,49 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace ChatBox.Client.Managers +{ + public class WindowChromeManager + { + private readonly Window _window; + + public WindowChromeManager(Window window) + { + _window = window; + } + + public void OnTopbarMouseDown(MouseButtonEventArgs e) + { + if (e.ChangedButton == MouseButton.Left) + { + if (e.ClickCount == 2) + { + ToggleMaximize(); + } + else + { + _window.DragMove(); + } + } + } + + public void Minimize() + { + _window.WindowState = WindowState.Minimized; + } + + public void ToggleMaximize() + { + _window.WindowState = _window.WindowState == WindowState.Maximized + ? WindowState.Normal + : WindowState.Maximized; + } + + public void Close() + { + _window.Close(); + } + } +} \ No newline at end of file diff --git a/LocalChat.Core/Contracts/IChatClient.cs b/LocalChat.Core/Contracts/IChatClient.cs new file mode 100644 index 0000000..314b705 --- /dev/null +++ b/LocalChat.Core/Contracts/IChatClient.cs @@ -0,0 +1,10 @@ +namespace LocalChat.Core.Contracts +{ + public interface IChatClient + { + event Action? OnMessageReceived; + Task ConnectAsync(string serverIp, CancellationToken token); + Task SendMessageAsync(string message); + void Disconnect(); + } +} \ No newline at end of file diff --git a/LocalChat.Core/Contracts/IChatServer.cs b/LocalChat.Core/Contracts/IChatServer.cs new file mode 100644 index 0000000..d69355a --- /dev/null +++ b/LocalChat.Core/Contracts/IChatServer.cs @@ -0,0 +1,13 @@ +namespace LocalChat.Core.Contracts +{ + public interface IChatServer + { + string RoomName { get; set; } + string Greeting { get; set; } + event Action? OnLog; + Task SetRoomName(string name); + Task SetGreeting(string greeting); + Task StartListeningAsync(CancellationToken token); + Task BroadcastAsync(string message, string excludeClientId = ""); + } +} \ No newline at end of file diff --git a/LocalChat.Core/Contracts/IFileClient.cs b/LocalChat.Core/Contracts/IFileClient.cs new file mode 100644 index 0000000..0475cbb --- /dev/null +++ b/LocalChat.Core/Contracts/IFileClient.cs @@ -0,0 +1,10 @@ +namespace LocalChat.Core.Contracts +{ + public interface IFileClient + { + event Action? OnUploadProgress; + event Action? OnDownloadProgress; + Task UploadFileAsync(string serverIp, string filePath, Guid fileId); + Task DownloadFileAsync(string serverIp, string savePath, Guid fileId, long totalSize); + } +} \ No newline at end of file diff --git a/LocalChat.Core/Contracts/IFileServer.cs b/LocalChat.Core/Contracts/IFileServer.cs new file mode 100644 index 0000000..517cf2c --- /dev/null +++ b/LocalChat.Core/Contracts/IFileServer.cs @@ -0,0 +1,8 @@ +namespace LocalChat.Core.Contracts +{ + public interface IFileServer + { + event Action? OnLog; + Task StartListeningAsync(CancellationToken token); + } +} \ No newline at end of file diff --git a/LocalChat.Core/Services/ChatService.cs b/LocalChat.Core/Services/ChatService.cs index a4a09fe..a5b0c1e 100644 --- a/LocalChat.Core/Services/ChatService.cs +++ b/LocalChat.Core/Services/ChatService.cs @@ -300,17 +300,30 @@ private async Task ProcessMessageAsync(string rawMessage, string sourceClientId) private string SerializeReactions(IEnumerable reactions) { - return JsonSerializer.Serialize(GroupReactions(reactions)); + var dtos = GroupReactions(reactions); + return JsonSerializer.Serialize(dtos); } - private List GroupReactions(IEnumerable reactions) + private List GroupReactions(IEnumerable reactions) { return reactions .GroupBy(r => r.Emoji) - .Select(g => new { emoji = g.Key, users = g.Where(r => r.User != null).Select(r => r.User!.Username).ToList() } as object) + .Select(g => new ReactionDto + { + Emoji = g.Key, + Count = g.Count(), + UserNames = string.Join(", ", g.Where(r => r.User != null).Select(r => r.User!.Username)) + }) .ToList(); } + private class ReactionDto + { + public string Emoji { get; set; } = ""; + public int Count { get; set; } + public string UserNames { get; set; } = ""; + } + private async Task BroadcastReactionUpdate(string messageId, ChatDbContext db) { var reactions = await db.MessageReactions diff --git a/docs/system-design.md b/docs/system-design.md new file mode 100644 index 0000000..ac19864 --- /dev/null +++ b/docs/system-design.md @@ -0,0 +1,349 @@ +# ChatBox System Design + +**Project:** LAN Chat Application (WPF/.NET 10) +**Date:** 2026-05-20 +**Status:** Analysis Complete + +--- + +## 1. Current Architecture + +### Solution Structure +``` +ChatBox.slnx (3 projects) +├── ChatBox.Client/ # WPF client (UI + socket client) +├── ChatBox.Server/ # WPF server (TCP listener + PostgreSQL) +└── LocalChat.Core/ # Shared library (models, services, contracts) +``` + +### Technology Stack +| Component | Technology | +|-----------|------------| +| Framework | .NET 10.0-windows, WPF | +| MVVM | CommunityToolkit.Mvvm | +| Database | PostgreSQL via EF Core + Npgsql | +| Networking | TCP Sockets (port 9999 chat, 10000 file transfer) | +| Emoji | Emoji.Wpf | +| Image Handling | Base64 encoding for avatars, chunked file transfer | + +--- + +## 2. Current Folder Structure + +### ChatBox.Client/ +``` +ChatBox.Client/ +├── App.xaml / App.xaml.cs +├── MainWindow.xaml / MainWindow.xaml.cs ⚠️ 1534 lines - TOO LARGE +├── Behaviors/ +│ └── SmoothScrollBehavior.cs +├── Converters/ +│ ├── AvatarInitialsVisibilityConverter.cs +│ ├── Base64ImageConverter.cs +│ └── FirstLetterConverter.cs +├── Managers/ +│ ├── ChannelManager.cs +│ ├── ClipboardPasteHandler.cs +│ ├── ConnectionManager.cs +│ ├── EmojiManager.cs +│ ├── FileTransferManager.cs +│ ├── GalleryManager.cs +│ ├── LightboxManager.cs +│ ├── MessageRouter.cs +│ └── WindowChromeManager.cs +└── ViewModels/ + └── ChatMessage.cs ⚠️ DUPLICATE - exists also in MainWindow.xaml.cs +``` + +### LocalChat.Core/ +``` +LocalChat.Core/ +├── Contracts/ +│ ├── IChatClient.cs +│ ├── IChatServer.cs +│ ├── IFileClient.cs +│ └── IFileServer.cs +├── Data/ +│ └── ChatDbContext.cs +├── Models/ +│ ├── ChatMessage.cs +│ ├── MessageReaction.cs +│ └── User.cs +└── Services/ + ├── ChatService.cs # Contains ChatServer + ChatClient classes + └── FileTransferService.cs # Contains FileServer + FileClient classes +``` + +--- + +## 3. Architecture Patterns Observed + +### Current Patterns +| Pattern | Implementation | +|---------|---------------| +| MVVM-light | ViewModels folder exists, but Client uses code-behind heavily | +| Manager Pattern | 9 managers in Client for UI concerns | +| Service Layer | Business logic in LocalChat.Core/Services | +| Contract/Interface | Interfaces in LocalChat.Core/Contracts | +| Converter Pattern | Value converters in Converters folder | +| Behavior Pattern | WPF attached behaviors in Behaviors folder | + +### Issues Identified +1. **Code-behind heavy** - MainWindow.xaml.cs (1534 lines) contains UI logic +2. **Duplicate classes** - ChatMessage exists in two locations +3. **No DI container** - Manual constructor injection only +4. **Large files** - Violates "keep files under 200 lines" rule + +--- + +## 4. Recommended Folder Structure + +### Clean Architecture Proposal + +``` +ChatBox/ +├── ChatBox.Client/ +│ ├── App.xaml / App.xaml.cs +│ ├── MainWindow.xaml / MainWindow.xaml.cs # Keep minimal +│ ├── Views/ +│ │ ├── Controls/ +│ │ │ ├── MessageBubble.xaml +│ │ │ ├── UserAvatar.xaml +│ │ │ ├── EmojiPicker.xaml +│ │ │ └── ReactionPopup.xaml +│ │ ├── Overlays/ +│ │ │ ├── LightboxOverlay.xaml +│ │ │ └── SettingsOverlay.xaml +│ │ └── Components/ +│ │ ├── ChatMessageList.xaml +│ │ ├── ImageGallery.xaml +│ │ ├── FileGallery.xaml +│ │ ├── OnlineUsersPanel.xaml +│ │ └── ChannelSidebar.xaml +│ ├── ViewModels/ +│ │ ├── MainViewModel.cs +│ │ ├── ChatViewModel.cs +│ │ ├── MessageViewModel.cs +│ │ ├── SettingsViewModel.cs +│ │ └── GalleryViewModel.cs +│ ├── Models/ # Client-side models +│ │ └── LocalChatMessage.cs +│ ├── Services/ # Client services (if needed) +│ ├── Behaviors/ +│ ├── Converters/ +│ └── Managers/ # UI-specific managers +│ ├── ConnectionManager.cs +│ ├── ImageGalleryManager.cs +│ └── LightboxManager.cs +│ +├── ChatBox.Server/ +│ ├── App.xaml / App.xaml.cs +│ ├── MainWindow.xaml / MainWindow.xaml.cs +│ ├── Views/ +│ │ └── ServerDashboard.xaml +│ └── ViewModels/ +│ └── ServerViewModel.cs +│ +└── LocalChat.Core/ + ├── Contracts/ + ├── Models/ + ├── Data/ + └── Services/ + ├── ChatServer.cs + ├── ChatClient.cs + ├── FileServer.cs + └── FileClient.cs +``` + +### Key Changes +1. **Views/Controls/** - Reusable XAML user controls (MessageBubble, UserAvatar, etc.) +2. **Views/Components/** - Composite components (MessageList, Gallery, etc.) +3. **ViewModels/** - Formal MVVM ViewModels with commands and properties +4. **Models/** - Client-side models separate from Core models + +--- + +## 5. UI/UX Design + +### Color Palette (Current Discord-Inspired) +| Element | Color | Hex | +|---------|-------|-----| +| Background Primary | Dark | `#060607` | +| Background Secondary | Dark Gray | `#1E1F22` | +| Background Tertiary | Medium Gray | `#2B2D31` | +| Text Primary | White | `#FFFFFF` | +| Text Secondary | Light Gray | `#B5BAC1` | +| Text Muted | Gray | `#6D6F78` | +| Online Status | Green | `#23A55A` | +| Offline Status | Gray | `#747F8D` | +| Error/Disconnect | Red | `#ED4245` | +| Accent Active | Light Gray | `#E3E5E8` | + +### Layout Structure + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Title Bar (Custom Chrome - Drag, Min/Max/Close) │ +├─────────────┬───────────────────────────────────────────────────┤ +│ │ Room Name Header [Info] [Emoji] │ +│ Channels ├───────────────────────────────────────────────────┤ +│ ───────── │ │ +│ # chat │ Message List (Virtualized) │ +│ # images │ ┌─────────────────────────────────────────────┐ │ +│ # files │ │ [Avatar] Username timestamp │ │ +│ │ │ Message content here... │ │ +│ Online │ │ [Reactions] [React] │ │ +│ ───────── │ └─────────────────────────────────────────────┘ │ +│ User1 │ │ +│ User2 (You)│ ┌─────────────────────────────────────────────┐ │ +│ │ │ My messages aligned right │ │ +│ │ └─────────────────────────────────────────────┘ │ +├─────────────┴───────────────────────────────────────────────────┤ +│ Input Area (TextBox + Upload + Send) │ +├─────────────────────────────────────────────────────────────────┤ +│ Footer: Avatar Initials | Username | Online Status │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### UI Components + +| Component | Description | +|-----------|-------------| +| MessageBubble | Rounded rect with avatar, sender name, timestamp, content, reactions | +| EmojiPicker | 6-category emoji grid popup (Smileys, Animals, Food, Activities, Travel, Objects) | +| ReactionPopup | Quick emoji reaction buttons on message hover | +| LightboxOverlay | Full-screen image viewer with download option | +| SettingsOverlay | Username/avatar/IP configuration panel | + +### Interaction Patterns + +| Action | Behavior | +|--------|----------| +| Send Message | Enter key or Send button | +| Paste Image | Ctrl+V with image in clipboard | +| Drag & Drop | Files to upload | +| Emoji Reaction | Click reaction button on message | +| View Image | Click thumbnail to open lightbox | +| Channel Switch | Click channel in sidebar | +| Edit Profile | Info button → Settings overlay | + +--- + +## 6. Data Flow + +### Message Flow +``` +[Client] [Server] [Database] + │ │ │ + │──── JOIN ─────────────────>│ │ + │ │──── Save User ──────────────>│ + │ │<─── Load History ────────────│ + │<─── History + Room ───────│ │ + │ │ │ + │──── MSG ─────────────────>│ │ + │ │──── Save Message ───────────>│ + │ │<─────────────────────────────│ + │<─── Broadcast ────────────│ │ + │ │ │ + │──── REACT ───────────────>│ │ + │ │──── Save/Remove Reaction ───>│ + │<─── REACTION_UPDATE ──────│ │ +``` + +### File Transfer Flow +``` +[Client] [Server] [Storage] + │ │ │ + │──── FILE_READY ───────>│ │ + │<─── FILE_READY ────────│ (broadcast to others) │ + │ │ │ + │==== Chunk 1 upload =====>│ │ + │==== Chunk 2 upload =====>│ │ + │==== Chunk N upload =====>│ │ + │ │==== Save to disk ─────────>│ +``` + +--- + +## 7. Network Protocol + +### Message Types +| Type | Format | Direction | +|------|--------|-----------| +| JOIN | `JOIN\|UserId\|Username\|AvatarBase64` | C→S | +| MSG | `MSG\|UserId\|Content` | C→S | +| FILE_READY | `FILE_READY\|UserId\|FileId\|FileName\|Size` | C→S | +| REACT | `REACT\|UserId\|MessageId\|Emoji` | C→S | +| UPDATE_PROFILE | `UPDATE_PROFILE\|UserId\|Username\|AvatarBase64` | C→S | +| ROOM_NAME | `ROOM_NAME\|RoomName` | S→C | +| GREETING | `GREETING\|Message` | S→C | +| ONLINE_USERS | `ONLINE_USERS\|User1,User2,...` | S→C | +| REACTION_UPDATE | `REACTION_UPDATE\|MessageId\|ReactionsJson` | S→C | + +### File Transfer Protocol +- **Port:** 10000 +- **Header:** 29 bytes (Action + FileId + Offset + Length) +- **Chunk Size:** 4MB +- **Parallel:** 4 concurrent chunks + +--- + +## 8. Database Schema + +### Entities +``` +User +├── Id (PK, string) +├── Username (string, max 100) +├── AvatarBase64 (string, nullable) +└── LastSeen (datetime) + +ChatMessage +├── Id (PK, string) +├── SenderId (FK → User) +├── Content (string) +├── Timestamp (datetime) +├── IsFile (bool) +├── FileId (string, nullable) +├── FileSize (long) +└── Reactions (collection) + +MessageReaction +├── Id (PK, string) +├── MessageId (FK → ChatMessage) +├── UserId (FK → User) +├── Emoji (string, max 50) +├── CreatedAt (datetime) +└── Unique index on (MessageId, UserId, Emoji) +``` + +--- + +## 9. Key Issues & Recommendations + +### Critical Issues +1. **MainWindow.xaml.cs too large** (1534 lines) → Split into ViewModels + Views +2. **Duplicate ChatMessage class** → Consolidate into single location +3. **No DI container** → Consider Microsoft.Extensions.DependencyInjection +4. **No proper MVVM** → Create ViewModels with RelayCommand from CommunityToolkit.Mvvm + +### Recommendations +1. Extract UI logic from MainWindow.xaml.cs into MainViewModel +2. Create reusable XAML user controls for MessageBubble, EmojiPicker, ReactionPopup +3. Implement ICommand pattern for all button clicks +4. Add VirtualizingStackPanel for message list performance +5. Consider adding unit tests + +--- + +## 10. Unresolved Questions + +1. Should client have local SQLite cache for offline message history? +2. Do you want to add typing indicators? +3. Should there be message search functionality? +4. Any planned features like DMs, channels, roles? +5. Should file transfer support pause/resume? + +--- + +*Document generated from codebase analysis. To implement recommended changes, see phase plan.* \ No newline at end of file diff --git a/plans/260519-1520-emoji-reactions-feature/plan.md b/plans/260519-1520-emoji-reactions-feature/plan.md new file mode 100644 index 0000000..71a60bd --- /dev/null +++ b/plans/260519-1520-emoji-reactions-feature/plan.md @@ -0,0 +1,124 @@ +# Emoji Reactions Feature - Implementation Plan + +## Context +- **Project:** ChatBox - Local LAN chat application with WPF (.NET), MVVM-light, PostgreSQL +- **Feature:** Add emoji reactions to messages (like Discord) +- **User Requirements:** + - Emoji picker popup on right-click/long-press + - Heart emoji with no color when not hovered, colored when hovered + - Show 5-6 emoji options + - Persistent storage in PostgreSQL + real-time broadcast to all users + - Show reactions on messages (who reacted) + +## Architecture Overview + +``` +Client (WPF) Server (WPF + Console) + | | + |--- TCP 9999 (Chat) ----------------> | + |<--- Broadcast/Messages -------------- | + | | + |--------- REACT||------> | + |<------- REACTION_UPDATE ------------ | + | | + PostgreSQL Database + (Stores Users, ChatMessages, Reactions) +``` + +## Message Protocol Extensions + +### New Messages +``` +REACT|MessageId|Emoji +REACTION_UPDATE|MessageId|ReactionsJson +``` + +### ReactionsJson Format +```json +[{"emoji":"👍","users":["user1","user2"]},{"emoji":"❤️","users":["user3"]}] +``` + +## Database Schema + +### New Table: MessageReaction +```sql +CREATE TABLE message_reactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(message_id, user_id, emoji) +); + +CREATE INDEX idx_message_reactions_message_id ON message_reactions(message_id); +``` + +## Tasks + +### Task 1: Database Schema Update +- **Files:** `LocalChat.Core\Data\ChatDbContext.cs` +- Add `MessageReaction` entity +- Add `Reactions` navigation property to `ChatMessage` +- Add unique constraint (message_id, user_id, emoji) +- Run migration or ensure table creation + +### Task 2: Message Model Update +- **Files:** `LocalChat.Core\Models\ChatMessage.cs` +- Add `Reactions` property (list of MessageReaction) +- Add `ReactionSummary` computed property for UI display + +### Task 3: Server - Reaction Handling +- **Files:** `LocalChat.Core\Services\ChatService.cs` +- Add `ProcessReactionAsync` method +- Add `BroadcastReactionUpdate` method +- Parse `REACT|MessageId|Emoji` message +- Store reaction in database +- Broadcast to all clients + +### Task 4: Client - Reaction Popup UI +- **Files:** `ChatBox.Client\MainWindow.xaml` +- Create `ReactionPopup` custom control or Popup +- 5-6 emoji options: 👍 👎 😂 ❤️ 😮 😢 +- Heart emoji with hover effect (no color → colored) +- Position popup near the message + +### Task 5: Client - Right-Click Handler +- **Files:** `ChatBox.Client\MainWindow.xaml.cs` +- Add `Message_MouseRightButtonDown` handler +- Show emoji popup at cursor position +- Handle emoji selection +- Send `REACT` message to server + +### Task 6: Client - Display Reactions on Messages +- **Files:** `ChatBox.Client\MainWindow.xaml` +- Add reactions display below message content +- Show emoji + count + user names tooltip +- Update UI when `REACTION_UPDATE` received +- Refresh message list + +### Task 7: Client - Send Reaction +- **Files:** `ChatBox.Client\MainWindow.xaml.cs` +- Parse and handle `REACTION_UPDATE` messages +- Update local ChatMessage with reactions +- Refresh message list + +## Dependencies +- Task 1 → Task 3 (server needs database) +- Task 1 → Task 2 (model needs entity) +- Task 2, 3 → Task 7 (client needs server) +- Task 5, 7 → Task 6 (popup and receiving need display) + +## Status +- [ ] Task 1: Database Schema Update +- [ ] Task 2: Message Model Update +- [ ] Task 3: Server - Reaction Handling +- [ ] Task 4: Client - Reaction Popup UI +- [ ] Task 5: Client - Right-Click Handler +- [ ] Task 6: Client - Display Reactions on Messages +- [ ] Task 7: Client - Send Reaction + +## Next Steps +1. Create worktree for feature branch +2. Execute tasks sequentially using subagent-driven development +3. Each task: implement → spec review → code quality review → commit \ No newline at end of file diff --git a/plans/260520-1520-system-design-refactoring/phase-01-split-mainwindow.md b/plans/260520-1520-system-design-refactoring/phase-01-split-mainwindow.md new file mode 100644 index 0000000..a368888 --- /dev/null +++ b/plans/260520-1520-system-design-refactoring/phase-01-split-mainwindow.md @@ -0,0 +1,122 @@ +--- +title: "Phase 1 - Split MainWindow" +description: "Split MainWindow.xaml.cs (1534 lines) into MainViewModel and supporting classes" +status: pending +priority: P1 +effort: 4h +branch: feature/emoji-reactions +tags: [refactoring, mvvm, mainwindow] +created: 2026-05-20 +--- + +# Phase 1: Split MainWindow into ViewModels + +Extract code-behind logic from MainWindow.xaml.cs into MainViewModel and supporting classes. + +## Context Links + +- [System Design](../system-design.md) +- [Plan Overview](../plan.md) + +## Overview + +MainWindow.xaml.cs (1534 lines) contains UI logic, socket handling, clipboard, emoji, gallery, file transfer, and connection management all in one file. This violates the 200-line rule and makes the code hard to maintain. + +## Key Insights + +- MainWindow.xaml.cs handles: socket messages, UI state, clipboard paste, emoji selection, lightbox, file transfer, gallery management +- Code uses _prefix for private fields and direct UI element access (this.messageList) +- Current architecture uses "Manager" classes for some concerns but still keeps too much logic in code-behind +- Need to extract into: MainViewModel, MessageListViewModel, InputAreaViewModel, StatusBarViewModel + +## Requirements + +### Functional +- Extract message handling logic to MessageListViewModel +- Extract input area logic (text, clipboard, send) to InputAreaViewModel +- Extract connection status to StatusBarViewModel +- Keep MainWindow.xaml.cs minimal (~200 lines) + +### Non-Functional +- No runtime behavior changes +- All existing keyboard shortcuts and interactions preserved +- Maintain current color scheme and layout + +## Architecture + +``` +MainWindow.xaml.cs (reduced) +├── MainViewModel +│ ├── MessageListViewModel +│ │ ├── Messages: ObservableCollection +│ │ ├── SelectedMessage: MessageViewModel +│ │ └── AddMessage(), RemoveMessage() +│ ├── InputAreaViewModel +│ │ ├── InputText: string +│ │ ├── SendCommand: ICommand +│ │ └── PasteImageCommand: ICommand +│ └── StatusBarViewModel +│ ├── Username: string +│ ├── AvatarInitials: string +│ └── IsOnline: bool +``` + +## Related Code Files + +### Files to Modify +- `ChatBox.Client/MainWindow.xaml.cs` +- `ChatBox.Client/MainWindow.xaml` + +### Files to Create +- `ChatBox.Client/ViewModels/MainViewModel.cs` +- `ChatBox.Client/ViewModels/MessageListViewModel.cs` +- `ChatBox.Client/ViewModels/InputAreaViewModel.cs` +- `ChatBox.Client/ViewModels/StatusBarViewModel.cs` + +## Implementation Steps + +1. **Read MainWindow.xaml.cs** to understand current structure and identify extraction points +2. **Create folder structure** - `ChatBox.Client/ViewModels/` +3. **Create MainViewModel.cs** - Extract MainWindow field declarations and initialization +4. **Create MessageListViewModel.cs** - Extract message list management (AddMessage, RemoveMessage, messages collection) +5. **Create InputAreaViewModel.cs** - Extract input text, send command, paste handling +6. **Create StatusBarViewModel.cs** - Extract username, avatar, online status properties +7. **Wire up MainWindow.xaml** - DataContext = MainViewModel, update bindings +8. **Update MainWindow.xaml.cs** - Remove extracted logic, keep window management only +9. **Compile and verify** - Ensure no breaking changes + +## Todo List + +- [ ] Read MainWindow.xaml.cs to identify extraction points +- [ ] Create ViewModels folder +- [ ] Create MainViewModel.cs +- [ ] Create MessageListViewModel.cs +- [ ] Create InputAreaViewModel.cs +- [ ] Create StatusBarViewModel.cs +- [ ] Wire up MainWindow.xaml DataContext +- [ ] Reduce MainWindow.xaml.cs to ~200 lines +- [ ] Compile and verify existing functionality + +## Success Criteria + +- MainWindow.xaml.cs under 200 lines +- All extracted ViewModels compile without errors +- MainWindow DataContext bound to MainViewModel +- All existing functionality preserved (send message, receive message, clipboard paste) + +## Risk Assessment + +- **Risk**: Breaking existing message flow during extraction +- **Mitigation**: Extract one concern at a time, compile after each, test manually +- **Risk**: Large number of UI element references in code-behind +- **Mitigation**: Use x:Name bindings to ViewModel properties, remove direct element access + +## Security Considerations + +- No new network access or data handling in this phase +- Preserve existing input validation + +## Next Steps + +- [Phase 2](./phase-02-cleanup-duplicates.md) - Remove duplicate ChatMessage classes +- [Phase 3](./phase-03-mvvm-refactor.md) - Create proper ViewModels with RelayCommand \ No newline at end of file diff --git a/plans/260520-1520-system-design-refactoring/phase-02-cleanup-duplicates.md b/plans/260520-1520-system-design-refactoring/phase-02-cleanup-duplicates.md new file mode 100644 index 0000000..9aa6fb3 --- /dev/null +++ b/plans/260520-1520-system-design-refactoring/phase-02-cleanup-duplicates.md @@ -0,0 +1,118 @@ +--- +title: "Phase 2 - Cleanup Duplicates" +description: "Remove duplicate ChatMessage classes, consolidate into LocalChat.Core/Models" +status: pending +priority: P1 +effort: 2h +branch: feature/emoji-reactions +tags: [refactoring, models, cleanup] +created: 2026-05-20 +--- + +# Phase 2: Cleanup Duplicate Classes + +Remove duplicate ChatMessage class definitions and consolidate into single location. + +## Context Links + +- [System Design](../system-design.md) +- [Plan Overview](../plan.md) +- [Phase 1](./phase-01-split-mainwindow.md) + +## Overview + +ChatMessage exists in two locations: +1. `LocalChat.Core/Models/ChatMessage.cs` (canonical, used by EF Core) +2. `ChatBox.Client/ViewModels/ChatMessage.cs` (duplicate) +3. Inline in MainWindow.xaml.cs (duplicate fields) + +This duplication causes maintenance issues and confusion about which class to use. + +## Key Insights + +- Core/Models/ChatMessage.cs is used by EF Core for database operations +- Client/ViewModels/ChatMessage.cs is a lighter-weight client-side model +- MainWindow.xaml.cs inline defines sender ID, content, timestamp fields +- Need to decide: use Core model client-side OR create shared client model + +## Requirements + +### Functional +- Remove duplicate ChatMessage from ViewModels folder +- Remove inline ChatMessage definition from MainWindow.xaml.cs +- Ensure all usages reference LocalChat.Core/Models/ChatMessage +- Add any client-specific properties as partial/extension + +### Non-Functional +- No runtime behavior changes +- All message serialization/deserialization preserved + +## Architecture + +``` +Before (Duplicate): +├── LocalChat.Core/Models/ChatMessage.cs ← Entity for EF Core +├── ChatBox.Client/ViewModels/ChatMessage.cs ← Duplicate +└── MainWindow.xaml.cs inline fields ← Duplicate + +After (Consolidated): +└── LocalChat.Core/Models/ChatMessage.cs ← Single source of truth +``` + +## Related Code Files + +### Files to Modify +- `ChatBox.Client/MainWindow.xaml.cs` - Remove inline Message class +- `ChatBox.Client/ViewModels/ChatMessage.cs` - Delete file + +### Files to Check for Usages +- `ChatBox.Client/MainWindow.xaml.cs` +- `ChatBox.Client/ViewModels/` (any files referencing ChatMessage) +- `ChatBox.Client/Managers/` (any managers using ChatMessage) + +## Implementation Steps + +1. **Search for all ChatMessage usages** in ChatBox.Client +2. **Read LocalChat.Core/Models/ChatMessage.cs** to understand structure +3. **Read ChatBox.Client/ViewModels/ChatMessage.cs** to see differences +4. **Check if client has extra properties** not in Core model +5. **If client has extra properties**, extend Core model or create LocalChatMessage client model +6. **Update all references** to use Core model +7. **Delete duplicate files** +8. **Compile and verify** + +## Todo List + +- [ ] Search for all ChatMessage usages in ChatBox.Client +- [ ] Read existing ChatMessage classes in both locations +- [ ] Identify any client-specific properties missing from Core model +- [ ] Extend Core model if needed (add client properties) +- [ ] Update all references in MainWindow.xaml.cs +- [ ] Update all references in ViewModels +- [ ] Update all references in Managers +- [ ] Delete ChatBox.Client/ViewModels/ChatMessage.cs +- [ ] Delete any inline Message class in MainWindow.xaml.cs +- [ ] Compile and verify + +## Success Criteria + +- Only one ChatMessage definition exists (in LocalChat.Core/Models/) +- No compile errors from reference changes +- All message handling uses consolidated model + +## Risk Assessment + +- **Risk**: Client needs properties not in Core model +- **Mitigation**: Add missing properties to Core model (they may be useful for server too) +- **Risk**: Breaking message serialization +- **Mitigation**: Verify message format matches protocol spec + +## Security Considerations + +- No security changes in this phase +- Ensure no sensitive data handling changes + +## Next Steps + +- [Phase 3](./phase-03-mvvm-refactor.md) - Create proper ViewModels with RelayCommand +- [Phase 1](./phase-01-split-mainwindow.md) - Can run in parallel if duplicate removal is simple \ No newline at end of file diff --git a/plans/260520-1520-system-design-refactoring/phase-03-mvvm-refactor.md b/plans/260520-1520-system-design-refactoring/phase-03-mvvm-refactor.md new file mode 100644 index 0000000..ad7ae6f --- /dev/null +++ b/plans/260520-1520-system-design-refactoring/phase-03-mvvm-refactor.md @@ -0,0 +1,119 @@ +--- +title: "Phase 3 - MVVM Refactor" +description: "Create proper ViewModels with RelayCommand using CommunityToolkit.Mvvm" +status: pending +priority: P1 +effort: 4h +branch: feature/emoji-reactions +tags: [refactoring, mvvm, community-toolkit] +created: 2026-05-20 +--- + +# Phase 3: MVVM Refactor with RelayCommand + +Convert all button click handlers and UI logic to proper MVVM with CommunityToolkit.Mvvm. + +## Context Links + +- [System Design](../system-design.md) +- [Plan Overview](../plan.md) +- [Phase 1](./phase-01-split-mainwindow.md) +- [Phase 2](./phase-02-cleanup-duplicates.md) + +## Overview + +Current implementation uses code-behind event handlers (e.g., `SendButton_Click`, `EmojiPickerButton_Click`). Need to convert to `ICommand` pattern using `RelayCommand` from CommunityToolkit.Mvvm. + +## Key Insights + +- CommunityToolkit.Mvvm already in tech stack but not fully utilized +- `RelayCommand` handles CanExecute for button enable/disable +- `ObservableObject` provides `PropertyChanged` notification +- Convert all event handlers in MainWindow.xaml.cs to commands + +## Requirements + +### Functional +- Convert SendButton_Click to SendCommand +- Convert EmojiPickerButton_Click to ToggleEmojiPickerCommand +- Convert ReactionButton_Click to ShowReactionPopupCommand +- Convert InfoButton_Click to ShowSettingsCommand +- Convert Channel selection to SelectChannelCommand +- Convert Image thumbnail click to OpenLightboxCommand +- Convert File thumbnail click to OpenFileCommand + +### Non-Functional +- All existing keyboard shortcuts preserved +- Button enable/disable states work correctly + +## Architecture + +``` +MainViewModel (with CommunityToolkit.Mvvm) +├── [ObservableProperty] Username +├── [ObservableProperty] InputText +├── [ObservableProperty] IsEmojiPickerOpen +├── [RelayCommand] SendMessage +├── [RelayCommand] ToggleEmojiPicker +├── [RelayCommand] ShowReactionPopup +├── [RelayCommand] ShowSettings +├── [RelayCommand] SelectChannel +└── [RelayCommand] OpenLightbox +``` + +## Related Code Files + +### Files to Modify +- `ChatBox.Client/ViewModels/MainViewModel.cs` +- `ChatBox.Client/ViewModels/MessageListViewModel.cs` +- `ChatBox.Client/ViewModels/InputAreaViewModel.cs` +- `ChatBox.Client/MainWindow.xaml` - Update binding syntax + +### Files to Check +- Any Manager classes that handle button clicks + +## Implementation Steps + +1. **Check if CommunityToolkit.Mvvm is installed** in ChatBox.Client csproj +2. **Read existing ViewModel files** to understand current structure +3. **Update MainViewModel.cs** - Add ObservableProperty attributes and RelayCommand attributes +4. **Update MessageListViewModel.cs** - Add message selection commands +5. **Update InputAreaViewModel.cs** - Add send command with CanExecute +6. **Update MainWindow.xaml** - Change event handlers to Command bindings +7. **Remove event handlers** from MainWindow.xaml.cs +8. **Compile and verify** - Ensure commands work + +## Todo List + +- [ ] Verify CommunityToolkit.Mvvm package in ChatBox.Client +- [ ] Read existing ViewModels to understand structure +- [ ] Update MainViewModel with [ObservableProperty] and [RelayCommand] +- [ ] Update MessageListViewModel with selection commands +- [ ] Update InputAreaViewModel with send command +- [ ] Update MainWindow.xaml Command bindings +- [ ] Remove event handler code from MainWindow.xaml.cs +- [ ] Compile and verify all commands work + +## Success Criteria + +- All button clicks use RelayCommand +- Keyboard shortcuts work (Enter to send, Ctrl+V to paste) +- No event handlers in MainWindow.xaml.cs +- All buttons enable/disable based on CanExecute + +## Risk Assessment + +- **Risk**: CommunityToolkit.Mvvm version mismatch +- **Mitigation**: Check csproj for version, update if needed +- **Risk**: Complex commands with parameters +- **Mitigation**: Use CommandParameter binding in XAML + +## Security Considerations + +- No security changes in this phase +- Preserve existing input validation + +## Next Steps + +- [Phase 4](./phase-04-add-di-container.md) - Add DI container +- [Phase 5](./phase-05-create-views-controls.md) - Extract XAML controls \ No newline at end of file diff --git a/plans/260520-1520-system-design-refactoring/phase-04-add-di-container.md b/plans/260520-1520-system-design-refactoring/phase-04-add-di-container.md new file mode 100644 index 0000000..a709b42 --- /dev/null +++ b/plans/260520-1520-system-design-refactoring/phase-04-add-di-container.md @@ -0,0 +1,126 @@ +--- +title: "Phase 4 - Add DI Container" +description: "Add Microsoft.Extensions.DependencyInjection for service registration" +status: pending +priority: P2 +effort: 2h +branch: feature/emoji-reactions +tags: [refactoring, di, dependency-injection] +created: 2026-05-20 +--- + +# Phase 4: Add DI Container + +Add Microsoft.Extensions.DependencyInjection for loose coupling and testability. + +## Context Links + +- [System Design](../system-design.md) +- [Plan Overview](../plan.md) +- [Phase 3](./phase-03-mvvm-refactor.md) + +## Overview + +Services are currently created manually in MainWindow.xaml.cs. Adding DI container will: +1. Enable constructor injection +2. Improve testability (can substitute mocks) +3. Follow best practices for .NET applications +4. Reduce tight coupling between components + +## Key Insights + +- Services currently created as singletons in MainWindow constructor +- DI container will manage service lifetimes +- ViewModels can receive services via constructor +- Need to register: ChatClient, FileClient, Managers + +## Requirements + +### Functional +- Add Microsoft.Extensions.DependencyInjection NuGet package +- Register all services in DI container +- Update MainWindow to resolve services from container +- Update ViewModels to receive services via constructor + +### Non-Functional +- Preserve existing service lifetimes (singleton for most) +- No breaking changes to existing code paths + +## Architecture + +``` +App.xaml.cs +├── ConfigureServices() +│ ├── services.AddSingleton() +│ ├── services.AddSingleton() +│ ├── services.AddSingleton() +│ └── services.AddTransient() +└── serviceProvider (static) + +MainWindow.xaml.cs +├── MainViewModel vm (resolved from container) +└── DataContext = vm +``` + +## Related Code Files + +### Files to Modify +- `ChatBox.Client/App.xaml.cs` +- `ChatBox.Client/MainWindow.xaml.cs` +- `ChatBox.Client/ViewModels/MainViewModel.cs` +- `ChatBox.Client/ViewModels/MessageListViewModel.cs` + +### Files to Create +- `ChatBox.Client/Services/ServiceConfiguration.cs` (optional, for organization) + +## Implementation Steps + +1. **Add NuGet package** - Microsoft.Extensions.DependencyInjection +2. **Read App.xaml.cs** to understand current startup +3. **Create DI configuration** in App.xaml.cs +4. **Register ChatServer, FileServer** as singletons +5. **Register Managers** as singletons +6. **Register ViewModels** as transient +7. **Update MainWindow.xaml.cs** - Remove manual service creation +8. **Update ViewModels** - Add constructor parameters +9. **Compile and verify** + +## Todo List + +- [ ] Add Microsoft.Extensions.DependencyInjection NuGet package +- [ ] Read App.xaml.cs to understand startup +- [ ] Create ConfigureServices method in App.xaml.cs +- [ ] Register ChatServer (IChatServer) +- [ ] Register FileServer (IFileServer) +- [ ] Register ConnectionManager +- [ ] Register GalleryManager +- [ ] Register LightboxManager +- [ ] Register other Managers +- [ ] Register ViewModels as transient +- [ ] Update MainWindow.xaml.cs to resolve from container +- [ ] Update ViewModels with constructor injection +- [ ] Compile and verify + +## Success Criteria + +- All services resolved via DI container +- No manual `new SomeManager()` calls in code +- ViewModels receive services via constructor +- Application starts and works correctly + +## Risk Assessment + +- **Risk**: Breaking existing singleton behavior +- **Mitigation**: Register as singleton explicitly +- **Risk**: Circular dependency +- **Mitigation**: Restructure if needed, use interface abstractions + +## Security Considerations + +- No security changes in this phase +- Container itself is secure + +## Next Steps + +- [Phase 5](./phase-05-create-views-controls.md) - Extract XAML controls +- [Phase 6](./phase-06-refactor-core-services.md) - Split core service files \ No newline at end of file diff --git a/plans/260520-1520-system-design-refactoring/phase-05-create-views-controls.md b/plans/260520-1520-system-design-refactoring/phase-05-create-views-controls.md new file mode 100644 index 0000000..a377aa3 --- /dev/null +++ b/plans/260520-1520-system-design-refactoring/phase-05-create-views-controls.md @@ -0,0 +1,131 @@ +--- +title: "Phase 5 - Create Views Controls" +description: "Extract XAML user controls: MessageBubble, EmojiPicker, ReactionPopup" +status: pending +priority: P2 +effort: 6h +branch: feature/emoji-reactions +tags: [refactoring, xaml, user-controls] +created: 2026-05-20 +--- + +# Phase 5: Create Views/Controls + +Extract XAML into reusable user controls following the folder structure from system design. + +## Context Links + +- [System Design](../system-design.md) +- [Plan Overview](../plan.md) +- [Phase 4](./phase-04-add-di-container.md) + +## Overview + +Extract inline XAML into reusable controls: +1. MessageBubble - message display with avatar, reactions +2. UserAvatar - avatar with fallback initials +3. EmojiPicker - emoji selection grid popup +4. ReactionPopup - quick emoji reaction buttons + +## Key Insights + +- Current message items are defined inline in MessageList DataTemplate +- Emoji picker is a popup overlay defined in MainWindow +- Reaction buttons are inline in message template +- Extraction enables reuse and simplifies MainWindow.xaml + +## Requirements + +### Functional +- Create MessageBubble.xaml with sender, timestamp, content, reactions +- Create UserAvatar.xaml with initials fallback +- Create EmojiPicker.xaml with category tabs and emoji grid +- Create ReactionPopup.xaml with quick reaction buttons +- Create MessageList.xaml as composite component + +### Non-Functional +- Maintain exact same visual appearance +- All bindings work correctly +- No duplicate visual elements + +## Architecture + +``` +ChatBox.Client/Views/ +├── Controls/ +│ ├── MessageBubble.xaml + MessageBubble.xaml.cs +│ ├── UserAvatar.xaml + UserAvatar.xaml.cs +│ ├── EmojiPicker.xaml + EmojiPicker.xaml.cs +│ └── ReactionPopup.xaml + ReactionPopup.xaml.cs +├── Components/ +│ ├── MessageList.xaml + MessageList.xaml.cs +│ ├── ImageGallery.xaml + ImageGallery.xaml.cs +│ ├── FileGallery.xaml + FileGallery.xaml.cs +│ ├── OnlineUsersPanel.xaml + OnlineUsersPanel.xaml.cs +│ └── ChannelSidebar.xaml + ChannelSidebar.xaml.cs +└── Overlays/ + ├── LightboxOverlay.xaml + LightboxOverlay.xaml.cs + └── SettingsOverlay.xaml + SettingsOverlay.xaml.cs +``` + +## Related Code Files + +### Files to Create +- `ChatBox.Client/Views/Controls/MessageBubble.xaml` +- `ChatBox.Client/Views/Controls/MessageBubble.xaml.cs` +- `ChatBox.Client/Views/Controls/UserAvatar.xaml` +- `ChatBox.Client/Views/Controls/UserAvatar.xaml.cs` +- `ChatBox.Client/Views/Controls/EmojiPicker.xaml` +- `ChatBox.Client/Views/Controls/EmojiPicker.xaml.cs` +- `ChatBox.Client/Views/Controls/ReactionPopup.xaml` +- `ChatBox.Client/Views/Controls/ReactionPopup.xaml.cs` + +### Files to Modify +- `ChatBox.Client/MainWindow.xaml` - Use extracted controls +- `ChatBox.Client/MainWindow.xaml.cs` - Update event handlers to commands + +## Implementation Steps + +1. **Create folder structure** - Views/Controls, Views/Components, Views/Overlays +2. **Create UserAvatar.xaml** - Simplest control, start here +3. **Create MessageBubble.xaml** - Message display with bindings +4. **Create ReactionPopup.xaml** - Quick reaction overlay +5. **Create EmojiPicker.xaml** - Category tabs and emoji grid +6. **Update MainWindow.xaml** - Replace inline XAML with control references +7. **Update code-behind** - Convert to commands +8. **Compile and verify** - Visual inspection + +## Todo List + +- [ ] Create Views/Controls folder structure +- [ ] Create UserAvatar.xaml + code-behind +- [ ] Create MessageBubble.xaml + code-behind +- [ ] Create ReactionPopup.xaml + code-behind +- [ ] Create EmojiPicker.xaml + code-behind +- [ ] Create MessageList component +- [ ] Update MainWindow.xaml to use controls +- [ ] Compile and visual verify + +## Success Criteria + +- MainWindow.xaml simplified with control references +- All controls visually identical to previous inline version +- Bindings work correctly +- No duplicate XAML + +## Risk Assessment + +- **Risk**: Breaking existing bindings +- **Mitigation**: Test each control individually +- **Risk**: Style differences +- **Mitigation**: Copy exact styles from existing MainWindow.xaml + +## Security Considerations + +- No security changes in this phase +- All content display only + +## Next Steps + +- [Phase 6](./phase-06-refactor-core-services.md) - Split core service files +- Post-refactor: Add unit tests \ No newline at end of file diff --git a/plans/260520-1520-system-design-refactoring/phase-06-refactor-core-services.md b/plans/260520-1520-system-design-refactoring/phase-06-refactor-core-services.md new file mode 100644 index 0000000..d8e0346 --- /dev/null +++ b/plans/260520-1520-system-design-refactoring/phase-06-refactor-core-services.md @@ -0,0 +1,132 @@ +--- +title: "Phase 6 - Refactor Core Services" +description: "Split ChatService.cs and FileTransferService.cs into individual classes" +status: pending +priority: P3 +effort: 6h +branch: feature/emoji-reactions +tags: [refactoring, services, core] +created: 2026-05-20 +--- + +# Phase 6: Refactor Core Services + +Split large service files (containing 2+ classes each) into individual class files. + +## Context Links + +- [System Design](../system-design.md) +- [Plan Overview](../plan.md) +- [Phase 5](./phase-05-create-views-controls.md) + +## Overview + +Current service files: +- `ChatService.cs` contains `ChatServer` + `ChatClient` classes +- `FileTransferService.cs` contains `FileServer` + `FileClient` classes + +Each file violates the 200-line rule. Split into individual files per class. + +## Key Insights + +- Each class already has distinct responsibility +- ChatServer handles server-side TCP messaging +- ChatClient handles client-side TCP messaging +- FileServer handles file upload reception +- FileClient handles file download +- Split is straightforward file organization + +## Requirements + +### Functional +- Split ChatService.cs into ChatServer.cs and ChatClient.cs +- Split FileTransferService.cs into FileServer.cs and FileClient.cs +- Update using statements in all referencing files +- Preserve all existing functionality + +### Non-Functional +- No behavioral changes +- Same class public interfaces +- Same internal implementation + +## Architecture + +``` +LocalChat.Core/Services/ (Before) +├── ChatService.cs # Contains ChatServer + ChatClient (~500 lines) +└── FileTransferService.cs # Contains FileServer + FileClient (~500 lines) + +LocalChat.Core/Services/ (After) +├── ChatServer.cs # Single class (~250 lines) +├── ChatClient.cs # Single class (~250 lines) +├── FileServer.cs # Single class (~250 lines) +└── FileClient.cs # Single class (~250 lines) +``` + +## Related Code Files + +### Files to Delete +- `LocalChat.Core/Services/ChatService.cs` +- `LocalChat.Core/Services/FileTransferService.cs` + +### Files to Create +- `LocalChat.Core/Services/ChatServer.cs` +- `LocalChat.Core/Services/ChatClient.cs` +- `LocalChat.Core/Services/FileServer.cs` +- `LocalChat.Core/Services/FileClient.cs` + +### Files to Modify +- Any files using `using LocalChat.Core.Services;` +- ChatBox.Client (references ChatClient, FileClient) +- ChatBox.Server (references ChatServer, FileServer) + +## Implementation Steps + +1. **Read ChatService.cs** to understand ChatServer and ChatClient classes +2. **Read FileTransferService.cs** to understand FileServer and FileClient classes +3. **Create ChatServer.cs** - Extract ChatServer class to new file +4. **Create ChatClient.cs** - Extract ChatClient class to new file +5. **Create FileServer.cs** - Extract FileServer class to new file +6. **Create FileClient.cs** - Extract FileClient class to new file +7. **Search for all files using ChatService.cs and FileTransferService.cs** +8. **Update using statements** in all referencing files +9. **Delete original files** +10. **Compile and verify** + +## Todo List + +- [ ] Read ChatService.cs to identify class boundaries +- [ ] Read FileTransferService.cs to identify class boundaries +- [ ] Create ChatServer.cs +- [ ] Create ChatClient.cs +- [ ] Create FileServer.cs +- [ ] Create FileClient.cs +- [ ] Search for all usages of original files +- [ ] Update using statements in ChatBox.Client +- [ ] Update using statements in ChatBox.Server +- [ ] Delete ChatService.cs +- [ ] Delete FileTransferService.cs +- [ ] Compile and verify + +## Success Criteria + +- All services in individual files under 200 lines +- No compile errors from reference changes +- All functionality preserved + +## Risk Assessment + +- **Risk**: Breaking namespace or using statements +- **Mitigation**: Copy exact namespace, update using statements +- **Risk**: Partial class issues +- **Mitigation**: Verify no partial class usage + +## Security Considerations + +- No security changes in this phase +- Refactoring only + +## Next Steps + +- Post-refactor: Add unit tests +- Post-refactor: Consider adding integration tests with test container \ No newline at end of file diff --git a/plans/260520-1520-system-design-refactoring/plan.md b/plans/260520-1520-system-design-refactoring/plan.md new file mode 100644 index 0000000..bb86ed7 --- /dev/null +++ b/plans/260520-1520-system-design-refactoring/plan.md @@ -0,0 +1,46 @@ +--- +title: "System Design Refactoring" +description: "Refactor ChatBox project: split MainWindow, remove duplicates, implement proper MVVM, add DI container" +status: pending +priority: P1 +effort: 24h +branch: feature/emoji-reactions +tags: [refactoring, mvvm, architecture] +created: 2026-05-20 +--- + +# System Design Refactoring Plan + +Split monolithic MainWindow.xaml.cs into proper MVVM architecture with CommunityToolkit.Mvvm. + +## Phases + +| Phase | Status | Description | +|-------|--------|-------------| +| [phase-01-split-mainwindow.md](./phase-01-split-mainwindow.md) | pending | Split MainWindow.xaml.cs into MainViewModel and supporting ViewModels | +| [phase-02-cleanup-duplicates.md](./phase-02-cleanup-duplicates.md) | pending | Remove duplicate ChatMessage classes, consolidate | +| [phase-03-mvvm-refactor.md](./phase-03-mvvm-refactor.md) | pending | Create proper ViewModels with RelayCommand | +| [phase-04-add-di-container.md](./phase-04-add-di-container.md) | pending | Add Microsoft.Extensions.DependencyInjection | +| [phase-05-create-views-controls.md](./phase-05-create-views-controls.md) | pending | Extract XAML user controls (MessageBubble, EmojiPicker, etc.) | +| [phase-06-refactor-core-services.md](./phase-06-refactor-core-services.md) | pending | Split large service files into individual classes | + +## Key Dependencies + +- Phase 1 → Phase 3 (ViewModel extraction) +- Phase 2 → Phase 3 (consolidated models) +- Phase 3 → Phase 4 (DI wiring) +- Phase 4 → Phase 5 (DI-based view construction) + +## Success Criteria + +- MainWindow.xaml.cs under 200 lines +- No duplicate model classes +- All UI logic uses RelayCommand pattern +- All services registered in DI container +- All XAML controls extracted to Views/Controls/ + +## Notes + +- Keep existing functionality working after each phase +- Commit after each phase for easy rollback +- Use CommunityToolkit.Mvvm for ObservableObject and RelayCommand \ No newline at end of file From 0c462c5a4102fab6362cc4210d14d75c324c970f Mon Sep 17 00:00:00 2001 From: UGing265 Date: Thu, 21 May 2026 01:35:01 +0700 Subject: [PATCH 17/26] feat(ui): add emoji reaction bar, draft image staging, lightbox, image gallery, file gallery, smooth scroll, and channel switching --- ChatBox.Client/MainWindow.xaml | 373 ++++++++++++++++++++----- ChatBox.Client/MainWindow.xaml.cs | 152 ++++++---- LocalChat.Core/Services/ChatService.cs | 20 +- 3 files changed, 405 insertions(+), 140 deletions(-) diff --git a/ChatBox.Client/MainWindow.xaml b/ChatBox.Client/MainWindow.xaml index 1098af7..ff4cda0 100644 --- a/ChatBox.Client/MainWindow.xaml +++ b/ChatBox.Client/MainWindow.xaml @@ -67,77 +67,240 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + @@ -760,9 +923,8 @@ - - + @@ -882,6 +1044,7 @@ + @@ -897,6 +1060,21 @@ + + @@ -910,9 +1088,25 @@ - + + + + @@ -976,6 +1170,8 @@ + + @@ -1160,8 +1356,45 @@ + + + + + + + + + + + + + + + + + + + + + - + diff --git a/ChatBox.Client/MainWindow.xaml.cs b/ChatBox.Client/MainWindow.xaml.cs index 048c0f2..5261262 100644 --- a/ChatBox.Client/MainWindow.xaml.cs +++ b/ChatBox.Client/MainWindow.xaml.cs @@ -1,5 +1,6 @@ using LocalChat.Core.Services; using System; +using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Text.Json; @@ -115,7 +116,7 @@ public partial class MainWindow : Window private string _displayName = ""; private CancellationTokenSource _cts = new CancellationTokenSource(); private ChatMessage? _currentTransferMessage; - private System.Collections.Generic.List _pendingImages = new System.Collections.Generic.List(); + private ObservableCollection _pendingImages = new ObservableCollection(); private System.Collections.Generic.List _allMessages = new System.Collections.Generic.List(); private string _currentChannel = "chat"; @@ -126,6 +127,10 @@ public MainWindow() LoadOrGenerateConfig(); InitializeEmojis(); + // Bind staging panel to pending images list + DraftStagingItems.ItemsSource = _pendingImages; + UpdateDraftPanelVisibility(); + _chatClient.OnMessageReceived += HandleIncomingMessage; _fileClient.OnUploadProgress += UpdateProgress; _fileClient.OnDownloadProgress += UpdateProgress; @@ -395,24 +400,26 @@ private void HandleIncomingMessage(string rawMessage) string type = parts[0]; - if (type == "MSG") // MSG|Sender|Content|AvatarBase64|Timestamp|ReactionsJson + if (type == "MSG") // MSG|MessageId|Sender|Content|AvatarBase64|Timestamp|ReactionsJson { - string sender = parts[1]; - string content = parts[2]; - string avatar = parts.Length > 3 ? parts[3] : ""; - string time = parts.Length > 4 ? FormatTimestamp(parts[4]) : FormatTimestamp(DateTime.UtcNow.ToString("O")); - string reactionsJson = parts.Length > 5 ? parts[5] : "[]"; - bool isMe = (sender == txtUsername.Text || sender == "Me"); + if (parts.Length < 5) return; + string messageId = parts[1]; + string sender = parts[2]; + string content = parts[3]; + string avatar = parts[4]; + string time = parts.Length > 5 ? FormatTimestamp(parts[5]) : FormatTimestamp(DateTime.UtcNow.ToString("O")); + string reactionsJson = parts.Length > 6 ? parts[6] : "[]"; + bool isMe = (sender == txtUsername.Text || sender == _displayName); var chatMsg = new ChatMessage { - MessageId = Guid.NewGuid().ToString(), + MessageId = messageId, Sender = sender, Content = content, IsFile = false, AvatarBase64 = avatar, IsMe = isMe, Timestamp = time, - RawDate = DateTime.TryParse(parts.Length > 4 ? parts[4] : "", out DateTime rdt) ? rdt : DateTime.UtcNow + RawDate = DateTime.TryParse(parts[5], out DateTime rdt) ? rdt : DateTime.UtcNow }; ParseReactionsToMessage(chatMsg, reactionsJson); _allMessages.Add(chatMsg); @@ -425,7 +432,7 @@ private void HandleIncomingMessage(string rawMessage) } else if (type == "FILE_READY") // FILE_READY|FileId|FileName|Size|Sender|AvatarBase64|Timestamp|ReactionsJson { - if (parts.Length < 6) return; + if (parts.Length < 7) return; string fileId = parts[1]; string fileName = parts[2]; long size = long.Parse(parts[3]); @@ -433,11 +440,11 @@ private void HandleIncomingMessage(string rawMessage) string avatar = parts[5]; string time = parts.Length > 6 ? FormatTimestamp(parts[6]) : FormatTimestamp(DateTime.UtcNow.ToString("O")); string reactionsJson = parts.Length > 7 ? parts[7] : "[]"; - bool isMe = (sender == txtUsername.Text || sender == "Me"); + bool isMe = (sender == txtUsername.Text || sender == _displayName); var fileMsg = new ChatMessage { - MessageId = Guid.NewGuid().ToString(), + MessageId = fileId, Sender = sender, Content = fileName, IsFile = true, @@ -447,7 +454,7 @@ private void HandleIncomingMessage(string rawMessage) IsMe = isMe, Timestamp = time, IsInImageChannel = (_currentChannel == "images"), - RawDate = DateTime.TryParse(parts.Length > 6 ? parts[6] : "", out DateTime rdt2) ? rdt2 : DateTime.UtcNow + RawDate = DateTime.TryParse(parts[6], out DateTime rdt2) ? rdt2 : DateTime.UtcNow }; ParseReactionsToMessage(fileMsg, reactionsJson); _allMessages.Add(fileMsg); @@ -715,23 +722,32 @@ private void AddEmojisToPanel(WrapPanel panel, string[] emojis) private async void BtnSendChat_Click(object sender, RoutedEventArgs e) { string cleanText = (txtInput.Text ?? "").Replace("\r", "").Replace("\n", "").Trim(); - if (string.IsNullOrWhiteSpace(cleanText)) return; - - string text = cleanText; - txtInput.Text = ""; - - var newMsg = new ChatMessage { Sender = string.IsNullOrWhiteSpace(_displayName) ? "User" : _displayName, Content = text, AvatarBase64 = _avatarBase64, IsMe = true, Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")) }; - _allMessages.Add(newMsg); - RefreshMessageList(); - try + // Send pending images first if any + if (_pendingImages.Count > 0) { - await _chatClient.SendMessageAsync($"MSG|{_userId}|{text}"); + await SendPendingImagesAsync(); } - catch (Exception) + + // Then send text message if there's text + if (!string.IsNullOrWhiteSpace(cleanText)) { - MessageBox.Show("Lost connection to server! Your message could not be sent."); - BtnDisconnect_Click(null, null); + string text = cleanText; + txtInput.Text = ""; + + var newMsg = new ChatMessage { MessageId = Guid.NewGuid().ToString(), Sender = string.IsNullOrWhiteSpace(_displayName) ? "User" : _displayName, Content = text, AvatarBase64 = _avatarBase64, IsMe = true, Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")) }; + _allMessages.Add(newMsg); + RefreshMessageList(); + + try + { + await _chatClient.SendMessageAsync($"MSG|{_userId}|{newMsg.MessageId}|{text}"); + } + catch (Exception) + { + MessageBox.Show("Lost connection to server! Your message could not be sent."); + BtnDisconnect_Click(null, null); + } } } @@ -746,10 +762,11 @@ private async Task UploadFileAsync(string filePath) { var msg = new ChatMessage { - Sender = string.IsNullOrWhiteSpace(_displayName) ? "User" : _displayName, - Content = fileInfo.Name, - IsFile = true, - FileId = fileId.ToString(), + MessageId = fileId.ToString(), // Match server message ID + Sender = string.IsNullOrWhiteSpace(_displayName) ? "User" : _displayName, + Content = fileInfo.Name, + IsFile = true, + FileId = fileId.ToString(), FileSize = fileInfo.Length, AvatarBase64 = _avatarBase64, IsTransferring = true, @@ -997,6 +1014,11 @@ private void ChanFiles_Click(object sender, System.Windows.Input.MouseButtonEven SelectChannel("files"); } + private void UpdateDraftPanelVisibility() + { + DraftStagingPanel.Visibility = _pendingImages.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + } + private void RefreshMessageList() { lstChatMessages.Items.Clear(); @@ -1307,7 +1329,7 @@ private void GalleryFile_Click(object sender, System.Windows.Input.MouseButtonEv } } - private void TxtInput_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) + private async void TxtInput_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e) { var isControl = System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Control); var isAlt = System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Alt); @@ -1340,7 +1362,7 @@ private void TxtInput_PreviewKeyDown(object sender, System.Windows.Input.KeyEven return; } e.Handled = true; - SendPendingImages(); + await SendPendingImagesAsync(); BtnSendChat_Click(this, new RoutedEventArgs()); } else if (e.Key == System.Windows.Input.Key.V && isControl) @@ -1357,9 +1379,9 @@ private void HandleImagePaste() { try { - if (_pendingImages.Count >= 10) + if (_pendingImages.Count >= 5) { - MessageBox.Show("Maximum 10 images pending. Send or remove some first.", "Limit Reached", MessageBoxButton.OK, MessageBoxImage.Information); + MessageBox.Show("Maximum 5 images pending. Send or remove some first.", "Limit Reached", MessageBoxButton.OK, MessageBoxImage.Information); return; } @@ -1385,7 +1407,7 @@ private void HandleImagePaste() var msg = new ChatMessage { - MessageId = Guid.NewGuid().ToString(), + MessageId = fileId.ToString(), // Use same ID as FileId so server round-trip matches Sender = _displayName, Content = fileInfo.Name, IsFile = true, @@ -1400,10 +1422,9 @@ private void HandleImagePaste() IsDraft = true }; - // Add directly to messages list (inline draft) + // Add to staging panel only (not message list yet) _pendingImages.Add(msg); - _allMessages.Add(msg); - RefreshMessageList(); + UpdateDraftPanelVisibility(); } catch (Exception ex) { @@ -1411,33 +1432,43 @@ private void HandleImagePaste() } } - private async void SendPendingImages() - { - if (_pendingImages.Count == 0) return; + private bool _isSendingPendingImages = false; - var toSend = _pendingImages.ToList(); - _pendingImages.Clear(); - // UpdatePendingImagesPanel no longer needed - panel removed + private async Task SendPendingImagesAsync() + { + if (_pendingImages.Count == 0 || _isSendingPendingImages) return; - foreach (var msg in toSend) + _isSendingPendingImages = true; + try { - msg.IsDraft = false; - msg.IsTransferring = true; - RefreshMessageList(); + var toSend = _pendingImages.ToList(); + _pendingImages.Clear(); + UpdateDraftPanelVisibility(); - try - { - await _fileClient.UploadFileAsync(_serverIp, msg.LocalFilePath, Guid.Parse(msg.FileId)); - msg.IsTransferring = false; - await _chatClient.SendMessageAsync($"FILE_READY|{_userId}|{msg.FileId}|{msg.Content}|{msg.FileSize}"); - } - catch + foreach (var msg in toSend) { - msg.IsTransferring = false; + msg.IsDraft = false; + msg.IsTransferring = true; + _allMessages.Add(msg); + + try + { + await _fileClient.UploadFileAsync(_serverIp, msg.LocalFilePath, Guid.Parse(msg.FileId)); + msg.IsTransferring = false; + await _chatClient.SendMessageAsync($"FILE_READY|{_userId}|{msg.FileId}|{msg.Content}|{msg.FileSize}"); + } + catch + { + msg.IsTransferring = false; + } } - } - RefreshMessageList(); + RefreshMessageList(); + } + finally + { + _isSendingPendingImages = false; + } } private void Topbar_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e) @@ -1556,8 +1587,7 @@ private void BtnRemovePending_Click(object sender, RoutedEventArgs e) if (sender is Button btn && btn.Tag is ChatMessage msg) { _pendingImages.Remove(msg); - _allMessages.Remove(msg); - RefreshMessageList(); + UpdateDraftPanelVisibility(); } } diff --git a/LocalChat.Core/Services/ChatService.cs b/LocalChat.Core/Services/ChatService.cs index a5b0c1e..3f8d617 100644 --- a/LocalChat.Core/Services/ChatService.cs +++ b/LocalChat.Core/Services/ChatService.cs @@ -99,7 +99,7 @@ private async Task HandleClientAsync(string clientId, TcpClient client, Cancella _clients.TryRemove(clientId, out _); if (_onlineUsers.TryRemove(clientId, out string? username)) { - await BroadcastAsync($"MSG|System|{username} has left the chat.||{DateTime.UtcNow:O}"); + await BroadcastAsync($"MSG|{Guid.NewGuid()}|System|{username} has left the chat.||{DateTime.UtcNow:O}"); await BroadcastOnlineUsersAsync(); } OnLog?.Invoke($"Client disconnected: {clientId}"); @@ -172,31 +172,33 @@ private async Task ProcessMessageAsync(string rawMessage, string sourceClientId) } else { - await writer.WriteLineAsync($"MSG|{msg.Sender.Username}|{msg.Content}|{msg.Sender.AvatarBase64}|{msg.Timestamp:O}|{reactionsJson}"); + await writer.WriteLineAsync($"MSG|{msg.Id}|{msg.Sender.Username}|{msg.Content}|{msg.Sender.AvatarBase64}|{msg.Timestamp:O}|{reactionsJson}"); } } } - await BroadcastAsync($"MSG|System|{username} has joined the chat.||{DateTime.UtcNow:O}", sourceClientId); + await BroadcastAsync($"MSG|{Guid.NewGuid()}|System|{username} has joined the chat.||{DateTime.UtcNow:O}", sourceClientId); OnLog?.Invoke($"[JOIN] {username}"); _onlineUsers[sourceClientId] = username; await BroadcastOnlineUsersAsync(); } - else if (type == "MSG") // MSG|UserId|Content + else if (type == "MSG") // MSG|UserId|ClientMessageId|Content { + if (parts.Length < 4) return; string userId = parts[1]; - string content = string.Join("|", parts, 2, parts.Length - 2); + string clientMessageId = parts[2]; + string content = string.Join("|", parts, 3, parts.Length - 3); var user = await db.Users.FindAsync(userId); if (user != null) { - var msg = new ChatMessage { SenderId = userId, Content = content, IsFile = false, Timestamp = DateTime.UtcNow }; + var msg = new ChatMessage { Id = clientMessageId, SenderId = userId, Content = content, IsFile = false, Timestamp = DateTime.UtcNow }; db.ChatMessages.Add(msg); await db.SaveChangesAsync(); // Thêm sourceClientId để không dội ngược tin nhắn về người gửi - await BroadcastAsync($"MSG|{user.Username}|{content}|{user.AvatarBase64}|{msg.Timestamp:O}", sourceClientId); + await BroadcastAsync($"MSG|{msg.Id}|{user.Username}|{content}|{user.AvatarBase64}|{msg.Timestamp:O}", sourceClientId); OnLog?.Invoke($"[MSG] {user.Username}: {content}"); } } @@ -211,12 +213,12 @@ private async Task ProcessMessageAsync(string rawMessage, string sourceClientId) var user = await db.Users.FindAsync(userId); if (user != null) { - var msg = new ChatMessage { SenderId = userId, Content = fileName, IsFile = true, FileId = fileId, FileSize = size, Timestamp = DateTime.UtcNow }; + var msg = new ChatMessage { Id = fileId, SenderId = userId, Content = fileName, IsFile = true, FileId = fileId, FileSize = size, Timestamp = DateTime.UtcNow }; db.ChatMessages.Add(msg); await db.SaveChangesAsync(); // Thêm sourceClientId - await BroadcastAsync($"FILE_READY|{fileId}|{fileName}|{size}|{user.Username}|{user.AvatarBase64}|{msg.Timestamp:O}", sourceClientId); + await BroadcastAsync($"FILE_READY|{fileId}|{fileName}|{size}|{user.Username}|{user.AvatarBase64}|{msg.Timestamp:O}|[]", sourceClientId); OnLog?.Invoke($"[FILE] {user.Username}: {fileName}"); } } From 4504d74b834b6e756e0481a1daf6d582a4273b92 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Thu, 21 May 2026 01:35:11 +0700 Subject: [PATCH 18/26] docs(plans): add implementation plans for file upload draft, staging panel reposition, and max draft image limit --- .../phase-01-file-upload-draft.md | 113 ++++++++++++++++++ .../phase-02-loading-animation.md | 84 +++++++++++++ .../phase-03-remove-button.md | 77 ++++++++++++ .../phase-04-testing.md | 66 ++++++++++ .../plan.md | 37 ++++++ .../plan.md | 97 +++++++++++++++ .../plan.md | 42 +++++++ 7 files changed, 516 insertions(+) create mode 100644 plans/260520-1652-image-file-draft-upload/phase-01-file-upload-draft.md create mode 100644 plans/260520-1652-image-file-draft-upload/phase-02-loading-animation.md create mode 100644 plans/260520-1652-image-file-draft-upload/phase-03-remove-button.md create mode 100644 plans/260520-1652-image-file-draft-upload/phase-04-testing.md create mode 100644 plans/260520-1652-image-file-draft-upload/plan.md create mode 100644 plans/260520-2300-staging-panel-reposition/plan.md create mode 100644 plans/260520-2315-reduce-max-draft-images/plan.md diff --git a/plans/260520-1652-image-file-draft-upload/phase-01-file-upload-draft.md b/plans/260520-1652-image-file-draft-upload/phase-01-file-upload-draft.md new file mode 100644 index 0000000..771df14 --- /dev/null +++ b/plans/260520-1652-image-file-draft-upload/phase-01-file-upload-draft.md @@ -0,0 +1,113 @@ +--- +title: "Phase 1: File Upload Draft Implementation" +description: "Modify upload code path to create draft items first instead of uploading immediately" +status: completed +priority: P1 +created: 2026-05-20 +--- + +# Phase 1: File Upload Draft Implementation + +## Context Links +- Parent: [plan.md](../plan.md) +- Scout report: `scout/scout-01.md` (synthesized) + +## Overview + +**Priority:** P1 | **Status:** Pending + +Modify the upload flow so files/images staged via + button, drag-drop, or paste create draft items first. User presses Enter to actually upload. This mirrors the existing paste draft behavior but for all upload methods. + +## Key Insights + +- `UploadFileAsync()` (lines 750-794) uploads immediately +- `HandleImagePaste()` (lines 1369-1425) correctly creates draft with `IsDraft=true` +- Need to create a `CreateDraftFileMessage()` method that creates the message in draft state (like paste) +- Both `BtnUpload_Click` and `Window_Drop` should call this draft method instead of `UploadFileAsync` +- `SendPendingImages()` (lines 1427-1454) handles the actual upload when Enter is pressed + +## Requirements + +### Functional +- When user clicks + button or drags file → create draft item in chat (purple dashed border) +- When user presses Enter → `SendPendingImages()` uploads all drafts +- Max 10 draft items enforced on all paths + +### Non-functional +- Same UX for + button, drag-drop, and paste +- No change to existing server protocol + +## Architecture + +**Current flow (broken):** +``` ++ Button / Drag-Drop → UploadFileAsync() → IsTransferring=true → upload starts immediately +``` + +**New flow (target):** +``` ++ Button / Drag-Drop → CreateDraftFileMessage() → IsDraft=true → appears in chat +Enter pressed → SendPendingImages() → IsDraft=false → IsTransferring=true → upload starts +``` + +## Related Code Files + +**Modify:** +- `ChatBox.Client/MainWindow.xaml.cs` + - `BtnUpload_Click` (796-803): Call `CreateDraftFileMessage()` instead of `UploadFileAsync()` + - `Window_Drop` (818-834): Call `CreateDraftFileMessage()` instead of `UploadFileAsync()` + - Add `CreateDraftFileMessage()` method (new) + - `SendPendingImages()` (1427-1454): Keep as-is - handles draft→upload transition + +## Implementation Steps + +1. **Add `CreateDraftFileMessage()` method** to `MainWindow.xaml.cs`: + - Takes `filePath` string parameter + - Checks `_pendingImages.Count >= 10` + - Creates `ChatMessage` with `IsDraft=true, IsTransferring=false` + - Adds to `_pendingImages` and `_allMessages` + - Calls `RefreshMessageList()` + - Returns the created `ChatMessage` or null if at limit + +2. **Modify `BtnUpload_Click`**: + - Remove `await UploadFileAsync(dialog.FileName)` + - Call `CreateDraftFileMessage(dialog.FileName)` instead + +3. **Modify `Window_Drop`**: + - Remove `await UploadFileAsync(file)` call + - Call `CreateDraftFileMessage(file)` instead + - Keep the async/await structure but change method called + +4. **Verify `HandleImagePaste()` already does the right thing** - it creates draft, nothing to change + +## Todo List + +- [ ] Add `CreateDraftFileMessage()` method +- [ ] Update `BtnUpload_Click` to use draft method +- [ ] Update `Window_Drop` to use draft method +- [ ] Test: + button creates draft with purple border +- [ ] Test: drag-drop creates draft with purple border +- [ ] Test: Enter sends all drafts + +## Success Criteria + +1. Files dropped or selected via + button appear in chat with purple dashed border (IsDraft=true) before Enter +2. Pressing Enter uploads all pending drafts and shows ProgressBar loading animation +3. Cannot stage more than 10 images (existing check works for all paths) +4. Existing paste (Ctrl+V) behavior unchanged + +## Risk Assessment + +- **Risk:** `_pendingImages.Clear()` in `SendPendingImages()` might cause issues if multiple upload paths add to it +- **Mitigation:** `_pendingImages` list is the correct staging area, clear is intentional before re-uploading +- **Risk:** `_currentTransferMessage` tracking in `UploadFileAsync` not used in draft path +- **Mitigation:** Draft path doesn't need `_currentTransferMessage` since upload happens in `SendPendingImages()` + +## Security Considerations + +- File validation already exists (checks `File.Exists`) +- No new security concerns - same file handling as before + +## Next Steps + +- Phase 2: Verify ProgressBar animation fires correctly during actual upload \ No newline at end of file diff --git a/plans/260520-1652-image-file-draft-upload/phase-02-loading-animation.md b/plans/260520-1652-image-file-draft-upload/phase-02-loading-animation.md new file mode 100644 index 0000000..29b655e --- /dev/null +++ b/plans/260520-1652-image-file-draft-upload/phase-02-loading-animation.md @@ -0,0 +1,84 @@ +--- +title: "Phase 2: Loading Animation Verification" +description: "Verify ProgressBar shows during upload - fix if needed" +status: completed +priority: P1 +created: 2026-05-20 +--- + +# Phase 2: Loading Animation Verification + +## Context Links +- Parent: [plan.md](../plan.md) +- Phase 1: [phase-01-file-upload-draft.md](./phase-01-file-upload-draft.md) + +## Overview + +**Priority:** P1 | **Status:** Pending + +Verify the ProgressBar loading animation shows correctly during file upload. The ProgressBar bindings exist in XAML but we need to confirm they update during `SendPendingImages()`. + +## Key Insights + +- `ChatMessage.TransferProgress` property exists (lines 29-34 in ChatMessage.cs) +- `ChatMessage.IsTransferring` property exists (lines 36-41) +- XAML ProgressBar bindings at lines 1061, 1078, 1217, 1293 bind to these properties +- `FileClient.UploadFileAsync()` fires `OnUploadProgress` event with percentage +- `UpdateProgress()` handler at lines 872-880 updates UI thread + +## Architecture + +**Current flow in SendPendingImages:** +```csharp +msg.IsDraft = false; +msg.IsTransferring = true; // Should show ProgressBar +RefreshMessageList(); + +await _fileClient.UploadFileAsync(...); // Progress events fire + +msg.IsTransferring = false; // Should hide ProgressBar +``` + +The flow looks correct but we need to verify: +1. `UpdateProgress()` handler is wired to `OnUploadProgress` event +2. `TransferProgress` property setter calls `PropertyChanged` for UI binding + +## Related Code Files + +**Verify/Modify:** +- `LocalChat.Core/Services/FileTransferService.cs` - lines 93-135 (UploadFileAsync) +- `ChatBox.Client/ViewModels/ChatMessage.cs` - TransferProgress, IsTransferring properties +- `ChatBox.Client/MainWindow.xaml.cs` - UpdateProgress handler, event subscription + +## Implementation Steps + +1. **Verify event subscription**: Check if `OnUploadProgress` event is connected to `UpdateProgress` handler in `MainWindow.xaml.cs` + - Look for `_fileClient.OnUploadProgress += UpdateProgress` or similar + +2. **Verify PropertyChanged**: Check `ChatMessage.cs` that `TransferProgress` setter calls `OnPropertyChanged(nameof(TransferProgress))` + +3. **If not wired**: Add event subscription in `MainWindow.xaml.cs` initialization + +4. **Test**: Upload a file and verify ProgressBar animates from 0-100% + +## Todo List + +- [ ] Check event wiring for upload progress +- [ ] Check TransferProgress PropertyChanged +- [ ] Fix any broken wiring +- [ ] Test ProgressBar animation during upload + +## Success Criteria + +1. When upload starts, ProgressBar appears and animates 0→100% +2. ProgressBar disappears when upload completes +3. Animation is smooth (updates at least every few percent) + +## Risk Assessment + +- **Risk:** If event wiring is missing, progress won't update +- **Mitigation:** Simple wiring fix if needed + +## Next Steps + +- Phase 3: Add remove (X) button to draft items \ No newline at end of file diff --git a/plans/260520-1652-image-file-draft-upload/phase-03-remove-button.md b/plans/260520-1652-image-file-draft-upload/phase-03-remove-button.md new file mode 100644 index 0000000..d0c2d33 --- /dev/null +++ b/plans/260520-1652-image-file-draft-upload/phase-03-remove-button.md @@ -0,0 +1,77 @@ +--- +title: "Phase 3: Remove Button for Draft Items" +description: "Add X button to draft images/files so user can remove before sending" +status: completed +priority: P1 +created: 2026-05-20 +--- + +# Phase 3: Remove Button for Draft Items + +## Context Links +- Parent: [plan.md](../plan.md) + +## Overview + +**Priority:** P1 | **Status:** Pending + +Add a remove (X) button to draft images/files so users can remove them before pressing Enter to send. + +## Key Insights + +- `BtnRemovePending_Click` handler exists at lines 1561-1569 in `MainWindow.xaml.cs` +- No XAML button is currently bound to this handler (grep returned no matches) +- Need to add an X button overlay on draft image/file items + +## Architecture + +**Current state:** +- Draft images show with purple dashed border (via DataTrigger at lines 1137-1142) +- No visible way to remove them before sending + +**Target state:** +- X button appears in top-right corner of draft items +- Clicking removes from `_pendingImages` and `_allMessages` + +## Related Code Files + +**Modify:** +- `ChatBox.Client/MainWindow.xaml` - Add remove button to draft image template +- `ChatBox.Client/MainWindow.xaml.cs` - `BtnRemovePending_Click` already exists + +## Implementation Steps + +1. **Add remove Button to ImageEmbedBorder** template: + - Position absolute in top-right corner + - Only visible when `IsDraft=True` + - Style: small circular button with X or trash icon + - Bind `Click="BtnRemovePending_Click"` + - Bind `Tag="{Binding}"` to pass the message to handler + +2. **Add remove Button to File attachment template**: + - Similar approach - X button in top-right of file card + - Only visible when `IsDraft=True` + +3. **Test**: Draft items show X button, clicking removes from chat + +## Todo List + +- [ ] Add X button to draft image template (ImageEmbedBorder) +- [ ] Add X button to draft file template (FileAttachmentBorder) +- [ ] Test remove button on draft images +- [ ] Test remove button on draft files + +## Success Criteria + +1. Draft images show X button in top-right corner +2. Clicking X removes image from draft list +3. X button only visible for draft items (IsDraft=True) +4. Works for both images and file attachments + +## Risk Assessment + +- None identified - straightforward button binding + +## Next Steps + +- Phase 4: Testing & verification \ No newline at end of file diff --git a/plans/260520-1652-image-file-draft-upload/phase-04-testing.md b/plans/260520-1652-image-file-draft-upload/phase-04-testing.md new file mode 100644 index 0000000..eaa5cea --- /dev/null +++ b/plans/260520-1652-image-file-draft-upload/phase-04-testing.md @@ -0,0 +1,66 @@ +--- +title: "Phase 4: Testing & Verification" +description: "Test all draft upload flows and loading animation" +status: completed +priority: P1 +created: 2026-05-20 +--- + +# Phase 4: Testing & Verification + +## Context Links +- Parent: [plan.md](../plan.md) +- Phase 1-3 complete before this phase + +## Overview + +**Priority:** P1 | **Status:** Pending + +Comprehensive testing of all draft upload flows. + +## Implementation Steps + +### Test Cases + +1. **Ctrl+V Paste Image** + - [ ] Image appears in chat with purple dashed border + - [ ] X button removes image + - [ ] Enter uploads and shows ProgressBar + +2. **+ Button Upload (File dialog)** + - [ ] File appears in chat with purple dashed border (before Enter) + - [ ] X button removes file + - [ ] Enter uploads and shows ProgressBar + +3. **Drag-Drop Upload** + - [ ] File appears in chat with purple dashed border (before Enter) + - [ ] X button removes file + - [ ] Enter uploads and shows ProgressBar + +4. **Multiple Files** + - [ ] Up to 10 files can be staged + - [ ] 11th file shows "Maximum 10 images" message + +5. **Loading Animation** + - [ ] ProgressBar shows during upload (0-100%) + - [ ] ProgressBar hides when upload completes + +6. **Mixed Content** + - [ ] Text + images sent together on Enter + - [ ] Each file gets its own draft item + +## Todo List + +- [ ] Test Ctrl+V paste +- [ ] Test + button upload +- [ ] Test drag-drop +- [ ] Test 10 file limit +- [ ] Test ProgressBar animation +- [ ] Test mixed text + images + +## Success Criteria + +All test cases pass. User can: +- Stage up to 10 images/files with visual feedback +- Remove any staged item before sending +- Press Enter to upload all with loading animation \ No newline at end of file diff --git a/plans/260520-1652-image-file-draft-upload/plan.md b/plans/260520-1652-image-file-draft-upload/plan.md new file mode 100644 index 0000000..527a674 --- /dev/null +++ b/plans/260520-1652-image-file-draft-upload/plan.md @@ -0,0 +1,37 @@ +--- +title: "Image/File Draft Upload with Loading Animation" +description: "When uploading or Ctrl+V, images/files should draft in chat box for review before sending (Enter). Add loading animation during upload. Support up to 10 images." +status: done +priority: P1 +effort: 6h +branch: feature/emoji-reactions +tags: [image-upload, draft, loading-animation, wpf] +created: 2026-05-20 +--- + +# Feature Implementation Plan + +## Overview + +Implement draft behavior for image/file uploads: staged in chat with purple dashed border, removable before Enter. Add loading animation during upload. Max 10 images. + +## Phases + +| # | Phase | Status | Effort | Link | +|---|-------|--------|--------|------| +| 1 | Fix file upload to draft before sending | Pending | 2h | [phase-01](./phase-01-file-upload-draft.md) | +| 2 | Add loading animation during upload | Pending | 2h | [phase-02](./phase-02-loading-animation.md) | +| 3 | Wire up remove button on draft items | Pending | 1h | [phase-03](./phase-03-remove-button.md) | +| 4 | Testing & verification | Pending | 1h | [phase-04](./phase-04-testing.md) | + +## Dependencies + +- Existing: `_pendingImages` list, `IsDraft` flag, `IsTransferring` flag, `TransferProgress` property +- No new dependencies needed + +## Key Problems Identified + +1. **Upload via + button and drag-drop** directly calls `UploadFileAsync()` which starts upload immediately without drafting +2. **Paste (Ctrl+V)** correctly creates draft with `IsDraft=true` but `BtnRemovePending_Click` has no XAML binding +3. **Loading animation** exists (ProgressBar bound to `IsTransferring`/`TransferProgress`) but not triggered because upload starts immediately +4. **Remove button** `BtnRemovePending_Click` exists in code-behind but no XAML button binding found \ No newline at end of file diff --git a/plans/260520-2300-staging-panel-reposition/plan.md b/plans/260520-2300-staging-panel-reposition/plan.md new file mode 100644 index 0000000..0bec136 --- /dev/null +++ b/plans/260520-2300-staging-panel-reposition/plan.md @@ -0,0 +1,97 @@ +# Plan: Reposition Draft Staging Panel Above Input Box + +## Summary +Move the draft staging panel from its current floating/scattered position to a dedicated slot **right above the message input box**, as a persistent horizontal strip. This makes staged images/files visible and organized, directly adjacent to where the user types. + +## Context +- **Parent:** `260520-1652-image-file-draft-upload/plan.md` (image/file draft upload feature) +- **Issue:** After the recent fix (removing inline chat insertion), pasted/dragged images only go to `DraftStagingPanel`. But the panel is not clearly positioned near the input area - user wants it "near chat, not random panel." + +## Current Layout (Grid.Row assignments in pnlChat) + +| Row | Height | Content | +|-----|--------|---------| +| 0 | 50 | Chat Header | +| 1 | * | Message List / Galleries | +| 2 | Auto | [EMPTY - was staging panel] | +| 3 | Auto | Draft Staging Panel (currently scattered) | +| 4 | Auto | Input Area | + +**Problem:** `Grid.Row="3"` for staging panel - no explicit positioning makes it appear "random." + +## Target Layout + +| Row | Height | Content | +|-----|--------|---------| +| 0 | 50 | Chat Header | +| 1 | * | Message List / Galleries | +| 2 | Auto | **Draft Staging Panel** (repositioned here, always visible when items exist) | +| 3 | Auto | Input Area (tight margin above) | + +**Key change:** Swap staging panel to `Grid.Row="2"`, adjust margins so it sits directly above input area. + +## Requirements +1. Staging panel appears **immediately above the input box** when items are staged +2. Panel is a **horizontal strip** of thumbnails (same 256x256 size) +3. Small, unobtrusive - doesn't block the chat messages +4. When collapsed (no pending images), takes **zero vertical space** +5. When expanded, pushes input box down naturally + +## Architecture + +### Current Row Definitions: +```xml + + + + + + + +``` + +### Target Row Definitions: +```xml + + + + + + +``` + +**Remove one empty row (Row 2 currently empty).** + +### XAML Changes: +1. Move `DraftStagingPanel` from `Grid.Row="3"` to `Grid.Row="2"` +2. Remove the empty `RowDefinition Height="Auto"` at row 2 +3. Adjust margins: panel's top margin from ~10 to something like 8-10px +4. The input area `Grid.Row="4"` becomes `Grid.Row="3"` +5. Update `UpdateDraftPanelVisibility()` to properly show/hide with correct row behavior + +## Implementation Steps + +### Step 1: Edit MainWindow.xaml +- Remove the empty `RowDefinition` between messages and staging panel +- Move `DraftStagingPanel` to `Grid.Row="2"` (was row 3) +- Shift input area `Grid.Row` from 4 to 3 +- Adjust panel margins to be tight against input area + +### Step 2: Verify Code-behind +- `UpdateDraftPanelVisibility()` uses `Visibility.Visible/Collapsed` - already correct +- No code changes needed in `.cs` for positioning + +## Success Criteria +1. Draft staging panel appears as a **horizontal strip directly above** the message input box +2. Panel is positioned at `Grid.Row="2"` in the chat panel's grid +3. When no pending images: panel hidden, input box at normal position +4. When pending images exist: panel visible, input box pushed down +5. Thumbnail size remains 256x256px + +## Risk Assessment +- **Low risk** - pure XAML layout adjustment +- No logic changes, no interaction with upload/send flow +- Should not affect any existing functionality + +## Unresolved +None - layout change is straightforward. \ No newline at end of file diff --git a/plans/260520-2315-reduce-max-draft-images/plan.md b/plans/260520-2315-reduce-max-draft-images/plan.md new file mode 100644 index 0000000..2b6ea28 --- /dev/null +++ b/plans/260520-2315-reduce-max-draft-images/plan.md @@ -0,0 +1,42 @@ +# Plan: Reduce Max Draft Images & Thumbnail Size + +## Summary +1. Change max staged images from **10 → 5** +2. Reduce staging panel thumbnail size from **256x256 → 128x128** so multiple fit horizontally + +## Context +- User: staging panel shows only one image, needs to show more +- Solution: shrink thumbnails from 256px to 128px so more fit side-by-side +- Also reducing max limit to 5 per user request + +## Context +- User wants to limit staged images to max 5 (currently 10) +- Simple one-line change in two places + +## Thumbnail Size Change (MainWindow.xaml) + +Lines ~1368-1370: +```xml + + + + + + + +``` + +## Changes Summary + +| File | Line | Change | +|------|------|--------| +| `MainWindow.xaml.cs` | ~1374 | `Count >= 10` → `Count >= 5` | +| `MainWindow.xaml.cs` | ~1431 | `Count >= 10` → `Count >= 5` | +| `MainWindow.xaml` | ~1368 | `Width="256" Height="256"` → `128x128` | +| `MainWindow.xaml` | ~1370 | `Width="256" Height="256"` → `128x128` | + +## Success Criteria +1. Max 5 images staged at once +2. Thumbnails 128x128px (smaller, more fit horizontally) +3. Scroll appears if more than can fit in panel width +4. Build succeeds \ No newline at end of file From 61c4ee380cba123ffefe9b976ea8d0db5c54ba70 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Thu, 21 May 2026 01:36:17 +0700 Subject: [PATCH 19/26] docs: add bugfix plan for ChatBox UI bugs (reaction, wrap, scroll, lag, image size) --- docs/bugfix-plan-260521.md | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/bugfix-plan-260521.md diff --git a/docs/bugfix-plan-260521.md b/docs/bugfix-plan-260521.md new file mode 100644 index 0000000..e0a6e59 --- /dev/null +++ b/docs/bugfix-plan-260521.md @@ -0,0 +1,85 @@ +# Bug Fix Plan — ChatBox UI — 2025-05-21 + +> **Scope:** `ChatBox.Client/MainWindow.xaml` · `ChatBox.Client/MainWindow.xaml.cs` +> **Branch:** `feature/emoji-reactions` +> **Strategy:** Fix per-bug → commit ngay sau mỗi fix + +--- + +## Bug List & Root Causes + +### Bug #1 — Hover Reaction Bar không hoạt động +**Triệu chứng:** Hover vào tin nhắn không có reaction bar, hoặc click emoji reaction không có hiệu ứng. +**Root cause:** `QuickReactionBar` nằm ngoài scope `DataTemplate` → `btn.DataContext is ChatMessage msg` luôn `false` trong `ReactionButton_Click`. +**Fix:** Bind `Tag="{Binding}"` vào mỗi `Button` trong `QuickReactionBar`; đọc `ChatMessage` từ `btn.Tag` thay vì `btn.DataContext`. + +--- + +### Bug #2 — Tin nhắn không xuống dòng (text wrap) +**Triệu chứng:** Gõ tin nhắn dài, text chạy ngang không xuống dòng trong bubble. +**Root cause:** `emoji:RichTextBox` không có `MaxHeight` giới hạn, và hàm lấy text đang dùng `txtInput.Text` không đúng với `RichTextBox`. +**Fix:** Đặt `AcceptsReturn="False"` và sửa hàm lấy text dùng `TextRange(Document.ContentStart, Document.ContentEnd).Text`. + +--- + +### Bug #3 — Cục xám kỳ cục gần scrollbar +**Triệu chứng:** Xuất hiện một khối màu xám lạ ở vùng scroll của chat. +**Root cause:** `ScrollBar` global style render "thumb" kể cả khi không có nội dung cần scroll; `CanContentScroll="False"` kết hợp với style tùy chỉnh gây artifact. +**Fix:** Bật `VirtualizingStackPanel` cho `lstChatMessages` và đặt `ScrollViewer.CanContentScroll="True"`. + +--- + +### Bug #4 — Ảnh draft size bất thường khi gửi nhiều ảnh +**Triệu chứng:** Gửi 5 ảnh cùng lúc → preview trong chat to/nhỏ bất thường, méo. +**Root cause:** `DraftStagingItems` template dùng `Stretch="Uniform"` cộng với `Width/Height` cố định `128×128` nhưng ảnh nhỏ bị kéo giãn. +**Fix:** Bọc `Image` trong `Border` với `ClipToBounds="True"` và dùng `Stretch="UniformToFill"`. + +--- + +### Bug #5 — Placeholder cần 2 ký tự mới ẩn +**Triệu chứng:** Gõ 1 ký tự vào input → placeholder chưa ẩn; gõ ký tự thứ 2 mới ẩn. +**Root cause:** `emoji:RichTextBox.Text` trả về `\r\n` (nội dung trống của `FlowDocument`) ngay cả khi chưa gõ, nên lần đầu gõ text thực tế vẫn match `empty`. +**Fix:** Kiểm tra `TextRange(Document.ContentStart, Document.ContentEnd).Text.Trim()` thay vì `txtInput.Text`. + +--- + +### Bug #6 — AllChat không hiện đủ, cần lazy load +**Triệu chứng:** Chuyển channel hoặc kết nối → chỉ thấy một phần tin nhắn, scroll không tải thêm. +**Root cause:** `RefreshMessageList()` gọi `Items.Clear()` rồi add lại toàn bộ — không có virtualization. +**Fix:** Bật `VirtualizingStackPanel.IsVirtualizing="True"` + `VirtualizationMode="Recycling"` trên `lstChatMessages`; bind `ItemsSource` một lần thay vì clear/add. + +--- + +### Bug #7 — Chat lag nặng khi gửi ảnh/file +**Triệu chứng:** Gửi 1 tin/ảnh → UI đơ vài giây. +**Root causes:** +1. `RefreshMessageList()` clear+add toàn bộ `_allMessages` trên UI thread mỗi lần có tin mới. +2. `SendPendingImagesAsync()` await tuần tự từng ảnh (`foreach` + `await`). +3. `Window_Drop` await tuần tự từng file. + +**Fix:** +- Đổi `_allMessages` → `ObservableCollection`, bind `ItemsSource` 1 lần → WPF tự update incremental. +- `SendPendingImagesAsync()` → `Task.WhenAll(toSend.Select(msg => UploadOneAsync(msg)))`. +- `Window_Drop` → `await Task.WhenAll(files.Select(f => UploadFileAsync(f)))`. + +--- + +## Thứ tự thực thi & Commits + +| Bước | Bug | Commit message | +|------|-----|----------------| +| 1 | #7 - Performance (ObservableCollection) | `perf(ui): replace List with ObservableCollection for message binding` | +| 2 | #7 - Parallel upload | `perf(ui): parallelize pending image and file drop uploads` | +| 3 | #5 - Placeholder | `fix(ui): use TextRange to detect empty RichTextBox for placeholder` | +| 4 | #2 - Text wrap | `fix(ui): fix text wrap in chat by reading RichTextBox content correctly` | +| 5 | #1 - Reaction bar | `fix(ui): fix reaction button DataContext by using Tag binding` | +| 6 | #3 - Scrollbar artifact | `fix(ui): enable VirtualizingStackPanel to remove scrollbar artifact` | +| 7 | #4 - Image size | `fix(ui): fix draft image preview stretch in staging panel` | +| 8 | #6 - Lazy load | `perf(ui): enable ListBox virtualization for lazy message rendering` | + +--- + +## Files ảnh hưởng + +- `ChatBox.Client/MainWindow.xaml` — Bug #1, #3, #4, #6 +- `ChatBox.Client/MainWindow.xaml.cs` — Bug #1, #2, #5, #7 From 55e54d0eb9e58458660fb5da8b32cbf33c1136e7 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Thu, 21 May 2026 01:37:57 +0700 Subject: [PATCH 20/26] perf(ui): replace List with ObservableCollection and parallelize image/file uploads --- ChatBox.Client/MainWindow.xaml.cs | 59 +++++++++++++++++-------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/ChatBox.Client/MainWindow.xaml.cs b/ChatBox.Client/MainWindow.xaml.cs index 5261262..181c68f 100644 --- a/ChatBox.Client/MainWindow.xaml.cs +++ b/ChatBox.Client/MainWindow.xaml.cs @@ -118,7 +118,8 @@ public partial class MainWindow : Window private ChatMessage? _currentTransferMessage; private ObservableCollection _pendingImages = new ObservableCollection(); - private System.Collections.Generic.List _allMessages = new System.Collections.Generic.List(); + private ObservableCollection _allMessages = new ObservableCollection(); + private System.Collections.ObjectModel.ObservableCollection _chatViewMessages = new ObservableCollection(); private string _currentChannel = "chat"; public MainWindow() @@ -131,6 +132,9 @@ public MainWindow() DraftStagingItems.ItemsSource = _pendingImages; UpdateDraftPanelVisibility(); + // Bind chat list once — ObservableCollection handles incremental UI updates + lstChatMessages.ItemsSource = _chatViewMessages; + _chatClient.OnMessageReceived += HandleIncomingMessage; _fileClient.OnUploadProgress += UpdateProgress; _fileClient.OnDownloadProgress += UpdateProgress; @@ -291,7 +295,8 @@ private async void BtnConnect_Click(object sender, RoutedEventArgs e) try { - lstChatMessages.Items.Clear(); + _allMessages.Clear(); + _chatViewMessages.Clear(); if (_cts.IsCancellationRequested) _cts = new CancellationTokenSource(); await _chatClient.ConnectAsync(_serverIp, _cts.Token); @@ -363,7 +368,7 @@ private void BtnDisconnect_Click(object sender, RoutedEventArgs e) pnlChat.IsEnabled = false; _allMessages.Clear(); - lstChatMessages.Items.Clear(); + _chatViewMessages.Clear(); lstOnlineUsers.ItemsSource = null; lblStatus.Text = "Disconnected"; @@ -426,7 +431,7 @@ private void HandleIncomingMessage(string rawMessage) if (IsMessageInCurrentChannel(chatMsg)) { - lstChatMessages.Items.Add(chatMsg); + _chatViewMessages.Add(chatMsg); lstChatMessages.ScrollIntoView(chatMsg); } } @@ -476,7 +481,7 @@ private void HandleIncomingMessage(string rawMessage) } else { - lstChatMessages.Items.Add(fileMsg); + _chatViewMessages.Add(fileMsg); lstChatMessages.ScrollIntoView(fileMsg); } } @@ -484,7 +489,7 @@ private void HandleIncomingMessage(string rawMessage) else if (type == "CLEAR_CHAT") { _allMessages.Clear(); - lstChatMessages.Items.Clear(); + _chatViewMessages.Clear(); } else if (type == "ROOM_NAME") { @@ -737,7 +742,11 @@ private async void BtnSendChat_Click(object sender, RoutedEventArgs e) var newMsg = new ChatMessage { MessageId = Guid.NewGuid().ToString(), Sender = string.IsNullOrWhiteSpace(_displayName) ? "User" : _displayName, Content = text, AvatarBase64 = _avatarBase64, IsMe = true, Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")) }; _allMessages.Add(newMsg); - RefreshMessageList(); + if (IsMessageInCurrentChannel(newMsg)) + { + _chatViewMessages.Add(newMsg); + lstChatMessages.ScrollIntoView(newMsg); + } try { @@ -777,7 +786,8 @@ private async Task UploadFileAsync(string filePath) IsInImageChannel = (_currentChannel == "images") }; _allMessages.Add(msg); - RefreshMessageList(); + if (IsMessageInCurrentChannel(msg)) + _chatViewMessages.Add(msg); _currentTransferMessage = msg; await _fileClient.UploadFileAsync(_serverIp, filePath, fileId); @@ -787,7 +797,6 @@ private async Task UploadFileAsync(string filePath) // Báo cho Server lưu db và broadcast await _chatClient.SendMessageAsync($"FILE_READY|{_userId}|{fileId}|{fileInfo.Name}|{fileInfo.Length}"); - RefreshMessageList(); } catch (Exception ex) { @@ -826,13 +835,8 @@ private async void Window_Drop(object sender, DragEventArgs e) string[] files = (string[])e.Data.GetData(DataFormats.FileDrop); if (files != null && files.Length > 0) { - foreach (var file in files) - { - if (File.Exists(file)) - { - await UploadFileAsync(file); - } - } + // Upload all dropped files in parallel + await Task.WhenAll(files.Where(File.Exists).Select(f => UploadFileAsync(f))); } } } @@ -1021,18 +1025,14 @@ private void UpdateDraftPanelVisibility() private void RefreshMessageList() { - lstChatMessages.Items.Clear(); + _chatViewMessages.Clear(); foreach (var msg in _allMessages) { if (IsMessageInCurrentChannel(msg)) - { - lstChatMessages.Items.Add(msg); - } - } - if (lstChatMessages.Items.Count > 0) - { - lstChatMessages.ScrollIntoView(lstChatMessages.Items[lstChatMessages.Items.Count - 1]); + _chatViewMessages.Add(msg); } + if (_chatViewMessages.Count > 0) + lstChatMessages.ScrollIntoView(_chatViewMessages[_chatViewMessages.Count - 1]); } private bool IsMessageInCurrentChannel(ChatMessage msg) @@ -1445,12 +1445,19 @@ private async Task SendPendingImagesAsync() _pendingImages.Clear(); UpdateDraftPanelVisibility(); + // Mark all as in-flight and add to view immediately (Optimistic UI) foreach (var msg in toSend) { msg.IsDraft = false; msg.IsTransferring = true; _allMessages.Add(msg); + if (IsMessageInCurrentChannel(msg)) + _chatViewMessages.Add(msg); + } + // Upload all in parallel — no more sequential blocking + await Task.WhenAll(toSend.Select(async msg => + { try { await _fileClient.UploadFileAsync(_serverIp, msg.LocalFilePath, Guid.Parse(msg.FileId)); @@ -1461,9 +1468,7 @@ private async Task SendPendingImagesAsync() { msg.IsTransferring = false; } - } - - RefreshMessageList(); + })); } finally { From 23876ac389e8a769434505e66ab0fd2c90f4171d Mon Sep 17 00:00:00 2001 From: UGing265 Date: Thu, 21 May 2026 01:38:34 +0700 Subject: [PATCH 21/26] fix(ui): use TextRange to read RichTextBox content for placeholder and send handler --- ChatBox.Client/MainWindow.xaml.cs | 35 ++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/ChatBox.Client/MainWindow.xaml.cs b/ChatBox.Client/MainWindow.xaml.cs index 181c68f..61a8971 100644 --- a/ChatBox.Client/MainWindow.xaml.cs +++ b/ChatBox.Client/MainWindow.xaml.cs @@ -713,12 +713,14 @@ private void AddEmojisToPanel(WrapPanel panel, string[] emojis) btn.Click += (s, e) => { - txtInput.Text += trimmed; + // Append emoji text to RichTextBox properly + var range = new System.Windows.Documents.TextRange( + txtInput.Document.ContentEnd, + txtInput.Document.ContentEnd); + range.Text = trimmed; + txtInput.CaretPosition = txtInput.Document.ContentEnd; if (lblInputPlaceholder != null) - { - string cleanText = (txtInput.Text ?? "").Replace("\r", "").Replace("\n", "").Trim(); - lblInputPlaceholder.Visibility = string.IsNullOrEmpty(cleanText) ? Visibility.Visible : Visibility.Collapsed; - } + lblInputPlaceholder.Visibility = Visibility.Collapsed; }; panel.Children.Add(btn); } @@ -726,7 +728,7 @@ private void AddEmojisToPanel(WrapPanel panel, string[] emojis) private async void BtnSendChat_Click(object sender, RoutedEventArgs e) { - string cleanText = (txtInput.Text ?? "").Replace("\r", "").Replace("\n", "").Trim(); + string cleanText = GetInputText(); // Send pending images first if any if (_pendingImages.Count > 0) @@ -738,7 +740,7 @@ private async void BtnSendChat_Click(object sender, RoutedEventArgs e) if (!string.IsNullOrWhiteSpace(cleanText)) { string text = cleanText; - txtInput.Text = ""; + txtInput.Document.Blocks.Clear(); // Clear RichTextBox properly var newMsg = new ChatMessage { MessageId = Guid.NewGuid().ToString(), Sender = string.IsNullOrWhiteSpace(_displayName) ? "User" : _displayName, Content = text, AvatarBase64 = _avatarBase64, IsMe = true, Timestamp = FormatTimestamp(DateTime.UtcNow.ToString("O")) }; _allMessages.Add(newMsg); @@ -915,11 +917,28 @@ private void TxtInput_TextChanged(object sender, TextChangedEventArgs e) { if (lblInputPlaceholder != null) { - string cleanText = (txtInput.Text ?? "").Replace("\r", "").Replace("\n", "").Trim(); + // RichTextBox.Text includes \r\n even when empty — use TextRange instead + string cleanText = GetInputText(); lblInputPlaceholder.Visibility = string.IsNullOrEmpty(cleanText) ? Visibility.Visible : Visibility.Collapsed; } } + /// Get trimmed plain text from the emoji RichTextBox input. + private string GetInputText() + { + try + { + var range = new System.Windows.Documents.TextRange( + txtInput.Document.ContentStart, + txtInput.Document.ContentEnd); + return range.Text.Replace("\r", "").Replace("\n", "").Trim(); + } + catch + { + return (txtInput.Text ?? "").Replace("\r", "").Replace("\n", "").Trim(); + } + } + private void SelectChannel(string channelName) { _currentChannel = channelName; From 9f7c4661c4b272deed8e121141136e45af7f1d17 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Thu, 21 May 2026 01:38:57 +0700 Subject: [PATCH 22/26] fix(ui): enable VirtualizingStackPanel on message list to fix scrollbar artifact and lazy render --- ChatBox.Client/MainWindow.xaml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/ChatBox.Client/MainWindow.xaml b/ChatBox.Client/MainWindow.xaml index ff4cda0..2818297 100644 --- a/ChatBox.Client/MainWindow.xaml +++ b/ChatBox.Client/MainWindow.xaml @@ -1008,7 +1008,19 @@ - + + + + + + From 8342fe0e8ead1ebb7d8ded86fdd2e1dc8a3b0a10 Mon Sep 17 00:00:00 2001 From: UGing265 Date: Thu, 21 May 2026 01:39:21 +0700 Subject: [PATCH 23/26] fix(ui): fix draft image thumbnail stretch using UniformToFill with ClipToBounds --- ChatBox.Client/MainWindow.xaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ChatBox.Client/MainWindow.xaml b/ChatBox.Client/MainWindow.xaml index 2818297..01de290 100644 --- a/ChatBox.Client/MainWindow.xaml +++ b/ChatBox.Client/MainWindow.xaml @@ -1378,9 +1378,9 @@ - - - + + +