- Add responsive breakpoints (compact <600, medium 600-1023, expanded >=1024) - Add ViewportInfo InheritedWidget for layout-aware descendants - Add inline ChatPanel (380dp right column) for expanded desktop layout - Add channel right-click context menu with Chat option for in-place switching - Add per-target draft persistence via restoredDraft/onDraftChanged callbacks - Fix header chat button to switch to current voice channel when panel open - Fix close = dismiss (preserves last target and draft for reopen) - Add unread dot indicator on channel tiles when chat is closed - Fix audio regression: decimate dBFS computation to every 3rd callback (~31 Hz) to avoid buffer underruns on macOS CoreAudio real-time thread - Add tools/build-macos.sh release build script (7-step process) - Add chat panel switching implementation plan and 3-panel design spec Tests: 183 passed, 2 skipped. Flutter analyze clean.
643 lines
23 KiB
Markdown
643 lines
23 KiB
Markdown
# Chat Panel Switching 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:** Enable in-place conversation switching in the expanded 3-panel layout, add per-conversation draft persistence, and improve unread awareness — matching industry-standard UX patterns from Discord/Slack/Telegram/Element.
|
||
|
||
**Architecture:** The expanded layout (≥1024dp) shows voice controls | channel tree | inline chat panel. Currently the chat panel locks to one conversation with no way to switch from the channel tree. The fix adds channel→chat triggers, in-place target swapping, per-target draft storage, and preserves chat state across switches. The state machine (`_inlineChatTarget` + `_chatOpen`) already supports switching — we just need UI affordances and draft persistence.
|
||
|
||
**Tech Stack:** Flutter/Dart, existing `BridgeMessageTarget` sealed class, existing `ChatDetailView` / `ChatPanel` / `SnapshotView` widgets.
|
||
|
||
**Design research basis:** Discord (in-place swap, dot/badge unread hierarchy), Telegram Desktop (adaptive 3-tier layout, per-conversation drafts + scroll anchoring), Element (per-room panel state, toggleable right panel), Slack (bold sidebar for unread, split view). All apps treat DMs and channels identically for switching behavior.
|
||
|
||
---
|
||
|
||
## Current State
|
||
|
||
| UX Element | Status |
|
||
|---|---|
|
||
| Unread indicator | Single global badge count on app bar chat button |
|
||
| Channel → chat trigger | **None.** Channel tiles only join voice |
|
||
| Client → DM trigger | Works (right-click → "Direct Message") |
|
||
| Header chat button when panel open | Idempotent — re-uses same `_inlineChatTarget` |
|
||
| Draft persistence | None — single `TextEditingController`, lost on switch |
|
||
| Scroll position memory | None — always auto-scrolls to bottom |
|
||
| Per-conversation unread | None |
|
||
|
||
## Scope
|
||
|
||
**In scope (this plan):**
|
||
- Channel → chat switching in expanded layout
|
||
- Channel right-click → "Chat" option
|
||
- Header chat button → switch to current voice channel chat when panel already open
|
||
- Per-target draft persistence (in-memory `Map`)
|
||
- Close = dismiss (remember last target and draft)
|
||
- Unread dot indicator on channels in `SnapshotView`
|
||
|
||
**Out of scope (future):**
|
||
- Scroll position memory per target
|
||
- "New messages" divider
|
||
- Per-target unread counts / badge numbers
|
||
- Notification tiering (dot/badge/mention)
|
||
- Split view (Slack power-user feature)
|
||
|
||
## Responsive Behavior
|
||
|
||
| Tier | Width | Chat Mode | Changes in this plan |
|
||
|---|---|---|---|
|
||
| **Expanded** | ≥1024dp | Inline `ChatPanel` (right column) | ✅ All changes apply here |
|
||
| **Medium** | 600–1023dp | Full-screen `ChatPage` route | No changes needed (already works) |
|
||
| **Compact** | <600dp | Full-screen `ChatPage` route | No changes needed (already works) |
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Action | Responsibility |
|
||
|---|---|---|
|
||
| `apps/chanora_flutter/lib/widgets/snapshot_view.dart` | **Modify** | Add `onOpenChannelChat` callback, channel context menu with "Chat" option, unread dot on channels |
|
||
| `apps/chanora_flutter/lib/main.dart` | **Modify** | Add `_chatDrafts` map, wire `onOpenChannelChat`, fix header chat button to switch to current channel, fix `_closeInlineChat` to preserve last target |
|
||
| `apps/chanora_flutter/lib/widgets/chat_panel.dart` | **Modify** | Accept `onSwitchTarget` callback, pass draft state through |
|
||
| `apps/chanora_flutter/lib/widgets/chat_views.dart` | **Modify** | `ChatDetailView` accepts external draft text, exposes draft text on target change |
|
||
| `apps/chanora_flutter/lib/design/breakpoints.dart` | **No changes** | Breakpoints unchanged |
|
||
|
||
---
|
||
|
||
### Task 1: Add `onOpenChannelChat` callback to `SnapshotView`
|
||
|
||
**Files:**
|
||
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:26-63` (constructor params)
|
||
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart:203-284` (`_channelTile`)
|
||
|
||
- [ ] **Step 1: Add the callback field to `SnapshotView` widget**
|
||
|
||
In `snapshot_view.dart`, add a new optional callback field after `onOpenClientPoke` (around line 71):
|
||
|
||
```dart
|
||
/// Open chat for a channel.
|
||
final ValueChanged<rust.BridgeChannel>? onOpenChannelChat;
|
||
```
|
||
|
||
Update the constructor to include it (around line 32, after `onOpenClientPoke`):
|
||
|
||
```dart
|
||
this.onOpenChannelChat,
|
||
```
|
||
|
||
- [ ] **Step 2: Add a right-click/long-press context menu to `_channelTile`**
|
||
|
||
Replace the `InkWell` in `_channelTile` (lines 243-284) with a context menu wrapper. The channel tile should support:
|
||
- **Tap**: join voice (existing behavior, unchanged)
|
||
- **Right-click / long-press**: show a popup menu with "Open chat" option
|
||
|
||
```dart
|
||
return InkWell(
|
||
onTap: onTap,
|
||
onLongPress: widget.onOpenChannelChat != null
|
||
? () => widget.onOpenChannelChat!(channel)
|
||
: null,
|
||
child: PopupMenuButton<String>(
|
||
position: PopupMenuPosition.under,
|
||
enabled: widget.onOpenChannelChat != null,
|
||
onSelected: (value) {
|
||
if (value == 'chat') {
|
||
widget.onOpenChannelChat?.call(channel);
|
||
}
|
||
},
|
||
itemBuilder: (context) => [
|
||
PopupMenuItem(
|
||
value: 'chat',
|
||
child: Row(
|
||
children: [
|
||
const Icon(Icons.chat_bubble_outline, size: 18),
|
||
const SizedBox(width: 12),
|
||
Text(AppLocalizations.of(context)!.chatAction),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(minHeight: 40),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(width: channelIndent),
|
||
_expandButton(
|
||
theme,
|
||
hasVisibleChildren: hasVisibleChildren,
|
||
expanded: expanded,
|
||
onPressed: onToggleExpanded,
|
||
),
|
||
SizedBox(
|
||
width: _channelIconColumnWidth,
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Icon(
|
||
Icons.tag,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: _channelTextGap),
|
||
Expanded(
|
||
child: Text(
|
||
channel.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
if (channel.hasPassword) ...[
|
||
const SizedBox(width: 8),
|
||
Icon(
|
||
Icons.lock_outline,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
```
|
||
|
||
Note: The `PopupMenuButton` wraps the existing content as its `child`, so the tile looks identical until right-clicked. The `onTap` on `InkWell` continues to handle voice join.
|
||
|
||
- [ ] **Step 3: Run `flutter analyze`**
|
||
|
||
Run: `cd apps/chanora_flutter && flutter analyze`
|
||
Expected: No new errors (the callback is optional, so existing call sites compile without changes)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart
|
||
git commit -m "feat(chat): add onOpenChannelChat callback with context menu to channel tiles"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Wire `onOpenChannelChat` in `main.dart` and add per-target draft storage
|
||
|
||
**Files:**
|
||
- Modify: `apps/chanora_flutter/lib/main.dart:370-374` (state fields)
|
||
- Modify: `apps/chanora_flutter/lib/main.dart:2521-2540` (SnapshotView constructor)
|
||
|
||
- [ ] **Step 1: Add draft storage map**
|
||
|
||
Add a new state field near line 374 (after `_inlineChatCollapseNoticeShown`):
|
||
|
||
```dart
|
||
/// Per-target draft text. Populated when switching away from a conversation
|
||
/// so the user's unfinished message is preserved.
|
||
final Map<String, String> _chatDrafts = {};
|
||
```
|
||
|
||
- [ ] **Step 2: Add `_lastDismissedTarget` field**
|
||
|
||
Add a new state field to remember the last dismissed target so reopening returns to it:
|
||
|
||
```dart
|
||
/// The last chat target before the panel was closed. Used to restore the
|
||
/// previous conversation when the user reopens chat.
|
||
rust.BridgeMessageTarget? _lastDismissedTarget;
|
||
String _lastDismissedClientName = '';
|
||
```
|
||
|
||
- [ ] **Step 3: Wire `onOpenChannelChat` in `SnapshotView` constructor**
|
||
|
||
In the `SnapshotView(...)` constructor around line 2512, add the new callback:
|
||
|
||
```dart
|
||
onOpenChannelChat: (channel) => unawaited(
|
||
_onOpenChat(
|
||
target: rust.BridgeMessageTarget.channel(channel.id),
|
||
),
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 4: Run `flutter analyze`**
|
||
|
||
Run: `cd apps/chanora_flutter && flutter analyze`
|
||
Expected: No new errors
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/chanora_flutter/lib/main.dart
|
||
git commit -m "feat(chat): add per-target draft storage and wire onOpenChannelChat"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Fix `_onOpenChat` to support switching and draft save/restore
|
||
|
||
**Files:**
|
||
- Modify: `apps/chanora_flutter/lib/main.dart:1628-1685` (`_onOpenChat` and `_closeInlineChat`)
|
||
|
||
- [ ] **Step 1: Update `_onOpenChat` to save current draft and restore new target's draft**
|
||
|
||
Replace the `_onOpenChat` method (lines 1628-1676) with logic that:
|
||
1. Saves the current `_inlineChatTarget` draft before switching
|
||
2. Restores the new target's draft (if any)
|
||
3. When called with no explicit target and panel is already open, switches to current voice channel's chat
|
||
|
||
```dart
|
||
Future<void> _onOpenChat({
|
||
rust.BridgeMessageTarget? target,
|
||
String clientName = '',
|
||
}) async {
|
||
final initialSnapshot = _snapshot!;
|
||
|
||
// Resolve the new target.
|
||
// If no target passed and panel is already open, switch to current voice channel.
|
||
// If no target passed and panel is closed, resolve from history or default.
|
||
rust.BridgeMessageTarget newTarget;
|
||
if (target != null) {
|
||
newTarget = target;
|
||
} else if (_chatOpen && _currentVoiceChannelId != null) {
|
||
newTarget = rust.BridgeMessageTarget.channel(_currentVoiceChannelId!);
|
||
} else if (_inlineChatTarget != null) {
|
||
newTarget = _inlineChatTarget!;
|
||
} else {
|
||
newTarget = resolveInitialChatTarget(
|
||
messages: _chatMessages,
|
||
currentVoiceChannelId: _currentVoiceChannelId,
|
||
) ?? const rust.BridgeMessageTarget.server();
|
||
}
|
||
|
||
final newClientName = clientName.isNotEmpty
|
||
? clientName
|
||
: (newTarget == _inlineChatTarget) ? _inlineChatClientName : '';
|
||
|
||
final isExpanded =
|
||
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
|
||
LayoutClass.expanded;
|
||
if (isExpanded) {
|
||
setState(() {
|
||
// Save draft for the current target before switching.
|
||
_saveCurrentDraft();
|
||
_chatUnread = 0;
|
||
_chatOpen = true;
|
||
_inlineChatTarget = newTarget;
|
||
_inlineChatClientName = newClientName;
|
||
_inlineChatCollapseNoticeShown = false;
|
||
});
|
||
return;
|
||
}
|
||
setState(() {
|
||
_chatUnread = 0;
|
||
_chatOpen = true;
|
||
});
|
||
await Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (_) => ChatPage(
|
||
messages: _chatMessages,
|
||
snapshot: initialSnapshot,
|
||
messagesSource: () => _chatMessages,
|
||
snapshotSource: () => _snapshot ?? initialSnapshot,
|
||
refreshListenable: _chatFeedRevision,
|
||
initialTarget: newTarget,
|
||
initialClientName: newClientName,
|
||
onTs3ServerLink: _onTs3ServerLink,
|
||
),
|
||
),
|
||
);
|
||
if (mounted) setState(() => _chatOpen = false);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add `_saveCurrentDraft` and `_draftKeyForTarget` helper methods**
|
||
|
||
Add these near `_onOpenChat`:
|
||
|
||
```dart
|
||
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
|
||
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
|
||
return switch (target) {
|
||
rust.BridgeMessageTarget_Server() => 'server',
|
||
rust.BridgeMessageTarget_Channel(:final id) => 'channel:$id',
|
||
rust.BridgeMessageTarget_Client(:final id) => 'client:$id',
|
||
rust.BridgeMessageTarget_Poke(:final id) => 'poke:$id',
|
||
};
|
||
}
|
||
|
||
/// Saves the current draft text (if any) for the current inline chat target.
|
||
/// Called before switching targets or closing the panel.
|
||
void _saveCurrentDraft() {
|
||
// Note: The actual draft text is read from ChatDetailView's
|
||
// TextEditingController via a callback. This is wired in Task 4.
|
||
}
|
||
```
|
||
|
||
Note: `_saveCurrentDraft` will be completed in Task 4 when we wire the draft callback from `ChatDetailView`.
|
||
|
||
- [ ] **Step 3: Update `_closeInlineChat` to preserve last target instead of nulling it**
|
||
|
||
Replace `_closeInlineChat` (lines 1678-1685):
|
||
|
||
```dart
|
||
void _closeInlineChat() {
|
||
setState(() {
|
||
// Save draft before closing.
|
||
_saveCurrentDraft();
|
||
// Remember the last target so reopening returns to it.
|
||
_lastDismissedTarget = _inlineChatTarget;
|
||
_lastDismissedClientName = _inlineChatClientName;
|
||
_chatOpen = false;
|
||
// Do NOT null _inlineChatTarget — we want to remember it for reopen.
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run `flutter analyze`**
|
||
|
||
Run: `cd apps/chanora_flutter && flutter analyze`
|
||
Expected: No new errors
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/chanora_flutter/lib/main.dart
|
||
git commit -m "feat(chat): switch chat target on channel click, save draft before switching, preserve target on close"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Add draft save/restore callback to `ChatDetailView` and `ChatPanel`
|
||
|
||
**Files:**
|
||
- Modify: `apps/chanora_flutter/lib/widgets/chat_views.dart:1054-1098` (`ChatDetailView` constructor + state)
|
||
- Modify: `apps/chanora_flutter/lib/widgets/chat_panel.dart:12-75` (`ChatPanel` constructor + build)
|
||
|
||
- [ ] **Step 1: Add draft callbacks to `ChatDetailView`**
|
||
|
||
Add two new optional callbacks to `ChatDetailView` (after `messageMaxWidth` around line 1066):
|
||
|
||
```dart
|
||
/// External draft text to restore when the widget initializes or the target changes.
|
||
final String? restoredDraft;
|
||
|
||
/// Called with the current draft text whenever the target changes or the widget is disposed.
|
||
final ValueChanged<String>? onDraftChanged;
|
||
```
|
||
|
||
- [ ] **Step 2: Implement draft restore in `_ChatDetailViewState`**
|
||
|
||
In `_ChatDetailViewState` (line 1100), add `initState` and `didUpdateWidget` to handle drafts:
|
||
|
||
```dart
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.restoredDraft != null && widget.restoredDraft!.isNotEmpty) {
|
||
_textCtl.text = widget.restoredDraft!;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant ChatDetailView oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.target != widget.target) {
|
||
// Save draft for old target before switching.
|
||
if (oldWidget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
|
||
oldWidget.onDraftChanged!(_textCtl.text);
|
||
}
|
||
// Restore draft for new target.
|
||
_textCtl.text = widget.restoredDraft ?? '';
|
||
_lastRenderedTarget = null;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
// Emit the current draft so the parent can save it.
|
||
if (widget.onDraftChanged != null && _textCtl.text.isNotEmpty) {
|
||
widget.onDraftChanged!(_textCtl.text);
|
||
}
|
||
_textCtl.dispose();
|
||
_scrollCtl.dispose();
|
||
super.dispose();
|
||
}
|
||
```
|
||
|
||
Remove the existing `dispose` method (lines 1127-1132) — it's replaced by the new one above.
|
||
|
||
- [ ] **Step 3: Thread draft callbacks through `ChatPanel`**
|
||
|
||
Update `ChatPanel` to accept and pass through the new callbacks. Add fields:
|
||
|
||
```dart
|
||
/// External draft text to restore in the chat detail view.
|
||
final String? restoredDraft;
|
||
|
||
/// Called when the draft text changes.
|
||
final ValueChanged<String>? onDraftChanged;
|
||
```
|
||
|
||
Pass them through in `build()` where `ChatDetailView` is constructed (line 58):
|
||
|
||
```dart
|
||
child: ChatDetailView(
|
||
messages: messages,
|
||
snapshot: snapshot,
|
||
target: target,
|
||
clientName: clientName,
|
||
currentChannelId: currentChannelId,
|
||
channelName: channelName,
|
||
onTs3ServerLink: onTs3ServerLink,
|
||
restoredDraft: restoredDraft,
|
||
onDraftChanged: onDraftChanged,
|
||
messageMaxWidth: 500,
|
||
headerTrailing: IconButton(
|
||
tooltip: 'Close chat',
|
||
icon: const Icon(Icons.close),
|
||
onPressed: onClose,
|
||
),
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 4: Wire draft callbacks in `main.dart`**
|
||
|
||
In the `ChatPanel(...)` constructor around line 2567, add the draft callbacks:
|
||
|
||
```dart
|
||
ChatPanel(
|
||
messages: _chatMessages,
|
||
snapshot: _snapshot!,
|
||
target: inlineChatTarget,
|
||
clientName: _inlineChatClientName,
|
||
onTs3ServerLink: _onTs3ServerLink,
|
||
restoredDraft: _chatDrafts[_draftKeyForTarget(inlineChatTarget)],
|
||
onDraftChanged: (text) {
|
||
_chatDrafts[_draftKeyForTarget(_inlineChatTarget!)] = text;
|
||
},
|
||
onClose: _closeInlineChat,
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 5: Complete `_saveCurrentDraft` in `main.dart`**
|
||
|
||
The `_saveCurrentDraft` method is called from `_onOpenChat` (before switching) and `_closeInlineChat`. Since `ChatDetailView` emits drafts via `onDraftChanged` and `dispose`, the parent always has the latest draft in `_chatDrafts`. The method body stays as a no-op safety net:
|
||
|
||
```dart
|
||
void _saveCurrentDraft() {
|
||
// Drafts are continuously saved via onDraftChanged callback.
|
||
// This method exists as an explicit save point for any future
|
||
// snapshot-based draft capture.
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run `flutter analyze`**
|
||
|
||
Run: `cd apps/chanora_flutter && flutter analyze`
|
||
Expected: No new errors
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/chanora_flutter/lib/widgets/chat_views.dart apps/chanora_flutter/lib/widgets/chat_panel.dart apps/chanora_flutter/lib/main.dart
|
||
git commit -m "feat(chat): per-target draft persistence with save/restore on switch"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Add unread dot indicator to channel tiles in `SnapshotView`
|
||
|
||
**Files:**
|
||
- Modify: `apps/chanora_flutter/lib/widgets/snapshot_view.dart` (add unread indicator)
|
||
- Modify: `apps/chanora_flutter/lib/main.dart` (pass unread channel set)
|
||
|
||
- [ ] **Step 1: Add unread channel IDs parameter to `SnapshotView`**
|
||
|
||
Add a new required field to `SnapshotView` (after `canJoinVoiceChannel` around line 57):
|
||
|
||
```dart
|
||
/// Set of channel IDs that have unread chat messages.
|
||
final Set<BigInt> unreadChannelIds;
|
||
```
|
||
|
||
- [ ] **Step 2: Add unread dot to `_channelTile`**
|
||
|
||
In `_channelTile`, inside the `Row` children (after the channel name `Expanded` widget, around line 273), add an unread dot:
|
||
|
||
```dart
|
||
// Unread indicator.
|
||
if (widget.unreadChannelIds.contains(channel.id)) ...[
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.primary,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
],
|
||
```
|
||
|
||
This must come before the password lock icon check (line 274).
|
||
|
||
- [ ] **Step 3: Compute unread channel set in `main.dart`**
|
||
|
||
Add a getter in `_BetaHomeState` that computes which channels have unread messages:
|
||
|
||
```dart
|
||
/// Channel IDs that have unread chat messages (used for dot indicators).
|
||
Set<BigInt> get _unreadChannelIds {
|
||
if (_chatOpen) return const {};
|
||
final ids = <BigInt>{};
|
||
for (final entry in _chatMessages) {
|
||
if (!entry.countsTowardUnread || entry.isSelf) continue;
|
||
if (entry.target case rust.BridgeMessageTarget_Channel(:final id)) {
|
||
ids.add(id);
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Pass unread channel set to `SnapshotView`**
|
||
|
||
In the `SnapshotView(...)` constructor around line 2512, add:
|
||
|
||
```dart
|
||
unreadChannelIds: _unreadChannelIds,
|
||
```
|
||
|
||
- [ ] **Step 5: Run `flutter analyze`**
|
||
|
||
Run: `cd apps/chanora_flutter && flutter analyze`
|
||
Expected: No new errors
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add apps/chanora_flutter/lib/widgets/snapshot_view.dart apps/chanora_flutter/lib/main.dart
|
||
git commit -m "feat(chat): unread dot indicator on channel tiles with unread messages"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: End-to-end verification
|
||
|
||
**Files:** All modified files.
|
||
|
||
- [ ] **Step 1: Run `flutter analyze` on the full project**
|
||
|
||
Run: `cd apps/chanora_flutter && flutter analyze`
|
||
Expected: Zero issues
|
||
|
||
- [ ] **Step 2: Run `flutter test`**
|
||
|
||
Run: `cd apps/chanora_flutter && flutter test`
|
||
Expected: All tests pass (same baseline as before — 180 passed, 2 skipped)
|
||
|
||
- [ ] **Step 3: Build macOS release**
|
||
|
||
Run: `bash tools/build-macos.sh`
|
||
Expected: Successful build producing `chanora-v0.2.0-beta.1-macos-aarch64.zip`
|
||
|
||
- [ ] **Step 4: Manual QA checklist**
|
||
|
||
Launch the app and verify:
|
||
|
||
1. **Channel → chat switching**: With window ≥1024dp and chat panel open showing Server chat, click a channel in the tree. The chat panel should switch to that channel's chat (messages filter to that channel). Voice join should also happen.
|
||
|
||
2. **Channel right-click → Chat**: Right-click a channel → "Open chat". Chat panel should switch to that channel's chat without joining voice.
|
||
|
||
3. **Header chat button toggle**: With chat panel open showing a DM, click the header chat button. It should switch to the current voice channel's chat.
|
||
|
||
4. **Draft persistence**: Type "hello" in chat input but don't send. Click a different channel. Type "world" in that channel's chat. Switch back to the first channel. The input should show "hello".
|
||
|
||
5. **Close and reopen**: Close the chat panel. Click the header chat button. It should reopen to the last conversation with the draft intact.
|
||
|
||
6. **Unread dots**: Close the chat panel. Have someone send a message to a specific channel. That channel in the tree should show a blue dot.
|
||
|
||
7. **Medium/compact unchanged**: Narrow the window below 1024dp. Open chat. It should still push a full-screen route as before. No regressions.
|
||
|
||
8. **DM switching still works**: Right-click a client → "Direct Message". Chat panel should switch to that DM. Right-click another client → "Direct Message". Should switch again.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
### Spec coverage
|
||
|
||
| Requirement | Task |
|
||
|---|---|
|
||
| Channel → chat switching (tap) | Task 2 (wiring) + Task 3 (target resolution) |
|
||
| Channel → chat (context menu) | Task 1 |
|
||
| Header button switches when open | Task 3 |
|
||
| Per-target draft persistence | Task 4 |
|
||
| Close = dismiss (remember state) | Task 3 |
|
||
| Unread dot on channels | Task 5 |
|
||
| Medium/compact unchanged | No changes to those paths |
|
||
|
||
### Placeholder scan
|
||
No TBD, TODO, or placeholder steps found. All code blocks contain complete implementations.
|
||
|
||
### Type consistency
|
||
- `BridgeMessageTarget.channel(id)` uses `BigInt` — matches `channel.id` type
|
||
- `_chatDrafts` uses `String` keys from `_draftKeyForTarget` — consistent
|
||
- `onOpenChannelChat` callback type `ValueChanged<rust.BridgeChannel>?` — matches widget pattern
|
||
- `unreadChannelIds` uses `Set<BigInt>` — matches channel ID type
|