feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../shared/l10n/l10n.dart';
import '../../../shared/theme/theme.dart';
import '../../../shared/widgets/buzz_loading_indicator.dart';
import '../../profile/user_cache_provider.dart';
import '../date_formatters.dart';
import 'observer_models.dart';
import 'observer_subscription.dart';
import 'transcript_item_widget.dart';
/// Full-screen modal bottom sheet showing the live agent activity transcript.
class AgentActivitySheet extends HookConsumerWidget {
final String channelId;
final String agentPubkey;
const AgentActivitySheet({
super.key,
required this.channelId,
required this.agentPubkey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final observerState = ref.watch(
observerSubscriptionProvider((
channelId: channelId,
agentPubkey: agentPubkey,
)),
);
final transcript = observerState.transcript;
final connection = observerState.connection;
// Resolve bot name.
final profile = ref.watch(
userCacheProvider.select((cache) => cache[agentPubkey.toLowerCase()]),
);
final botName = profile?.label ?? shortPubkey(agentPubkey);
// Auto-scroll to bottom on new items.
final sheetControllerRef = useRef<ScrollController?>(null);
final previousLength = useRef(0);
useEffect(() {
final sc = sheetControllerRef.value;
if (transcript.length > previousLength.value &&
sc != null &&
sc.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (sc.hasClients) {
sc.animateTo(
sc.position.maxScrollExtent,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
);
}
});
}
previousLength.value = transcript.length;
return null;
}, [transcript.length]);
// Preload the bot profile.
useEffect(() {
ref.read(userCacheProvider.notifier).preload([agentPubkey]);
return null;
}, [agentPubkey]);
return DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, sheetScrollController) {
sheetControllerRef.value = sheetScrollController;
final bottomPadding =
MediaQuery.viewPaddingOf(context).bottom + Grid.sm;
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.bot,
size: 18,
color: context.colors.onSurface,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
botName,
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
_ConnectionBadge(connection: connection),
],
),
const SizedBox(height: Grid.half),
Text(
context.l10n.agentLiveActivityHint,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.xxs),
Divider(color: context.colors.outlineVariant),
],
),
),
// Transcript list
Expanded(
child: transcript.isEmpty
? Padding(
padding: EdgeInsets.only(bottom: bottomPadding),
child: _EmptyState(
connection: connection,
errorMessage: observerState.errorMessage,
),
)
: ListView.builder(
controller: sheetScrollController,
padding: EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
bottomPadding,
),
itemCount: transcript.length,
itemBuilder: (context, index) {
return TranscriptItemWidget(item: transcript[index]);
},
),
),
],
);
},
);
}
}
class _EmptyState extends StatelessWidget {
final ObserverConnectionState connection;
final String? errorMessage;
const _EmptyState({required this.connection, this.errorMessage});
@override
Widget build(BuildContext context) {
if (connection == ObserverConnectionState.error) {
final localizedError =
Localizations.localeOf(context).languageCode == 'en'
? errorMessage
: context.l10n.agentObserverFailed;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.circleX, size: 24, color: context.colors.error),
const SizedBox(height: Grid.xxs),
Text(
'${context.l10n.transcriptError}: '
'${localizedError ?? context.l10n.agentUnknownError}',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
textAlign: TextAlign.center,
),
],
),
);
}
if (connection == ObserverConnectionState.idle) {
return Center(
child: Text(
context.l10n.agentNotConnected,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
);
}
// connecting or open — show spinner
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BuzzLoadingIndicator(
size: 28,
color: context.colors.onSurfaceVariant,
semanticLabel: context.l10n.waitingForAgent,
),
const SizedBox(height: Grid.xxs),
Text(
context.l10n.agentWaitingActivity,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}
class _ConnectionBadge extends StatelessWidget {
final ObserverConnectionState connection;
const _ConnectionBadge({required this.connection});
@override
Widget build(BuildContext context) {
final (color, label) = switch (connection) {
ObserverConnectionState.idle => (
context.colors.onSurfaceVariant,
context.l10n.agentConnectionIdle,
),
ObserverConnectionState.connecting => (
context.appColors.warning,
context.l10n.agentConnectionConnecting,
),
ObserverConnectionState.open => (
context.appColors.success,
context.l10n.agentConnectionLive,
),
ObserverConnectionState.error => (
context.colors.error,
context.l10n.agentConnectionError,
),
};
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: Grid.half),
Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
@@ -0,0 +1,149 @@
import 'package:flutter/foundation.dart';
/// Connection state for the observer relay subscription.
enum ObserverConnectionState { idle, connecting, open, error }
/// Status of a tool execution.
enum ToolStatus { executing, completed, failed, pending }
/// A decrypted observer frame from a kind:24200 event.
@immutable
class ObserverFrame {
final int seq;
final String timestamp;
final String kind;
final int? agentIndex;
final String? channelId;
final String? sessionId;
final String? turnId;
final dynamic payload;
const ObserverFrame({
required this.seq,
required this.timestamp,
required this.kind,
this.agentIndex,
this.channelId,
this.sessionId,
this.turnId,
this.payload,
});
factory ObserverFrame.fromJson(Map<String, dynamic> json) => ObserverFrame(
seq: json['seq'] as int? ?? 0,
timestamp: json['timestamp'] as String? ?? '',
kind: json['kind'] as String? ?? '',
agentIndex: json['agentIndex'] as int?,
channelId: json['channelId'] as String?,
sessionId: json['sessionId'] as String?,
turnId: json['turnId'] as String?,
payload: json['payload'],
);
}
/// A section within prompt context metadata.
@immutable
class PromptSection {
final String title;
final String body;
const PromptSection({required this.title, required this.body});
}
/// A single item in the agent activity transcript.
sealed class TranscriptItem {
String get id;
String get timestamp;
}
class MessageItem extends TranscriptItem {
@override
final String id;
final String role;
final String title;
String text;
@override
final String timestamp;
MessageItem({
required this.id,
required this.role,
required this.title,
required this.text,
required this.timestamp,
});
}
class ThoughtItem extends TranscriptItem {
@override
final String id;
final String title;
String text;
@override
final String timestamp;
ThoughtItem({
required this.id,
required this.title,
required this.text,
required this.timestamp,
});
}
class LifecycleItem extends TranscriptItem {
@override
final String id;
final String title;
String text;
@override
final String timestamp;
LifecycleItem({
required this.id,
required this.title,
required this.text,
required this.timestamp,
});
}
class MetadataItem extends TranscriptItem {
@override
final String id;
final String title;
List<PromptSection> sections;
@override
final String timestamp;
MetadataItem({
required this.id,
required this.title,
required this.sections,
required this.timestamp,
});
}
class ToolItem extends TranscriptItem {
@override
final String id;
String title;
String toolName;
String? buzzToolName;
ToolStatus status;
Map<String, dynamic> args;
String result;
bool isError;
@override
final String timestamp;
ToolItem({
required this.id,
required this.title,
required this.toolName,
this.buzzToolName,
required this.status,
required this.args,
required this.result,
required this.isError,
required this.timestamp,
});
}
@@ -0,0 +1,343 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/crypto/nip44.dart';
import '../../../shared/relay/relay.dart';
import 'observer_models.dart';
import 'transcript_builder.dart';
/// Maximum observer events to keep per agent.
const _maxObserverEvents = 800;
/// Key for channel-scoped transcript reads.
typedef ObserverKey = ({String channelId, String agentPubkey});
/// State emitted by the channel-scoped observer transcript provider.
@immutable
class ObserverState {
final ObserverConnectionState connection;
final List<TranscriptItem> transcript;
final String? errorMessage;
const ObserverState({
required this.connection,
required this.transcript,
this.errorMessage,
});
const ObserverState.initial()
: connection = ObserverConnectionState.idle,
transcript = const [],
errorMessage = null;
}
@immutable
class ObserverRelayState {
final ObserverConnectionState connection;
final Map<String, List<ObserverFrame>> framesByAgent;
final String? errorMessage;
const ObserverRelayState({
required this.connection,
required this.framesByAgent,
this.errorMessage,
});
const ObserverRelayState.initial()
: connection = ObserverConnectionState.idle,
framesByAgent = const {},
errorMessage = null;
}
class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
final Map<String, List<ObserverFrame>> _framesByAgent = {};
final Map<String, Set<String>> _dedupeKeysByAgent = {};
final Map<String, Uint8List> _conversationKeysByAgent = {};
void Function()? _unsubscribe;
Future<void>? _startFuture;
String? _privHex;
String? _ownerPubkey;
String? _identityKey;
String? _errorMessage;
int _subscriptionEpoch = 0;
bool _disposed = false;
@override
ObserverRelayState build() {
final config = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
final identityKey = '${config.baseUrl}|${config.nsec ?? ''}';
_disposed = false;
if (_identityKey != null && _identityKey != identityKey) {
_reset();
}
_identityKey = identityKey;
ref.onDispose(() {
_disposed = true;
_reset();
});
if (sessionState.status == SessionStatus.connected) {
Future.microtask(_ensureSubscribed);
}
final hasSigningKey = config.nsec?.isNotEmpty == true;
return ObserverRelayState(
connection: hasSigningKey
? _connectionForSession(sessionState.status)
: ObserverConnectionState.idle,
framesByAgent: _snapshotFrames(),
errorMessage: _errorMessage,
);
}
Future<void> _ensureSubscribed() {
if (_disposed) return Future.value();
if (_unsubscribe != null) return Future.value();
if (_startFuture != null) return _startFuture!;
_startFuture = _subscribe(_subscriptionEpoch);
return _startFuture!;
}
Future<void> _subscribe(int epoch) async {
try {
if (_disposed || epoch != _subscriptionEpoch) {
return;
}
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
if (nsec == null || nsec.isEmpty) {
_errorMessage = null;
_emit(connection: ObserverConnectionState.idle);
return;
}
final privHex = _decodePrivkey(nsec);
final ownerPubkey = _derivePubkey(privHex);
if (_disposed || epoch != _subscriptionEpoch) {
return;
}
_privHex = privHex;
_ownerPubkey = ownerPubkey;
_errorMessage = null;
_emit(connection: ObserverConnectionState.connecting);
final session = ref.read(relaySessionProvider.notifier);
final unsubscribe = await session.subscribe(
NostrFilter(
kinds: [EventKind.agentObserverFrame],
tags: {
'#p': [ownerPubkey],
},
limit: 0,
),
_handleEvent,
onClosed: (message) {
_unsubscribe = null;
_errorMessage = 'Observer subscription closed: $message';
_emit(connection: ObserverConnectionState.error);
},
);
if (_disposed || epoch != _subscriptionEpoch) {
unsubscribe();
return;
}
_unsubscribe = unsubscribe;
_emit(connection: ObserverConnectionState.open);
} catch (error) {
if (_disposed || epoch != _subscriptionEpoch) {
return;
}
_errorMessage = _observerErrorMessage(error);
_emit(connection: ObserverConnectionState.error);
} finally {
if (epoch == _subscriptionEpoch) {
_startFuture = null;
}
}
}
void _handleEvent(NostrEvent event) {
final agentPubkey = event.getTagValue('agent');
if (agentPubkey == null || event.getTagValue('frame') != 'telemetry') {
return;
}
final normalizedAgent = agentPubkey.toLowerCase();
if (event.pubkey.toLowerCase() != normalizedAgent) {
return;
}
final ownerPubkey = _ownerPubkey;
final privHex = _privHex;
if (ownerPubkey == null || privHex == null) {
return;
}
final pTag = event.getTagValue('p');
if (pTag?.toLowerCase() != ownerPubkey.toLowerCase()) {
return;
}
final frame = _decryptFrame(event, normalizedAgent, privHex);
if (frame == null) return;
final dedupeKey = '${frame.seq}:${frame.timestamp}';
final dedupeKeys = _dedupeKeysByAgent.putIfAbsent(
normalizedAgent,
() => <String>{},
);
if (!dedupeKeys.add(dedupeKey)) {
return;
}
final frames = _framesByAgent.putIfAbsent(
normalizedAgent,
() => <ObserverFrame>[],
);
frames.add(frame);
frames.sort(_compareObserverFrames);
if (frames.length > _maxObserverEvents) {
final removeCount = frames.length - _maxObserverEvents;
for (final removed in frames.take(removeCount)) {
dedupeKeys.remove('${removed.seq}:${removed.timestamp}');
}
frames.removeRange(0, removeCount);
}
_errorMessage = null;
_emit(connection: ObserverConnectionState.open);
}
ObserverFrame? _decryptFrame(
NostrEvent event,
String normalizedAgent,
String privHex,
) {
try {
final conversationKey = _conversationKeysByAgent.putIfAbsent(
normalizedAgent,
() => getConversationKey(privHex, normalizedAgent),
);
final plaintext = nip44Decrypt(conversationKey, event.content);
final json = jsonDecode(plaintext) as Map<String, dynamic>;
return ObserverFrame.fromJson(json);
} catch (error) {
_errorMessage = 'Observer event decrypt failed: $error';
_emit(connection: ObserverConnectionState.error);
return null;
}
}
void _emit({required ObserverConnectionState connection}) {
if (_disposed) return;
state = ObserverRelayState(
connection: connection,
framesByAgent: _snapshotFrames(),
errorMessage: _errorMessage,
);
}
Map<String, List<ObserverFrame>> _snapshotFrames() {
return Map<String, List<ObserverFrame>>.unmodifiable({
for (final entry in _framesByAgent.entries)
entry.key: List<ObserverFrame>.unmodifiable(entry.value),
});
}
void _reset() {
_subscriptionEpoch += 1;
_unsubscribe?.call();
_unsubscribe = null;
_startFuture = null;
_privHex = null;
_ownerPubkey = null;
_errorMessage = null;
_framesByAgent.clear();
_dedupeKeysByAgent.clear();
_conversationKeysByAgent.clear();
}
static String _decodePrivkey(String nsec) {
try {
final privHex = nostr.Nip19.decode(payload: nsec).data;
if (privHex.isEmpty) {
throw const FormatException('empty private key');
}
return privHex;
} catch (_) {
throw const FormatException('failed to decode private key');
}
}
static String _derivePubkey(String privHex) {
try {
return nostr.Keys(privHex).public;
} catch (_) {
throw const FormatException('failed to derive pubkey');
}
}
ObserverConnectionState _connectionForSession(SessionStatus status) {
if (_errorMessage != null && _unsubscribe == null && _startFuture == null) {
return ObserverConnectionState.error;
}
return switch (status) {
SessionStatus.connected =>
_unsubscribe == null
? ObserverConnectionState.connecting
: ObserverConnectionState.open,
SessionStatus.connecting ||
SessionStatus.reconnecting => ObserverConnectionState.connecting,
SessionStatus.disconnected => ObserverConnectionState.idle,
};
}
static String _observerErrorMessage(Object error) {
if (error is FormatException) {
return error.message;
}
return 'Observer subscription failed: $error';
}
static int _compareObserverFrames(ObserverFrame a, ObserverFrame b) {
final tsA = DateTime.tryParse(a.timestamp)?.millisecondsSinceEpoch ?? 0;
final tsB = DateTime.tryParse(b.timestamp)?.millisecondsSinceEpoch ?? 0;
if (tsA != tsB) return tsA.compareTo(tsB);
return a.seq.compareTo(b.seq);
}
}
final observerRelayProvider =
NotifierProvider<ObserverRelayNotifier, ObserverRelayState>(
ObserverRelayNotifier.new,
);
final observerSubscriptionProvider =
Provider.family<ObserverState, ObserverKey>((ref, key) {
final relayState = ref.watch(observerRelayProvider);
final normalizedAgent = key.agentPubkey.toLowerCase();
final frames = relayState.framesByAgent[normalizedAgent] ?? const [];
final channelFrames = [
for (final frame in frames)
if (frame.channelId == null || frame.channelId == key.channelId)
frame,
];
return ObserverState(
connection: relayState.connection,
transcript: buildTranscript(channelFrames),
errorMessage: relayState.errorMessage,
);
});
@@ -0,0 +1,714 @@
import 'dart:convert';
import 'observer_models.dart';
const _buzzReadTools = <String>{
'get_messages',
'get_channel_history',
'get_thread',
'search',
'get_feed',
'get_reactions',
'list_channels',
'get_channel',
'get_users',
'get_presence',
'list_channel_members',
'list_dms',
'get_canvas',
'list_workflows',
'get_workflow_runs',
'get_event',
'get_user_notes',
'get_contact_list',
};
const _buzzWriteTools = <String>{
'send_message',
'send_diff_message',
'edit_message',
'delete_message',
'add_reaction',
'remove_reaction',
'join_channel',
'leave_channel',
'update_channel',
'set_channel_topic',
'set_channel_purpose',
'open_dm',
'set_profile',
'set_presence',
'trigger_workflow',
'approve_step',
'create_channel',
'archive_channel',
'unarchive_channel',
'add_channel_member',
'remove_channel_member',
'add_dm_member',
'hide_dm',
'set_canvas',
'create_workflow',
'update_workflow',
'delete_workflow',
'set_channel_add_policy',
'vote_on_post',
'publish_note',
'set_contact_list',
};
final _buzzToolNames = <String>{..._buzzReadTools, ..._buzzWriteTools};
final _buzzToolNamesByLength = _buzzToolNames.toList()
..sort((a, b) => b.length.compareTo(a.length));
final _buzzToolTitleAliases = <(RegExp, String)>[
(RegExp(r'\bsending message to channel\b'), 'send_message'),
(RegExp(r'\bretrieving recent messages from channel\b'), 'get_messages'),
(RegExp(r'\bgetting channel details\b'), 'get_channel'),
(RegExp(r'\bgetting user information\b'), 'get_users'),
(RegExp(r'\bsearching relay history\b'), 'search'),
(RegExp(r'\bgetting thread\b'), 'get_thread'),
(RegExp(r'\badding reaction\b'), 'add_reaction'),
(RegExp(r'\bremoving reaction\b'), 'remove_reaction'),
];
Map<String, dynamic> _asRecord(dynamic value) {
if (value is Map<String, dynamic>) return value;
if (value is Map) return value.cast<String, dynamic>();
return const {};
}
String? _asString(dynamic value) {
return value is String ? value : null;
}
String _shorten(String value) {
if (value.length > 14) {
return '${value.substring(0, 8)}...${value.substring(value.length - 4)}';
}
return value;
}
String _titleCase(String value) {
return value
.replaceAll(RegExp(r'[_-]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.replaceAllMapped(RegExp(r'\b\w'), (m) => m[0]!.toUpperCase());
}
String _normalizeToolNameText(String value) {
return value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9_]+'), '_')
.replaceAll(RegExp(r'_+'), '_')
.replaceAll(RegExp(r'^_+|_+$'), '');
}
String? _findBuzzToolName(String value, bool includeShortNames) {
final alias = _findBuzzToolAlias(value);
if (alias != null) return alias;
final normalized = _normalizeToolNameText(value);
for (final name in _buzzToolNamesByLength) {
if ((!includeShortNames && name.length < 8) || !normalized.contains(name)) {
continue;
}
return name;
}
return null;
}
String? _findBuzzToolAlias(String value) {
final normalizedPhrase = value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[_-]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ');
for (final (pattern, name) in _buzzToolTitleAliases) {
if (pattern.hasMatch(normalizedPhrase)) return name;
}
return null;
}
bool _isGenericToolTitle(String value) {
final normalized = _normalizeToolNameText(value);
return normalized.isEmpty ||
normalized == 'tool' ||
normalized == 'tool_call' ||
normalized == 'mcp_tool_call' ||
normalized == 'unknown' ||
normalized == 'read' ||
normalized == 'write' ||
normalized == 'execute' ||
normalized == 'completed';
}
String _normalizeToolName(String title) {
final knownName = _findBuzzToolName(title, true);
if (knownName != null) return knownName;
final normalized = _normalizeToolNameText(
title,
).replaceAll(RegExp(r'^buzz_'), '');
return RegExp(r'[a-z][a-z0-9_]+').firstMatch(normalized)?[0] ?? normalized;
}
ToolStatus _normalizeToolStatus(String status) {
final normalized = status.toLowerCase();
if (normalized.contains('complete') ||
normalized.contains('success') ||
normalized == 'done') {
return ToolStatus.completed;
}
if (normalized.contains('fail') || normalized.contains('error')) {
return ToolStatus.failed;
}
if (normalized.contains('pending')) {
return ToolStatus.pending;
}
return ToolStatus.executing;
}
String _extractContentText(dynamic value) {
if (value is String) return value;
if (value is List) return value.map(_extractBlockText).join('\n');
return _extractBlockText(value);
}
String _extractBlockText(dynamic value) {
if (value is String) return value;
if (value is List) return value.map(_extractBlockText).join('\n');
final record = _asRecord(value);
final nestedContent = record['content'];
final rawOutput = record['rawOutput'];
final nestedText = nestedContent != null && nestedContent is! String
? _extractBlockText(nestedContent)
: '';
final rawOutputText = rawOutput == null
? ''
: rawOutput is String
? rawOutput
: _safeJsonEncode(rawOutput);
final directText = _asString(record['text']) ?? _asString(record['content']);
if (directText != null && directText.isNotEmpty) return directText;
if (nestedText.isNotEmpty) return nestedText;
if (rawOutputText.isNotEmpty) return rawOutputText;
return '';
}
String _extractPromptText(Map<String, dynamic> payload) {
final params = _asRecord(payload['params']);
final prompt = params['prompt'];
if (prompt is! List) return '';
return (prompt).map(_extractBlockText).where((s) => s.isNotEmpty).join('\n');
}
({List<PromptSection> sections, String userText, String userTitle})
_parsePromptText(String text) {
final sections = _parsePromptSections(text);
if (sections.isEmpty) {
return (
sections: <PromptSection>[],
userText: text.trim(),
userTitle: 'Prompt',
);
}
PromptSection? eventSection;
for (final section in sections) {
if (section.title.toLowerCase().startsWith('buzz event')) {
eventSection = section;
break;
}
}
final eventContent = eventSection != null
? _extractEventContent(eventSection.body)
: '';
final eventKind = eventSection?.title.split(':').skip(1).join(':').trim();
return (
sections: sections,
userText: eventContent,
userTitle: eventKind != null && eventKind.isNotEmpty
? _titleCase(eventKind)
: 'Buzz event',
);
}
List<PromptSection> _parsePromptSections(String text) {
final sections = <PromptSection>[];
final headerPattern = RegExp(r'^\[([^\]]+)]\s*$');
String? currentTitle;
final currentBody = StringBuffer();
final preamble = <String>[];
for (final line in text.split(RegExp(r'\r?\n'))) {
final header = headerPattern.firstMatch(line);
if (header != null) {
if (currentTitle != null) {
sections.add(
PromptSection(
title: currentTitle,
body: currentBody.toString().trim(),
),
);
} else {
final pre = preamble.join('\n').trim();
if (pre.isNotEmpty) {
sections.add(PromptSection(title: 'Prompt', body: pre));
}
}
currentTitle = header.group(1)!;
currentBody.clear();
continue;
}
if (currentTitle != null) {
if (currentBody.isNotEmpty) currentBody.write('\n');
currentBody.write(line);
} else {
preamble.add(line);
}
}
if (currentTitle != null) {
sections.add(
PromptSection(title: currentTitle, body: currentBody.toString().trim()),
);
} else {
final pre = preamble.join('\n').trim();
if (pre.isNotEmpty) {
sections.add(PromptSection(title: 'Prompt', body: pre));
}
}
return sections;
}
String _extractEventContent(String body) {
final match = RegExp(r'^Content:\s*(.*)$', multiLine: true).firstMatch(body);
return match?.group(1)?.trim() ?? '';
}
Map<String, dynamic> _extractToolArgs(Map<String, dynamic> update) {
final candidates = [
update['args'],
update['arguments'],
update['input'],
update['rawInput'],
];
for (final candidate in candidates) {
if (candidate is Map && candidate is! List) {
return Map<String, dynamic>.from(candidate);
}
}
return const {};
}
({String title, String toolName, String? buzzToolName}) _extractToolIdentity(
Map<String, dynamic> update,
) {
final candidates = _collectToolNameCandidates(update);
String? knownName;
for (final c in candidates) {
knownName = _findBuzzToolName(c, true);
if (knownName != null) break;
}
knownName ??= _findBuzzToolName(_safeJsonEncode(update), false);
String? firstSpecific;
for (final candidate in candidates) {
if (!_isGenericToolTitle(candidate)) {
firstSpecific = candidate;
break;
}
}
final title =
_asString(update['title']) ?? knownName ?? firstSpecific ?? 'Tool call';
return (
title: title,
toolName: knownName ?? _normalizeToolName(firstSpecific ?? title),
buzzToolName: knownName,
);
}
List<String> _collectToolNameCandidates(Map<String, dynamic> update) {
final args = _extractToolArgs(update);
final tool = _asRecord(update['tool']);
final input = _asRecord(update['input']);
final rawInput = _asRecord(update['rawInput']);
final sources = <dynamic>[
update['toolName'],
update['tool_name'],
update['name'],
update['title'],
update['kind'],
tool['name'],
tool['toolName'],
args['toolName'],
args['tool_name'],
args['name'],
args['method'],
input['toolName'],
input['tool_name'],
input['name'],
rawInput['toolName'],
rawInput['tool_name'],
rawInput['name'],
];
return [
for (final s in sources)
if (s is String && s.isNotEmpty) s,
];
}
String _extractToolResult(Map<String, dynamic> update) {
final contentText = _extractContentText(update['content']);
if (contentText.isNotEmpty) return contentText;
return _extractBlockText(update['rawOutput']);
}
String _describeTurnStarted(dynamic payload) {
final record = _asRecord(payload);
final ids = record['triggeringEventIds'];
if (ids is List) {
final stringIds = ids.whereType<String>().toList();
if (stringIds.isNotEmpty) {
return 'Triggered by ${stringIds.map(_shorten).join(', ')}.';
}
}
return 'Heartbeat or internal turn.';
}
String _describeSessionResolved(dynamic payload) {
final record = _asRecord(payload);
final sessionId = _asString(record['sessionId']);
final isNewSession = record['isNewSession'] == true;
if (sessionId == null) return 'Using existing ACP session.';
return '${isNewSession ? 'Created' : 'Using'} session ${_shorten(sessionId)}.';
}
String _safeJsonEncode(dynamic value) {
try {
return jsonEncode(value);
} catch (_) {
return value.toString();
}
}
List<TranscriptItem> buildTranscript(List<ObserverFrame> events) {
final items = <TranscriptItem>[];
final itemsById = <String, TranscriptItem>{};
// Maps a logical message ID to the actual key currently being appended to.
final activeMessageKey = <String, String>{};
final sealedKeys = <String>{};
var continuationSeq = 0;
void sealOpenMessages() {
for (final currentKey in activeMessageKey.values) {
sealedKeys.add(currentKey);
}
}
void upsertMessage(
String id,
String role,
String title,
String text,
String timestamp,
) {
final currentKey = activeMessageKey[id];
if (currentKey != null && !sealedKeys.contains(currentKey)) {
final existing = itemsById[currentKey];
if (existing is MessageItem) {
existing.text += text;
return;
}
}
continuationSeq += 1;
final newKey = currentKey != null ? '$id:c$continuationSeq' : id;
final item = MessageItem(
id: newKey,
role: role,
title: title,
text: text,
timestamp: timestamp,
);
items.add(item);
itemsById[newKey] = item;
activeMessageKey[id] = newKey;
}
void upsertTextItem(
String id,
String type,
String title,
String text,
String timestamp,
) {
final existing = itemsById[id];
if (existing != null) {
if (type == 'thought' && existing is ThoughtItem) {
existing.text += text;
return;
}
if (type == 'lifecycle' && existing is LifecycleItem) {
existing.text += text;
return;
}
}
sealOpenMessages();
final TranscriptItem item;
if (type == 'thought') {
item = ThoughtItem(
id: id,
title: title,
text: text,
timestamp: timestamp,
);
} else {
item = LifecycleItem(
id: id,
title: title,
text: text,
timestamp: timestamp,
);
}
items.add(item);
itemsById[id] = item;
}
void upsertMetadata(
String id,
String title,
List<PromptSection> sections,
String timestamp,
) {
final existing = itemsById[id];
if (existing is MetadataItem) {
existing.sections = sections;
return;
}
sealOpenMessages();
final item = MetadataItem(
id: id,
title: title,
sections: sections,
timestamp: timestamp,
);
items.add(item);
itemsById[id] = item;
}
void upsertTool(
String id,
String title,
String toolName,
String? buzzToolName,
ToolStatus status,
Map<String, dynamic> args,
String result,
bool isError,
String timestamp,
) {
final existing = itemsById[id];
final canonicalBuzzToolName =
buzzToolName ?? _findBuzzToolName(toolName, true);
if (existing is ToolItem) {
if (!_isGenericToolTitle(title)) {
existing.title = title;
}
if (canonicalBuzzToolName != null) {
existing.buzzToolName = canonicalBuzzToolName;
existing.toolName = canonicalBuzzToolName;
} else if (existing.buzzToolName == null &&
!_isGenericToolTitle(toolName)) {
existing.toolName = toolName;
}
existing.status = status;
existing.args = args.isNotEmpty ? args : existing.args;
if (result.isNotEmpty) existing.result = result;
existing.isError = isError || existing.isError;
return;
}
sealOpenMessages();
final item = ToolItem(
id: id,
title: title,
toolName: canonicalBuzzToolName ?? toolName,
buzzToolName: canonicalBuzzToolName,
status: status,
args: args,
result: result,
isError: isError,
timestamp: timestamp,
);
items.add(item);
itemsById[id] = item;
}
for (final event in events) {
if (event.kind == 'turn_started') {
upsertTextItem(
'turn:${event.turnId ?? '${event.seq}'}',
'lifecycle',
'Turn started',
_describeTurnStarted(event.payload),
event.timestamp,
);
continue;
}
if (event.kind == 'session_resolved') {
upsertTextItem(
'session:${event.turnId ?? '${event.seq}'}',
'lifecycle',
'Session ready',
_describeSessionResolved(event.payload),
event.timestamp,
);
continue;
}
if (event.kind == 'acp_parse_error') {
upsertTextItem(
'parse-error:${event.seq}',
'lifecycle',
'Wire parse error',
_extractBlockText(event.payload),
event.timestamp,
);
continue;
}
if (event.kind != 'acp_read' && event.kind != 'acp_write') {
continue;
}
final payload = _asRecord(event.payload);
final method = _asString(payload['method']);
if (event.kind == 'acp_write' && method == 'session/prompt') {
final promptText = _extractPromptText(payload);
if (promptText.isNotEmpty) {
final parsedPrompt = _parsePromptText(promptText);
if (parsedPrompt.userText.isNotEmpty) {
upsertMessage(
'prompt:${event.turnId ?? '${event.seq}'}',
'user',
parsedPrompt.userTitle,
parsedPrompt.userText,
event.timestamp,
);
}
if (parsedPrompt.sections.isNotEmpty) {
upsertMetadata(
'prompt-context:${event.turnId ?? '${event.seq}'}',
'Prompt context',
parsedPrompt.sections,
event.timestamp,
);
}
}
continue;
}
if (event.kind != 'acp_read' || method != 'session/update') {
continue;
}
final params = _asRecord(payload['params']);
final update = _asRecord(params['update']);
final updateType = _asString(update['sessionUpdate']) ?? 'unknown';
final turnKey = event.turnId ?? event.sessionId ?? 'unknown';
final messageId = _asString(update['messageId']);
if (updateType == 'agent_message_chunk') {
upsertMessage(
'assistant:${messageId ?? turnKey}',
'assistant',
'Assistant',
_extractContentText(update['content']),
event.timestamp,
);
continue;
}
if (updateType == 'user_message_chunk') {
upsertMessage(
'user:${messageId ?? turnKey}',
'user',
'User',
_extractContentText(update['content']),
event.timestamp,
);
continue;
}
if (updateType == 'agent_thought_chunk') {
upsertTextItem(
'thinking:${messageId ?? turnKey}',
'thought',
'Thinking',
_extractContentText(update['content']),
event.timestamp,
);
continue;
}
if (updateType == 'tool_call') {
final toolId = _asString(update['toolCallId']) ?? 'tool:${event.seq}';
final identity = _extractToolIdentity(update);
upsertTool(
'tool:$toolId',
identity.title,
identity.toolName,
identity.buzzToolName,
_normalizeToolStatus(_asString(update['status']) ?? 'executing'),
_extractToolArgs(update),
_extractToolResult(update),
false,
event.timestamp,
);
continue;
}
if (updateType == 'tool_call_update') {
final toolId = _asString(update['toolCallId']) ?? 'tool:${event.seq}';
final status = _normalizeToolStatus(
_asString(update['status']) ?? 'completed',
);
final identity = _extractToolIdentity(update);
upsertTool(
'tool:$toolId',
identity.title,
identity.toolName,
identity.buzzToolName,
status,
_extractToolArgs(update),
_extractToolResult(update),
status == ToolStatus.failed,
event.timestamp,
);
continue;
}
if (updateType == 'plan') {
final content = _extractContentText(update['content']);
upsertTextItem(
'plan:$turnKey',
'thought',
'Plan',
content.isNotEmpty ? content : _safeJsonEncode(update),
event.timestamp,
);
}
}
return items;
}
@@ -0,0 +1,559 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../shared/l10n/l10n.dart';
import '../../../shared/theme/theme.dart';
import 'observer_models.dart';
/// Renders a single [TranscriptItem] in the agent activity transcript.
class TranscriptItemWidget extends StatelessWidget {
final TranscriptItem item;
const TranscriptItemWidget({super.key, required this.item});
@override
Widget build(BuildContext context) {
return switch (item) {
final MessageItem i => _MessageItemWidget(item: i),
final ThoughtItem i => _ThoughtItemWidget(item: i),
final LifecycleItem i => _LifecycleItemWidget(item: i),
final MetadataItem i => _MetadataItemWidget(item: i),
final ToolItem i => _ToolItemWidget(item: i),
};
}
}
class _MessageItemWidget extends StatelessWidget {
final MessageItem item;
const _MessageItemWidget({required this.item});
@override
Widget build(BuildContext context) {
final isAssistant = item.role == 'assistant';
final badgeColor = isAssistant
? context.colors.primary
: context.colors.onSurfaceVariant;
final badgeLabel = isAssistant
? context.l10n.transcriptAssistant
: context.l10n.transcriptUser;
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
badgeLabel,
style: context.textTheme.labelSmall?.copyWith(
color: badgeColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: Grid.half),
if (item.text.isNotEmpty)
GptMarkdown(
item.text,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
);
}
}
class _ThoughtItemWidget extends HookWidget {
final ThoughtItem item;
const _ThoughtItemWidget({required this.item});
@override
Widget build(BuildContext context) {
final expanded = useState(item.text.length <= 200);
useEffect(() {
expanded.value = item.text.length <= 200;
return null;
}, [item.id]);
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half),
child: GestureDetector(
onTap: () => expanded.value = !expanded.value,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xxs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.brain,
size: 14,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Expanded(
child: Text(
_localizedTranscriptLabel(context, item.title),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
expanded.value
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 14,
color: context.colors.onSurfaceVariant,
),
],
),
if (expanded.value && item.text.isNotEmpty) ...[
const SizedBox(height: Grid.half),
GptMarkdown(
item.text,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
),
),
);
}
}
class _LifecycleItemWidget extends StatelessWidget {
final LifecycleItem item;
const _LifecycleItemWidget({required this.item});
@override
Widget build(BuildContext context) {
final localizedText = _localizedTranscriptText(context, item.text);
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half),
child: Center(
child: Text(
'${_localizedTranscriptLabel(context, item.title)}'
'${localizedText.isNotEmpty ? ' \u2014 $localizedText' : ''}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
);
}
}
class _MetadataItemWidget extends HookWidget {
final MetadataItem item;
const _MetadataItemWidget({required this.item});
@override
Widget build(BuildContext context) {
final expanded = useState(false);
useEffect(() {
expanded.value = false;
return null;
}, [item.id]);
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half),
child: GestureDetector(
onTap: () => expanded.value = !expanded.value,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xxs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.fileText,
size: 14,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Expanded(
child: Text(
'${_localizedTranscriptLabel(context, item.title)} '
'(${context.l10n.transcriptSections(item.sections.length)})',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
expanded.value
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 14,
color: context.colors.onSurfaceVariant,
),
],
),
if (expanded.value)
for (final section in item.sections) ...[
const SizedBox(height: Grid.xxs),
Text(
_localizedTranscriptLabel(context, section.title),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
if (section.body.isNotEmpty) ...[
const SizedBox(height: Grid.quarter),
Text(
section.body.length > 500
? '${section.body.substring(0, 500)}\u2026'
: section.body,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
],
),
),
),
);
}
}
class _ToolItemWidget extends HookWidget {
final ToolItem item;
const _ToolItemWidget({required this.item});
@override
Widget build(BuildContext context) {
final argsExpanded = useState(false);
final resultExpanded = useState(false);
useEffect(() {
argsExpanded.value = false;
resultExpanded.value = false;
return null;
}, [item.id]);
final (statusColor, statusLabel, statusIcon) = _toolStatusDisplay(
item.status,
item.isError,
context,
);
final displayName = _formatToolName(item.toolName);
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xxs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
border: item.isError
? Border.all(color: context.colors.error.withValues(alpha: 0.3))
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: tool name + status badge
Row(
children: [
Icon(LucideIcons.wrench, size: 14, color: statusColor),
const SizedBox(width: Grid.half),
Expanded(
child: Text(
displayName,
style: context.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(statusIcon, size: 10, color: statusColor),
const SizedBox(width: Grid.quarter),
Text(
statusLabel,
style: context.textTheme.labelSmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
// Args section
if (item.args.isNotEmpty) ...[
const SizedBox(height: Grid.half),
GestureDetector(
onTap: () => argsExpanded.value = !argsExpanded.value,
child: Row(
children: [
Text(
context.l10n.transcriptArguments,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(width: Grid.half),
Icon(
argsExpanded.value
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 12,
color: context.colors.onSurfaceVariant,
),
],
),
),
if (argsExpanded.value) ...[
const SizedBox(height: Grid.quarter),
_CodeBlock(text: _prettyJson(item.args)),
],
],
// Result section
if (item.result.isNotEmpty) ...[
const SizedBox(height: Grid.half),
GestureDetector(
onTap: () => resultExpanded.value = !resultExpanded.value,
child: Row(
children: [
Text(
context.l10n.transcriptResult,
style: context.textTheme.labelSmall?.copyWith(
color: item.isError
? context.colors.error
: context.colors.onSurfaceVariant,
),
),
const SizedBox(width: Grid.half),
Icon(
resultExpanded.value
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 12,
color: item.isError
? context.colors.error
: context.colors.onSurfaceVariant,
),
],
),
),
if (resultExpanded.value) ...[
const SizedBox(height: Grid.quarter),
_CodeBlock(
text: item.result.length > 2000
? '${item.result.substring(0, 2000)}\n\n'
'${context.l10n.transcriptTruncated}'
: item.result,
isError: item.isError,
),
],
],
],
),
),
);
}
}
(Color, String, IconData) _toolStatusDisplay(
ToolStatus status,
bool isError,
BuildContext context,
) {
if (isError || status == ToolStatus.failed) {
return (
context.colors.error,
context.l10n.transcriptError,
LucideIcons.circleX,
);
}
if (status == ToolStatus.completed) {
return (
context.appColors.success,
context.l10n.transcriptDone,
LucideIcons.circleCheck,
);
}
if (status == ToolStatus.pending) {
return (
context.colors.onSurfaceVariant,
context.l10n.transcriptPending,
LucideIcons.circleDot,
);
}
return (
context.appColors.warning,
context.l10n.transcriptRunning,
LucideIcons.clock3,
);
}
String _formatToolName(String toolName) {
return toolName
.split('_')
.map(
(part) =>
part.isEmpty ? '' : '${part[0].toUpperCase()}${part.substring(1)}',
)
.join(' ');
}
String _localizedTranscriptLabel(BuildContext context, String label) {
final l10n = context.l10n;
return switch (label) {
'Assistant' => l10n.transcriptAssistant,
'Base' => l10n.transcriptBase,
'Buzz event' => l10n.transcriptBuzzEvent,
'Channel Canvas' => l10n.transcriptChannelCanvas,
'Core Memory' => l10n.transcriptCoreMemory,
'Plan' => l10n.transcriptPlan,
'Prompt' => l10n.prompt,
'Prompt context' => l10n.transcriptPromptContext,
'Session ready' => l10n.transcriptSessionReady,
'System' => l10n.transcriptSystem,
'System prompt' => l10n.transcriptSystemPrompt,
'Team Instructions' => l10n.transcriptTeamInstructions,
'Thinking' => l10n.transcriptThinking,
'Tool call' => l10n.transcriptToolCall,
'Turn started' => l10n.transcriptTurnStarted,
'User' => l10n.transcriptUser,
'Wire parse error' => l10n.transcriptWireParseError,
_ => label,
};
}
String _localizedTranscriptText(BuildContext context, String text) {
final l10n = context.l10n;
if (text == 'Heartbeat or internal turn.') {
return l10n.transcriptInternalTurn;
}
if (text == 'Using existing ACP session.') {
return l10n.transcriptUsingExistingSession;
}
if (text.startsWith('Triggered by ') && text.endsWith('.')) {
return l10n.transcriptTriggeredBy(
text.substring('Triggered by '.length, text.length - 1),
);
}
if (text.startsWith('Created session ') && text.endsWith('.')) {
return l10n.transcriptCreatedSession(
text.substring('Created session '.length, text.length - 1),
);
}
if (text.startsWith('Using session ') && text.endsWith('.')) {
return l10n.transcriptUsingSession(
text.substring('Using session '.length, text.length - 1),
);
}
return text;
}
String _prettyJson(Map<String, dynamic> value) {
try {
return const JsonEncoder.withIndent(' ').convert(value);
} catch (_) {
return value.toString();
}
}
class _CodeBlock extends StatelessWidget {
final String text;
final bool isError;
const _CodeBlock({required this.text, this.isError = false});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xxs),
decoration: BoxDecoration(
color: isError
? context.colors.error.withValues(alpha: 0.06)
: context.colors.surface,
borderRadius: BorderRadius.circular(6),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
text,
softWrap: false,
style: context.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 11,
color: isError
? context.colors.error
: context.colors.onSurfaceVariant,
),
),
),
);
}
}
@@ -0,0 +1,27 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../channel_management_provider.dart';
import '../channel_typing_provider.dart';
/// Derived provider that computes which bot members in a channel are currently
/// typing (i.e. "working"). Returns a set of lowercase pubkeys.
///
/// Used by both the members button badge and the members sheet to avoid
/// duplicating the bot-typing cross-reference logic.
final workingBotPubkeysProvider = Provider.autoDispose
.family<Set<String>, String>((ref, channelId) {
final typingEntries = ref.watch(channelTypingProvider(channelId));
final membersAsync = ref.watch(channelMembersProvider(channelId));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final botPubkeys = <String>{
for (final m in allMembers)
if (m.isBot) m.pubkey.toLowerCase(),
};
return <String>{
for (final e in typingEntries)
if (botPubkeys.contains(e.pubkey.toLowerCase()))
e.pubkey.toLowerCase(),
};
});
@@ -0,0 +1,81 @@
import 'dart:io';
import 'package:image_picker/image_picker.dart';
var _retainedImageCounter = 0;
Future<void> processCapturedImage(
XFile image,
Future<void> Function(XFile image) onCapture,
) async {
await processTemporaryImages([image], (images) => onCapture(images.single));
}
/// Processes native-owned [images], then removes their temporary files.
Future<void> processTemporaryImages(
List<XFile> images,
Future<void> Function(List<XFile> images) process,
) async {
try {
await process(images);
} finally {
for (final image in images) {
final path = image.path;
if (path.isNotEmpty) {
try {
await File(path).delete();
} on FileSystemException {
// The native picker may already have removed its temporary file.
}
}
}
}
}
/// Copies native-owned [images] into composer-owned temporary files.
///
/// Native camera and picker integrations delete their source files as soon as
/// their callback returns. Deferred sends must therefore retain their own copy
/// before queueing a preview for later upload.
Future<List<XFile>> retainTemporaryImages(List<XFile> images) async {
final retained = <XFile>[];
try {
for (final image in images) {
final sourcePath = image.path;
final extension = _safeFileExtension(sourcePath);
final destination = File(
'${Directory.systemTemp.path}${Platform.pathSeparator}'
'buzz-compose-${DateTime.now().microsecondsSinceEpoch}-'
'${_retainedImageCounter++}$extension',
);
if (sourcePath.isNotEmpty && await File(sourcePath).exists()) {
await File(sourcePath).copy(destination.path);
} else {
await destination.writeAsBytes(await image.readAsBytes(), flush: true);
}
retained.add(
XFile(destination.path, name: image.name, mimeType: image.mimeType),
);
}
return retained;
} catch (_) {
for (final image in retained) {
try {
await File(image.path).delete();
} on FileSystemException {
// A failed copy may not have created every destination.
}
}
rethrow;
}
}
String _safeFileExtension(String path) {
final separator = path.lastIndexOf(Platform.pathSeparator);
final dot = path.lastIndexOf('.');
if (dot <= separator || dot == path.length - 1) return '.jpg';
final extension = path.substring(dot);
return RegExp(r'^\.[A-Za-z0-9]{1,10}$').hasMatch(extension)
? extension
: '.jpg';
}
+239
View File
@@ -0,0 +1,239 @@
import 'package:flutter/foundation.dart';
const Object _sentinel = Object();
/// Shown when a private-channel add is refused, so a missing Invite action
/// reads as a rule rather than a bug.
const privateChannelAddDeniedMessage =
'Only channel owners and admins can add people to a private channel.';
@immutable
class Channel {
final String id;
final String name;
final String channelType; // "stream", "forum", "dm"
final String visibility; // "open", "private"
final String description;
final String? topic;
final String? purpose;
final String createdBy;
final DateTime createdAt;
final int memberCount;
final DateTime? lastMessageAt;
final DateTime? archivedAt;
final List<String> participants;
final List<String> participantPubkeys;
final bool isMember;
final int? ttlSeconds;
final DateTime? ttlDeadline;
const Channel({
required this.id,
required this.name,
required this.channelType,
required this.visibility,
required this.description,
required this.createdBy,
required this.createdAt,
required this.memberCount,
this.topic,
this.purpose,
this.lastMessageAt,
this.archivedAt,
this.participants = const [],
this.participantPubkeys = const [],
this.isMember = false,
this.ttlSeconds,
this.ttlDeadline,
});
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
id: json['id'] as String,
name: json['name'] as String,
channelType: json['channel_type'] as String,
visibility: json['visibility'] as String,
description: (json['description'] as String?) ?? '',
topic: json['topic'] as String?,
purpose: json['purpose'] as String?,
createdBy: json['created_by'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
memberCount: json['member_count'] as int,
lastMessageAt: json['last_message_at'] != null
? DateTime.parse(json['last_message_at'] as String)
: null,
archivedAt: json['archived_at'] != null
? DateTime.parse(json['archived_at'] as String)
: null,
participants: (json['participants'] as List<dynamic>? ?? const [])
.cast<String>(),
participantPubkeys:
(json['participant_pubkeys'] as List<dynamic>? ?? const [])
.cast<String>(),
isMember: json['is_member'] as bool? ?? false,
ttlSeconds: json['ttl_seconds'] as int?,
ttlDeadline: json['ttl_deadline'] != null
? DateTime.parse(json['ttl_deadline'] as String)
: null,
);
bool get isEphemeral => ttlSeconds != null || ttlDeadline != null;
bool get isStream => channelType == 'stream';
bool get isForum => channelType == 'forum';
bool get isDm => channelType == 'dm';
bool get isPrivate => visibility == 'private';
/// Whether [selfRole] may add *another* identity here, mirroring the relay's
/// kind:9000 authority (`validate_admin_event` + `add_member`): DMs never,
/// open channels always, private channels owners/admins only. An unknown
/// visibility fails closed — the relay is the authority.
bool canAddMembers(String? selfRole) {
if (isDm) return false;
if (visibility == 'open') return true;
return selfRole == 'owner' || selfRole == 'admin';
}
bool get isArchived => archivedAt != null;
String displayLabel({String? currentPubkey}) {
if (!isDm || participants.isEmpty) {
return name;
}
final normalizedCurrent = currentPubkey?.toLowerCase();
final labels = <String>[];
for (var index = 0; index < participants.length; index++) {
final participantPubkey = index < participantPubkeys.length
? participantPubkeys[index].toLowerCase()
: null;
if (participantPubkey != null && participantPubkey == normalizedCurrent) {
continue;
}
labels.add(participants[index]);
}
if (labels.isEmpty) {
labels.addAll(participants);
}
return labels.join(', ');
}
Channel mergeDetails(ChannelDetails details) => Channel(
id: id,
name: details.name,
channelType: details.channelType,
visibility: details.visibility,
description: details.description,
topic: details.topic,
purpose: details.purpose,
createdBy: details.createdBy,
createdAt: details.createdAt,
memberCount: memberCount,
lastMessageAt: lastMessageAt,
archivedAt: details.archivedAt,
participants: participants,
participantPubkeys: participantPubkeys,
isMember: isMember,
ttlSeconds: details.ttlSeconds,
ttlDeadline: details.ttlDeadline,
);
Channel copyWith({
Object? lastMessageAt = _sentinel,
Object? archivedAt = _sentinel,
int? memberCount,
bool? isMember,
}) => Channel(
id: id,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
topic: topic,
purpose: purpose,
createdBy: createdBy,
createdAt: createdAt,
memberCount: memberCount ?? this.memberCount,
lastMessageAt: identical(lastMessageAt, _sentinel)
? this.lastMessageAt
: lastMessageAt as DateTime?,
archivedAt: identical(archivedAt, _sentinel)
? this.archivedAt
: archivedAt as DateTime?,
participants: participants,
participantPubkeys: participantPubkeys,
isMember: isMember ?? this.isMember,
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
);
}
@immutable
class ChannelDetails {
final String id;
final String name;
final String channelType;
final String visibility;
final String description;
final String? topic;
final String? purpose;
final String createdBy;
final DateTime createdAt;
final int memberCount;
final DateTime? archivedAt;
final int? ttlSeconds;
final DateTime? ttlDeadline;
const ChannelDetails({
required this.id,
required this.name,
required this.channelType,
required this.visibility,
required this.description,
required this.createdBy,
required this.createdAt,
required this.memberCount,
this.topic,
this.purpose,
this.archivedAt,
this.ttlSeconds,
this.ttlDeadline,
});
factory ChannelDetails.fromJson(Map<String, dynamic> json) => ChannelDetails(
id: json['id'] as String,
name: json['name'] as String,
channelType: json['channel_type'] as String,
visibility: json['visibility'] as String,
description: (json['description'] as String?) ?? '',
topic: json['topic'] as String?,
purpose: json['purpose'] as String?,
createdBy: json['created_by'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
memberCount: json['member_count'] as int,
archivedAt: json['archived_at'] != null
? DateTime.parse(json['archived_at'] as String)
: null,
ttlSeconds: json['ttl_seconds'] as int?,
ttlDeadline: json['ttl_deadline'] != null
? DateTime.parse(json['ttl_deadline'] as String)
: null,
);
factory ChannelDetails.fromChannel(Channel channel) => ChannelDetails(
id: channel.id,
name: channel.name,
channelType: channel.channelType,
visibility: channel.visibility,
description: channel.description,
topic: channel.topic,
purpose: channel.purpose,
createdBy: channel.createdBy,
createdAt: channel.createdAt,
memberCount: channel.memberCount,
archivedAt: channel.archivedAt,
ttlSeconds: channel.ttlSeconds,
ttlDeadline: channel.ttlDeadline,
);
}
@@ -0,0 +1,593 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/sheet_divider.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
import 'channel_mutes/channel_mutes_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_stars/channel_stars_provider.dart';
import 'channels_provider.dart';
import 'manage_channel_sheet.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/read_state/read_state_time.dart';
/// Opens the mobile channel actions sheet and returns whether its parent page
/// should close after a successful lifecycle action.
Future<bool?> showChannelActionsSheet({
required BuildContext context,
required Channel channel,
required bool isUnread,
VoidCallback? onMarkRead,
String? sectionId,
}) => showBuzzModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
builder: (_) => ChannelActionsSheet(
channel: channel,
isUnread: isUnread,
onMarkRead: onMarkRead,
sectionId: sectionId,
),
);
/// Mobile action sheet for channel-level read, organization, and lifecycle
/// operations.
class ChannelActionsSheet extends ConsumerWidget {
const ChannelActionsSheet({
super.key,
required this.channel,
required this.isUnread,
this.onMarkRead,
this.sectionId,
});
final Channel channel;
final bool isUnread;
final VoidCallback? onMarkRead;
final String? sectionId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isMuted =
ref.watch(channelMutesProvider).store.channels[channel.id]?.muted ==
true;
final isStarred =
!channel.isDm &&
ref.watch(channelStarsProvider).store.channels[channel.id]?.starred ==
true;
final membersAsync = channel.isDm
? const AsyncValue<List<ChannelMember>>.data([])
: ref.watch(channelMembersProvider(channel.id));
final agentOwnersAsync = channel.isDm
? const AsyncValue<Map<String, String>>.data({})
: ref.watch(agentOwnersProvider);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final currentMember = membersAsync.value?.cast<ChannelMember?>().firstWhere(
(member) => member?.pubkey.toLowerCase() == currentPubkey,
orElse: () => null,
);
final ownsOwnerAgent =
currentPubkey != null &&
membersAsync.value?.any(
(member) =>
member.isOwner &&
agentOwnersAsync.value?[member.pubkey.toLowerCase()]
?.toLowerCase() ==
currentPubkey,
) ==
true;
final canManageLifecycle =
currentMember?.isElevated == true || ownsOwnerAgent;
final canArchive = !channel.isArchived && canManageLifecycle;
final canUnarchive = channel.isArchived && canManageLifecycle;
final canDelete =
!channel.isArchived &&
(currentMember?.isOwner == true || ownsOwnerAgent);
final lifecycleCapabilitiesLoading =
membersAsync.isLoading || agentOwnersAsync.isLoading;
final lifecycleCapabilitiesUnavailable =
membersAsync.hasError || agentOwnersAsync.hasError;
void close() => Navigator.of(context).pop();
return SafeArea(
top: false,
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!channel.isDm) ...[
_ChannelQuickActionsRow(
isStarred: isStarred,
isUnread: isUnread,
onToggleStar: () {
close();
final notifier = ref.read(channelStarsProvider.notifier);
isStarred
? notifier.unstarChannel(channel.id)
: notifier.starChannel(channel.id);
},
onToggleRead: () {
close();
final timestamp = dateTimeToUnixSeconds(
channel.lastMessageAt,
);
if (isUnread) {
onMarkRead?.call();
if (timestamp != null) {
ref
.read(readStateProvider.notifier)
.markContextRead(
channel.id,
timestamp,
clearForcedMessages: true,
);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(
channel.id,
timestamp,
);
}
} else {
ref
.read(readStateProvider.notifier)
.markContextUnread(channel.id, channelId: channel.id);
}
},
),
const SizedBox(height: Grid.xs),
],
if (!channel.isDm)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.folderInput),
title: Text(context.l10n.moveToSection),
onTap: () async {
final pageContext = Navigator.of(
context,
rootNavigator: true,
).context;
close();
await _showMoveSectionSheet(
pageContext,
ref,
channel: channel,
sectionId: sectionId,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(isMuted ? LucideIcons.bell : LucideIcons.bellOff),
title: Text(
isMuted
? context.l10n.unmuteChannel
: context.l10n.muteChannel,
),
onTap: () {
close();
final notifier = ref.read(channelMutesProvider.notifier);
isMuted
? notifier.unmuteChannel(channel.id)
: notifier.muteChannel(channel.id);
},
),
if (!channel.isDm)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.settings),
title: Text(context.l10n.manageChannel),
onTap: () async {
final shouldClose = await showBuzzModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
),
builder: (_) => ManageChannelSheet(channel: channel),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.copy),
title: Text(context.l10n.copyChannelName),
onTap: () {
close();
copyToClipboard(
context,
channel.name,
message: context.l10n.channelNameCopied,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.hash),
title: Text(context.l10n.copyChannelId),
onTap: () {
close();
copyToClipboard(
context,
channel.id,
message: context.l10n.channelIdCopied,
);
},
),
if (!channel.isDm) ...[
const SheetDivider(),
if (channel.isMember && !channel.isArchived)
_ActionTile(
icon: LucideIcons.logOut,
label: context.l10n.leaveChannelLabel,
destructive: true,
onTap: () => _confirmAndRun(
context,
ref,
title: context.l10n.leaveChannel(channel.name),
body: context.l10n.leaveChannelBody,
confirmLabel: context.l10n.leave,
action: () => ref
.read(channelActionsProvider)
.leaveChannel(channel.id),
),
),
if (lifecycleCapabilitiesLoading)
ListTile(
enabled: false,
leading: BuzzLoadingIndicator(
size: 20,
semanticLabel: context.l10n.loadingChannelActions,
),
title: Text(context.l10n.loadingChannelActions),
)
else if (lifecycleCapabilitiesUnavailable)
ListTile(
enabled: false,
leading: Icon(LucideIcons.triangleAlert),
title: Text(context.l10n.channelActionsUnavailable),
)
else ...[
if (canArchive)
_ActionTile(
icon: LucideIcons.archive,
label: context.l10n.archiveChannelLabel,
onTap: () => _confirmAndRun(
context,
ref,
title: context.l10n.archiveChannel(channel.name),
body: context.l10n.archiveChannelBody,
confirmLabel: context.l10n.archive,
action: () => ref
.read(channelActionsProvider)
.archiveChannel(channel.id),
),
),
if (canUnarchive)
_ActionTile(
icon: LucideIcons.archiveRestore,
label: context.l10n.unarchiveChannelLabel,
onTap: () => _confirmAndRun(
context,
ref,
title: context.l10n.unarchiveChannel(channel.name),
body: context.l10n.unarchiveChannelBody,
confirmLabel: context.l10n.unarchive,
action: () => ref
.read(channelActionsProvider)
.unarchiveChannel(channel.id),
),
),
if (canDelete)
_ActionTile(
icon: LucideIcons.trash2,
label: context.l10n.deleteChannelLabel,
destructive: true,
onTap: () => _confirmAndRun(
context,
ref,
title: context.l10n.deleteChannel(channel.name),
body: context.l10n.deleteChannelBody,
confirmLabel: context.l10n.delete,
action: () => ref
.read(channelActionsProvider)
.deleteChannel(channel.id),
),
),
],
],
],
),
),
),
);
}
}
class _ChannelQuickActionsRow extends StatelessWidget {
const _ChannelQuickActionsRow({
required this.isStarred,
required this.isUnread,
required this.onToggleStar,
required this.onToggleRead,
});
final bool isStarred;
final bool isUnread;
final VoidCallback onToggleStar;
final VoidCallback onToggleRead;
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: _ChannelQuickAction(
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
label: isStarred ? context.l10n.unstar : context.l10n.star,
onTap: onToggleStar,
),
),
const SizedBox(width: Grid.twelve),
Expanded(
child: _ChannelQuickAction(
icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
label: isUnread ? context.l10n.markRead : context.l10n.markUnread,
onTap: onToggleRead,
),
),
],
);
}
class _ChannelQuickAction extends StatelessWidget {
const _ChannelQuickAction({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
],
),
);
}
class _ActionTile extends StatelessWidget {
const _ActionTile({
required this.icon,
required this.label,
required this.onTap,
this.destructive = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool destructive;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: destructive ? context.colors.error : null),
title: Text(
label,
style: destructive ? TextStyle(color: context.colors.error) : null,
),
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
);
}
Future<void> _confirmAndRun(
BuildContext sheetContext,
WidgetRef ref, {
required String title,
required String body,
required String confirmLabel,
required Future<void> Function() action,
}) async {
final pageContext = Navigator.of(sheetContext, rootNavigator: true).context;
final confirmed = await showBuzzDialog<bool>(
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(dialogContext.l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(confirmLabel),
),
],
),
);
if (confirmed != true) return;
try {
await action();
if (pageContext.mounted) Navigator.of(pageContext).pop(true);
} catch (error) {
if (!pageContext.mounted) return;
ScaffoldMessenger.of(pageContext).showSnackBar(
SnackBar(
content: Text(
pageContext.l10n.actionFailed(confirmLabel),
),
),
);
}
}
Future<void> _showMoveSectionSheet(
BuildContext context,
WidgetRef ref, {
required Channel channel,
required String? sectionId,
}) async {
final sections = [...ref.read(channelSectionsProvider).store.sections]
..sort((a, b) => a.order.compareTo(b.order));
await showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final section in sections)
ListTile(
leading: const Icon(LucideIcons.folder),
title: Text(section.name),
trailing: sectionId == section.id
? Icon(
LucideIcons.check,
color: sheetContext.colors.primary,
)
: null,
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.assignChannel(channel.id, section.id);
},
),
ListTile(
leading: const Icon(LucideIcons.folderPlus),
title: Text(context.l10n.newSection),
onTap: () async {
Navigator.of(sheetContext).pop();
final name = await _showSectionNameDialog(context);
if (name == null || name.isEmpty) return;
final notifier = ref.read(channelSectionsProvider.notifier);
notifier.createSection(name);
final created = ref
.read(channelSectionsProvider)
.store
.sections
.where((section) => section.name == name.trim())
.lastOrNull;
if (created != null) {
notifier.assignChannel(channel.id, created.id);
}
},
),
if (sectionId != null)
ListTile(
leading: const Icon(LucideIcons.folderMinus),
title: Text(context.l10n.removeFromSection),
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.unassignChannel(channel.id);
},
),
],
),
),
),
),
);
}
Future<String?> _showSectionNameDialog(BuildContext context) async {
final controller = TextEditingController();
final result = await showBuzzDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.newSectionTitle),
content: TextField(controller: controller, autofocus: true),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () =>
Navigator.of(dialogContext).pop(controller.text.trim()),
child: Text(context.l10n.create),
),
],
),
);
controller.dispose();
return result;
}
@@ -0,0 +1,539 @@
import 'dart:async';
import 'dart:math' show min;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/skeleton.dart';
import '../profile/presence_cache_provider.dart';
import '../profile/profile_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../forum/forum_posts_view.dart';
import 'channel.dart';
import 'channel_actions_sheet.dart';
import 'channel_link_navigation.dart';
import 'agent_activity/working_bots_provider.dart';
import 'channel_management_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'channel_typing_indicator.dart';
import 'channels_provider.dart';
import 'unread_badge/observed_unread_event.dart';
import 'compose_bar.dart';
import 'composer_dock_size_reporter.dart';
import 'date_formatters.dart';
import 'day_divider.dart';
import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_long_press_region.dart';
import 'message_content.dart';
import '../../shared/read_state/deferred_read_state_update.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/read_state/read_state_time.dart';
import 'reaction_row.dart';
import 'send_message_provider.dart';
import '../profile/user_profile_sheet.dart';
import 'small_avatar.dart';
import 'thread_detail_page.dart';
import 'timeline_message.dart';
part 'channel_detail_page/message_list.dart';
part 'channel_detail_page/system_rows.dart';
part 'channel_detail_page/message_bubble.dart';
part 'channel_detail_page/banners.dart';
part 'channel_detail_page/app_bar.dart';
/// Fetch deep-link targets that may be outside the loaded channel window.
Future<void> _loadDeepLinkEvents(
WidgetRef ref,
String channelId,
Set<String> eventIds,
) async {
try {
await ref
.read(channelMessagesProvider(channelId).notifier)
.loadEventsById(eventIds);
} catch (error) {
debugPrint('deep-link: failed to load target messages: $error');
}
}
/// Fetch channel members and preload their profiles into the user cache.
Future<void> _preloadMembers(WidgetRef ref, String channelId) async {
// Capture references before async gap to avoid using disposed ref.
final notifier = ref.read(userCacheProvider.notifier);
try {
final members = await ref.read(channelMembersProvider(channelId).future);
final pubkeys = members.map((m) => m.pubkey).toList();
if (pubkeys.isNotEmpty) {
notifier.preload(pubkeys);
}
} catch (_) {
// Non-fatal — mentions will just fall back to cache from messages.
}
}
int? _channelReadTimestamp({
required Channel channel,
required AsyncValue<List<NostrEvent>> messagesState,
}) {
if (channel.isForum) {
return dateTimeToUnixSeconds(channel.lastMessageAt);
}
final events = messagesState.value;
if (events != null && events.isNotEmpty) {
var latest = 0;
for (final event in events) {
if (event.threadReference.parentId != null) continue;
if (event.createdAt > latest) {
latest = event.createdAt;
}
}
if (latest > 0) {
return latest;
}
}
return dateTimeToUnixSeconds(channel.lastMessageAt);
}
class ChannelDetailPage extends HookConsumerWidget {
final Channel channel;
final String? initialMessageId;
final String? initialThreadRootId;
const ChannelDetailPage({
super.key,
required this.channel,
this.initialMessageId,
this.initialThreadRootId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final composerDockHeight = useState(0.0);
final sendMessage = ref.read(sendMessageProvider);
final detailsAsync = ref.watch(channelDetailsProvider(channel.id));
final channelsAsync = ref.watch(channelsProvider);
final messagesState = ref.watch(channelMessagesProvider(channel.id));
final sessionStatus = ref.watch(relaySessionProvider).status;
final readState = ref.watch(readStateProvider);
final channelsNotifier = ref.read(channelsProvider.notifier);
final initialOrdinaryUnreadMessageIdsRef = useRef<Set<String>>(const {});
final initialOldestOrdinaryUnreadMessageIdRef = useRef<String?>(null);
final initialForcedUnreadMessageIdsRef = useRef<Set<String>>(const {});
final didCaptureInitialReadAt = useRef(false);
if (readState.isReady && !didCaptureInitialReadAt.value) {
final channelReadAt = readState.effectiveTimestamp(channel.id);
final ordinaryUnreadEvents = [
for (final event
in channelsNotifier
.observedUnreadEventsByChannel[channel.id]
?.values ??
const <ObservedUnreadEvent>[])
if (event.rootId == null &&
event.createdAt >
(observedUnreadEventReadAt(
event,
channelReadAt,
(rootId) => readState.effectiveTimestamp(
threadContextKey(rootId),
),
(messageId) => readState.effectiveTimestamp(
msgContextKey(messageId),
),
) ??
0))
event,
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
initialOrdinaryUnreadMessageIdsRef.value = {
for (final event in ordinaryUnreadEvents) event.id,
};
initialOldestOrdinaryUnreadMessageIdRef.value =
ordinaryUnreadEvents.firstOrNull?.id;
initialForcedUnreadMessageIdsRef.value = {
for (final entry in readState.forcedUnreadContexts.entries)
if (entry.value == channel.id && entry.key.startsWith('msg:'))
entry.key.substring('msg:'.length),
};
didCaptureInitialReadAt.value = true;
}
final initialOrdinaryUnreadMessageIds =
initialOrdinaryUnreadMessageIdsRef.value;
final initialOldestOrdinaryUnreadMessageId =
initialOldestOrdinaryUnreadMessageIdRef.value;
final initialForcedUnreadMessageIds =
initialForcedUnreadMessageIdsRef.value;
final currentPubkey = ref
.watch(profileProvider)
.whenData((value) => value?.pubkey)
.value;
// Only show channel-level typing (exclude thread-scoped entries and self).
final typingEntries = ref
.watch(channelTypingProvider(channel.id))
.where((e) => e.threadHeadId == null)
.where(
(e) =>
currentPubkey == null ||
e.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
)
.toList();
final baseChannel =
channelsAsync
.whenData(
(channels) => channels.firstWhere(
(candidate) => candidate.id == channel.id,
orElse: () => channel,
),
)
.value ??
channel;
final resolvedChannel =
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
final showsComposer =
!resolvedChannel.isForum &&
resolvedChannel.isMember &&
!resolvedChannel.isArchived;
final messagesNotifier = ref.read(
channelMessagesProvider(channel.id).notifier,
);
final isConnectionInProgress =
sessionStatus == SessionStatus.connecting ||
sessionStatus == SessionStatus.reconnecting;
final showConnectionSkeleton = useState(false);
final shouldDebounceConnectionSkeleton =
isConnectionInProgress &&
(resolvedChannel.isForum || messagesNotifier.hasLoadedMessages);
useEffect(() {
if (!shouldDebounceConnectionSkeleton) {
showConnectionSkeleton.value = false;
return null;
}
final timer = Timer(const Duration(seconds: 2), () {
showConnectionSkeleton.value = true;
});
return timer.cancel;
}, [shouldDebounceConnectionSkeleton]);
final showInitialConnectionSkeleton =
!resolvedChannel.isForum &&
isConnectionInProgress &&
!messagesNotifier.hasLoadedMessages;
final appBarTitleContentHeight = resolvedChannel.isDm
? _dmAppBarTitleContentHeight(context)
: 0.0;
final readTimestamp = _channelReadTimestamp(
channel: resolvedChannel,
messagesState: messagesState,
);
useEffect(() {
final session = ref.read(relaySessionProvider.notifier);
return session.registerVisibleChannel(channel.id);
}, [channel.id]);
// Preload channel member profiles so @mentions resolve correctly.
useEffect(() {
_preloadMembers(ref, channel.id);
return null;
}, [channel.id]);
useEffect(
() {
if (channel.isForum) return null;
final eventIds = {
?initialMessageId,
?initialThreadRootId,
?initialOldestOrdinaryUnreadMessageId,
...initialForcedUnreadMessageIds,
};
if (eventIds.isEmpty) return null;
final notifier = ref.read(channelMessagesProvider(channel.id).notifier);
unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds));
return () => notifier.releaseDeepLinkEvents(eventIds);
},
[
channel.id,
initialMessageId,
initialThreadRootId,
initialOldestOrdinaryUnreadMessageId,
initialForcedUnreadMessageIds,
],
);
useEffect(() {
if (!readState.isReady || readTimestamp == null) {
return null;
}
return deferReadStateUpdate(context, () {
ref
.read(readStateProvider.notifier)
.markContextRead(channel.id, readTimestamp);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(channel.id, readTimestamp);
});
}, [channel.id, readState.isReady, readTimestamp]);
return FrostedScaffold(
appBar: FrostedAppBar(
iconColor: context.colors.primary,
titleContentHeight: appBarTitleContentHeight,
titleStyle: channelTitleTextStyle,
title: resolvedChannel.isDm
? _DmAppBarTitle(
channel: resolvedChannel,
currentPubkey: currentPubkey,
)
: Row(
children: [
SizedBox.square(
dimension: 22,
child: Center(
child: Icon(channelIcon(resolvedChannel), size: 18),
),
),
const SizedBox(width: Grid.half),
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
resolvedChannel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (resolvedChannel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: resolvedChannel),
],
],
),
),
],
),
actions: [
_MembersButton(
channelId: resolvedChannel.id,
channel: resolvedChannel,
currentPubkey: currentPubkey,
),
IconButton(
color: context.colors.primary,
onPressed: () async {
final shouldClose = await showChannelActionsSheet(
context: context,
channel: resolvedChannel,
isUnread: false,
sectionId: ref
.read(channelSectionsProvider)
.store
.assignments[resolvedChannel.id],
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop();
}
},
tooltip: context.l10n.channelActions,
icon: const Icon(LucideIcons.ellipsisVertical, size: 22),
),
],
),
body: Stack(
fit: StackFit.expand,
children: [
Column(
children: [
Expanded(
child: resolvedChannel.isForum
? Stack(
fit: StackFit.expand,
children: [
ForumPostsView(
channel: resolvedChannel,
currentPubkey: currentPubkey,
),
if (showConnectionSkeleton.value)
Positioned(
top:
frostedAppBarHeight(
context,
titleContentHeight:
appBarTitleContentHeight,
) +
Grid.xs,
left: Grid.gutter,
right: Grid.gutter,
child: _ForumConnectionSkeleton(
status: sessionStatus,
),
),
],
)
: SkeletonReveal(
loading:
showInitialConnectionSkeleton ||
showConnectionSkeleton.value ||
messagesState.isLoading,
shimmerEnabled:
sessionStatus != SessionStatus.disconnected,
skeleton: _MessageTimelineSkeleton(
appBarTitleContentHeight: appBarTitleContentHeight,
status: sessionStatus,
),
content: messagesState.when(
loading: SizedBox.shrink,
error: (e, _) => Padding(
padding: EdgeInsets.only(
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
),
),
child: Center(
child: Text(
context.l10n.failedLoadMessages,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.error,
),
),
),
),
data: (events) {
final messages = formatTimeline(
events,
currentPubkey: currentPubkey,
);
final summaries = ref
.read(
channelMessagesProvider(channel.id).notifier,
)
.threadSummaries;
final entries = buildMainTimelineEntries(
messages,
relaySummaries: summaries,
);
return _MessageList(
entries: entries,
allMessages: messages,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
initialOrdinaryUnreadMessageIds:
initialOrdinaryUnreadMessageIds,
initialOldestOrdinaryUnreadMessageId:
initialOldestOrdinaryUnreadMessageId,
initialForcedUnreadMessageIds:
initialForcedUnreadMessageIds,
hasInitialUnread:
readState.isReady &&
(readState.isForcedUnread(channel.id) ||
initialForcedUnreadMessageIds
.isNotEmpty ||
initialOldestOrdinaryUnreadMessageId !=
null),
channelId: channel.id,
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
isArchived: resolvedChannel.isArchived,
appBarTitleContentHeight:
appBarTitleContentHeight,
composerBottomInset: showsComposer
? composerDockHeight.value
: 0,
);
},
),
),
),
if (!resolvedChannel.isForum &&
(!resolvedChannel.isMember ||
resolvedChannel.isArchived)) ...[
AnimatedSize(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: typingEntries.isEmpty
? const SizedBox.shrink()
: ChannelTypingIndicator(entries: typingEntries),
),
if (!resolvedChannel.isDm)
_ReadOnlyNotice(channel: resolvedChannel),
],
],
),
if (showsComposer)
Align(
alignment: Alignment.bottomCenter,
child: ComposerDockSizeReporter(
key: const ValueKey('channel-composer-dock'),
onHeightChanged: (height) {
if ((composerDockHeight.value - height).abs() < 0.5) return;
composerDockHeight.value = height;
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSize(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: typingEntries.isEmpty
? const SizedBox.shrink()
: ChannelTypingIndicator(entries: typingEntries),
),
ComposeBar(
channelId: channel.id,
channelName: resolvedChannel.isDm
? ''
: resolvedChannel.name,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) => sendMessage.call(
channelId: channel.id,
content: content,
mentionPubkeys: mentionPubkeys,
mediaTags: mediaTags,
),
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,200 @@
part of '../channel_detail_page.dart';
double _scaledTextHeight(BuildContext context, TextStyle style) {
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 0);
return scaledFontSize * (style.height ?? 1);
}
double _dmAppBarTitleContentHeight(BuildContext context) {
const titleStyle = channelTitleTextStyle;
final presenceStyle = context.textTheme.bodySmall;
if (presenceStyle == null) {
return 30;
}
final textHeight =
_scaledTextHeight(context, titleStyle) +
_scaledTextHeight(context, presenceStyle);
return textHeight > 30 ? textHeight : 30;
}
class _MembersButton extends ConsumerWidget {
final String channelId;
final Channel channel;
final String? currentPubkey;
const _MembersButton({
required this.channelId,
required this.channel,
required this.currentPubkey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasWorkingBot = ref
.watch(workingBotPubkeysProvider(channelId))
.isNotEmpty;
return IconButton(
color: context.colors.primary,
onPressed: () {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
MembersSheet(channel: channel, currentPubkey: currentPubkey),
);
},
tooltip: context.l10n.viewMembers,
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(LucideIcons.users, size: 22),
if (hasWorkingBot)
Positioned(
top: -2,
right: -2,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: context.appColors.success,
shape: BoxShape.circle,
border: Border.all(color: context.colors.surface, width: 1.5),
),
),
),
],
),
);
}
}
class _DmAppBarTitle extends ConsumerWidget {
final Channel channel;
final String? currentPubkey;
const _DmAppBarTitle({required this.channel, required this.currentPubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profiles = ref.watch(userCacheProvider);
final presenceMap = ref.watch(presenceCacheProvider);
final normalizedCurrent = currentPubkey?.toLowerCase();
String? otherPubkey;
for (final pk in channel.participantPubkeys) {
if (pk.toLowerCase() != normalizedCurrent) {
otherPubkey = pk.toLowerCase();
break;
}
}
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
if (otherPubkey != null) {
if (profile == null) {
ref.read(userCacheProvider.notifier).preload([otherPubkey]);
}
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
}
final avatarUrl = profile?.avatarUrl;
final initial =
profile?.initial ??
(channel.participants.isNotEmpty
? channel.participants.first[0].toUpperCase()
: '?');
final presence = otherPubkey != null
? (presenceMap[otherPubkey] ?? 'offline')
: 'offline';
final presenceLabel = switch (presence) {
'online' => context.l10n.online,
'away' => context.l10n.away,
_ => context.l10n.offline,
};
return Row(
children: [
SizedBox(
width: 30,
height: 30,
child: Stack(
clipBehavior: Clip.none,
children: [
AvatarImage(
imageUrl: avatarUrl,
radius: 14,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
},
shape: BoxShape.circle,
border: Border.all(
color: context.colors.surface,
width: 1.5,
),
),
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: channelTitleTextStyle,
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: channel),
],
],
),
Text(
presenceLabel,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
],
);
}
}
@@ -0,0 +1,220 @@
part of '../channel_detail_page.dart';
class _ReadOnlyNotice extends StatelessWidget {
final Channel channel;
const _ReadOnlyNotice({required this.channel});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: Grid.xxs,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: context.colors.outlineVariant)),
color: context.colors.surface,
),
child: Text(
channel.isArchived
? context.l10n.archivedReadOnly(
channel.isForum ? context.l10n.forum : context.l10n.channel,
)
: context.l10n.joinFromManage(
channel.isForum ? context.l10n.forum : context.l10n.channel,
),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
);
}
}
class _HeaderEphemeralBadge extends StatelessWidget {
final Channel channel;
const _HeaderEphemeralBadge({required this.channel});
@override
Widget build(BuildContext context) {
final display = ephemeralChannelDisplay(channel, l10n: context.l10n);
if (display == null) return const SizedBox.shrink();
return Tooltip(
message: display.tooltipLabel,
child: Icon(
LucideIcons.clockFading,
key: const Key('chat-ephemeral-badge'),
size: 16,
color: context.colors.onSurfaceVariant,
),
);
}
}
class _MessageTimelineSkeleton extends StatelessWidget {
final double appBarTitleContentHeight;
final SessionStatus status;
const _MessageTimelineSkeleton({
required this.appBarTitleContentHeight,
required this.status,
});
@override
Widget build(BuildContext context) {
final semanticsLabel = switch (status) {
SessionStatus.connecting => context.l10n.connecting,
SessionStatus.reconnecting => context.l10n.reconnecting,
SessionStatus.connected || SessionStatus.disconnected =>
context.l10n.loading,
};
return Semantics(
key: const Key('channel-detail-connection-skeleton'),
liveRegion: true,
label: semanticsLabel,
child: ExcludeSemantics(
child: ListView.separated(
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
Grid.gutter,
frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
) +
Grid.xs,
Grid.gutter,
Grid.xs,
),
itemCount: 4,
separatorBuilder: (_, _) => const SizedBox(height: Grid.xs),
itemBuilder: (_, index) => _MessageSkeletonRow(index: index),
),
),
);
}
}
class _MessageSkeletonRow extends StatelessWidget {
final int index;
const _MessageSkeletonRow({required this.index});
@override
Widget build(BuildContext context) {
const authorWidths = <double>[112, 96, 128, 80];
const lineWidths = <List<double>>[
[280, 224],
[272, 184],
[232],
[288, 216],
];
final availableWidth = MediaQuery.sizeOf(context).width - 88;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBar(
width: 36,
height: 36,
borderRadius: BorderRadius.circular(Radii.full),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SkeletonBar(width: authorWidths[index], height: 15),
const SizedBox(width: Grid.xxs),
const SkeletonBar(width: 40, height: 12),
],
),
const SizedBox(height: Grid.half),
for (final width in lineWidths[index]) ...[
SkeletonBar(width: min(width, availableWidth), height: 16),
const SizedBox(height: Grid.half),
],
const Row(
children: [
SkeletonBar(width: 32, height: 16),
SizedBox(width: Grid.xs),
SkeletonBar(width: 32, height: 16),
SizedBox(width: Grid.xs),
SkeletonBar(width: 32, height: 16),
],
),
],
),
),
],
);
}
}
class _ForumConnectionSkeleton extends StatelessWidget {
final SessionStatus status;
const _ForumConnectionSkeleton({required this.status});
@override
Widget build(BuildContext context) {
final semanticsLabel = switch (status) {
SessionStatus.connecting => context.l10n.connecting,
SessionStatus.reconnecting => context.l10n.reconnecting,
SessionStatus.connected || SessionStatus.disconnected =>
context.l10n.loading,
};
return Semantics(
key: const Key('forum-connection-skeleton'),
liveRegion: true,
label: semanticsLabel,
child: ExcludeSemantics(
child: IgnorePointer(
child: ExcludeFocus(
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(color: context.colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(Grid.twelve),
child: SkeletonShimmer(
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
SkeletonBar(
width: 28,
height: 28,
borderRadius: BorderRadius.all(
Radius.circular(Radii.full),
),
),
SizedBox(width: Grid.xxs),
SkeletonBar(width: 112, height: 14),
],
),
SizedBox(height: Grid.xxs),
SkeletonBar(width: 240, height: 14),
],
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,320 @@
part of '../channel_detail_page.dart';
class _MessageBubble extends ConsumerWidget {
final TimelineMessage message;
final bool showAuthor;
final Map<String, String> channelNames;
final String currentChannelId;
final String? currentPubkey;
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
const _MessageBubble({
required this.message,
required this.showAuthor,
required this.channelNames,
required this.currentChannelId,
required this.currentPubkey,
this.allMessages,
this.isMember = false,
this.isArchived = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
final pk = message.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? shortPubkey(message.pubkey);
final canManageMessage =
currentPubkey?.toLowerCase() == pk ||
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase());
// Build mention names map from event p-tags.
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(
agentMentionPubkeysProvider(currentChannelId),
),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = <String, String>{};
final agentMentionPubkeys = <String>{};
for (final mpk in message.mentionPubkeys) {
final normalizedPubkey = mpk.toLowerCase();
final p = userCache[normalizedPubkey];
if (p?.displayName != null) {
mentionNames[normalizedPubkey] = p!.displayName!;
}
if (knownAgentPubkeys.contains(normalizedPubkey)) {
agentMentionPubkeys.add(normalizedPubkey);
}
}
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: message.mentionPubkeys,
profileMentionNames: mentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
void openMessageActions(Rect anchorRect) {
showMessageActions(
context: context,
ref: ref,
message: message,
channelId: currentChannelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
);
}
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
// The media carousel intentionally continues through the list's trailing
// gutter. InkWell still clips its ink to [borderRadius], while leaving
// overflowing message content visible.
clipBehavior: Clip.none,
child: MessageLongPressInkWell(
key: ValueKey('message-row-${message.id}'),
onLongPress: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
// Tap opens the thread; long-press still opens the action sheet.
// MessageContent handles mention, channel-link, and media taps.
onTap: allMessages == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
),
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () => showUserProfileSheet(context, message.pubkey),
child: _UserAvatar(
profile: profile,
pubkey: message.pubkey,
),
)
else
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.quarter,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: displayName,
username: messageUsernameLabel(profile),
timestamp: formatMessageTime(
message.createdAt,
l10n: context.l10n,
),
nameColor: context.colors.onSurface,
metadataColor:
context.colors.onSurfaceVariant,
onAuthorTap: () => showUserProfileSheet(
context,
message.pubkey,
),
displayNameKey: ValueKey(
'message-author-${message.id}',
),
usernameKey: ValueKey(
'message-username-${message.id}',
),
timestampKey: ValueKey(
'message-timestamp-${message.id}',
),
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
context.l10n.edited,
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
MessageContent(
content: message.content,
mentionNames: resolvedMentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: currentChannelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
),
onChannelTap: (channelId) {
openChannelLink(
context: context,
ref: ref,
channelId: channelId,
currentChannelId: currentChannelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
if (message.reactions.isNotEmpty)
ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
],
),
),
),
],
),
),
),
),
);
}
}
Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: Grid.xxl),
child: Text(
key: key,
formatMessageTime(createdAt, l10n: context.l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: messageTimestampTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
);
}
class _UserAvatar extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
final double size;
const _UserAvatar({
required this.profile,
required this.pubkey,
this.size = messageAvatarSize,
});
@override
Widget build(BuildContext context) {
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
final avatarUrl = profile?.avatarUrl;
return AvatarImage(
imageUrl: avatarUrl,
radius: size / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style:
(size > 28
? context.textTheme.labelMedium
: context.textTheme.labelSmall)
?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}
IconData channelIcon(Channel channel) {
if (channel.isDm) return LucideIcons.messagesSquare;
if (channel.isPrivate) return LucideIcons.lock;
if (channel.isForum) return LucideIcons.messageSquareText;
return LucideIcons.hash;
}
@@ -0,0 +1,637 @@
part of '../channel_detail_page.dart';
class _MessageList extends HookConsumerWidget {
final List<MainTimelineEntry> entries;
final List<TimelineMessage> allMessages;
final String? initialMessageId;
final String? initialThreadRootId;
final Set<String> initialOrdinaryUnreadMessageIds;
final String? initialOldestOrdinaryUnreadMessageId;
final Set<String> initialForcedUnreadMessageIds;
final bool hasInitialUnread;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
final double appBarTitleContentHeight;
final double composerBottomInset;
const _MessageList({
required this.entries,
required this.allMessages,
required this.initialMessageId,
required this.initialThreadRootId,
required this.initialOrdinaryUnreadMessageIds,
required this.initialOldestOrdinaryUnreadMessageId,
required this.initialForcedUnreadMessageIds,
required this.hasInitialUnread,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
required this.appBarTitleContentHeight,
required this.composerBottomInset,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayEntries = groupMembershipTimelineEntries(entries);
final itemScrollController = useMemoized(ItemScrollController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
final hasUserScrolled = useState(false);
final followsLatest = useRef(
initialMessageId == null && initialThreadRootId == null,
);
final isAutoScrolling = useRef(false);
final latestRealignmentQueued = useRef(false);
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
final previousLatestEntryId = useRef<String?>(null);
final didOpenInitialThread = useRef(false);
final didJumpToInitialMessage = useRef(false);
final isUnreadNavigationDismissed = useState(false);
final detachedWhileUnreadShown = useRef(false);
final oldestUnreadMessageId = useState<String?>(null);
final unreadBoundaryLoadFailed = useState(false);
final unreadBoundaryFetchCount = useRef(0);
final hasUnreadDeepLink =
initialMessageId != null || initialThreadRootId != null;
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
useEffect(
() {
if (!hasInitialUnread ||
hasUnreadDeepLink ||
oldestUnreadMessageId.value != null ||
unreadBoundaryLoadFailed.value ||
entries.isEmpty) {
return null;
}
final hasLoadedOrdinaryTarget =
initialOldestOrdinaryUnreadMessageId != null &&
entries.any(
(entry) =>
entry.message.id == initialOldestOrdinaryUnreadMessageId,
);
final hasLoadedForcedTarget = entries.any(
(entry) => initialForcedUnreadMessageIds.contains(entry.message.id),
);
final hasKnownTarget =
initialOldestOrdinaryUnreadMessageId != null ||
initialForcedUnreadMessageIds.isNotEmpty;
final hasLoadedFetchTarget =
initialOldestOrdinaryUnreadMessageId != null
? hasLoadedOrdinaryTarget
: hasLoadedForcedTarget;
final canFetchTarget =
hasKnownTarget &&
!hasLoadedFetchTarget &&
!notifier.reachedOldest &&
unreadBoundaryFetchCount.value < 4;
if (canFetchTarget) {
unreadBoundaryFetchCount.value += 1;
var cancelled = false;
unawaited(
Future<void>(() async {
final loaded = await notifier.fetchOlder();
if (!cancelled && !loaded && !notifier.reachedOldest) {
unreadBoundaryLoadFailed.value = true;
}
}),
);
return () => cancelled = true;
}
if (hasKnownTarget &&
!hasLoadedFetchTarget &&
!notifier.reachedOldest) {
unreadBoundaryLoadFailed.value = true;
}
final ordinaryUnread = entries
.where(
(entry) =>
initialOrdinaryUnreadMessageIds.contains(entry.message.id),
)
.map((entry) => entry.message)
.firstOrNull;
final forcedUnread = entries
.where(
(entry) =>
initialForcedUnreadMessageIds.contains(entry.message.id),
)
.map((entry) => entry.message)
.firstOrNull;
final candidates = [ordinaryUnread, forcedUnread].nonNulls.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
oldestUnreadMessageId.value = candidates.firstOrNull?.id;
return null;
},
[
hasInitialUnread,
hasUnreadDeepLink,
initialOrdinaryUnreadMessageIds,
initialOldestOrdinaryUnreadMessageId,
initialForcedUnreadMessageIds,
entries.length,
notifier.reachedOldest,
unreadBoundaryLoadFailed.value,
],
);
final showUnreadNavigation =
!isUnreadNavigationDismissed.value &&
oldestUnreadMessageId.value != null;
int? reversedIndexOf(String? messageId) {
if (messageId == null) return null;
final chronologicalIndex = displayEntries.indexWhere(
(group) => group.any((entry) => entry.message.id == messageId),
);
return chronologicalIndex < 0
? null
: displayEntries.length - 1 - chronologicalIndex;
}
double latestAlignment() {
final viewportHeight = context.size?.height ?? 0;
return viewportHeight > 0
? (composerBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble()
: 0.0;
}
Future<void> scrollToLatest() async {
if (!itemScrollController.isAttached || isAutoScrolling.value) return;
followsLatest.value = true;
hasUserScrolled.value = false;
isAutoScrolling.value = true;
try {
await itemScrollController.scrollTo(
index: 0,
alignment: latestAlignment(),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
if (context.mounted && !hasUserScrolled.value) {
isAtLatest.value = true;
}
} finally {
isAutoScrolling.value = false;
}
}
Future<void> scrollToOldestUnread() async {
final targetIndex = reversedIndexOf(oldestUnreadMessageId.value);
if (targetIndex == null ||
!itemScrollController.isAttached ||
isAutoScrolling.value) {
return;
}
isUnreadNavigationDismissed.value = true;
followsLatest.value = false;
hasUserScrolled.value = false;
isAtLatest.value = false;
isAutoScrolling.value = true;
try {
await itemScrollController.scrollTo(
index: targetIndex,
alignment: 0.35,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
} finally {
isAutoScrolling.value = false;
}
}
bool latestIsAtBoundary() {
// In this reversed list, item 0's leading edge is the visible bottom
// boundary above the composer. Being merely visible is not enough: a
// user who has pulled a tall newest row away from that boundary must not
// snap back on live updates.
final boundary = latestAlignment();
return itemPositionsListener.itemPositions.value.any(
(position) =>
position.index == 0 &&
(position.itemLeadingEdge - boundary).abs() < 0.01,
);
}
void realignLatestAfterLayoutChange() {
if (latestRealignmentQueued.value ||
!followsLatest.value ||
hasUserScrolled.value) {
return;
}
latestRealignmentQueued.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
latestRealignmentQueued.value = false;
if (!context.mounted ||
!itemScrollController.isAttached ||
!followsLatest.value ||
hasUserScrolled.value ||
latestIsAtBoundary()) {
return;
}
// A dock or keyboard resize is a layout correction, not a navigation
// action. Keeping it instant avoids restarting a smooth scroll for
// every position report while the viewport settles.
itemScrollController.jumpTo(index: 0);
});
}
useEffect(() {
void onPositionsChanged() {
final positions = itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
final nextIsAtLatest = latestIsAtBoundary();
if (showUnreadNavigation &&
nextIsAtLatest &&
detachedWhileUnreadShown.value) {
isUnreadNavigationDismissed.value = true;
}
if (nextIsAtLatest) {
if (!isAtLatest.value) isAtLatest.value = true;
} else if (!followsLatest.value && isAtLatest.value) {
isAtLatest.value = false;
}
final oldestVisible = positions
.map((position) => position.index)
.reduce((a, b) => a > b ? a : b);
if (!hasUserScrolled.value ||
oldestVisible < displayEntries.length - 3 ||
isLoadingOlder.value) {
return;
}
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
if (notifier.reachedOldest) return;
isLoadingOlder.value = true;
notifier.fetchOlder().whenComplete(() => isLoadingOlder.value = false);
}
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
return () => itemPositionsListener.itemPositions.removeListener(
onPositionsChanged,
);
}, [channelId, entries.length, itemPositionsListener]);
// Composer size changes and keyboard metrics changes arrive in separate
// layout passes. Preserve the latest-message anchor for both, but only
// while the user has not deliberately left the tail.
useEffect(() {
realignLatestAfterLayoutChange();
return null;
}, [composerBottomInset]);
useEffect(() {
final observer = _ChannelLatestMetricsObserver(
onMetricsChanged: realignLatestAfterLayoutChange,
);
WidgetsBinding.instance.addObserver(observer);
return () => WidgetsBinding.instance.removeObserver(observer);
}, [itemScrollController]);
useEffect(() {
if (initialThreadRootId == null || didOpenInitialThread.value) {
return null;
}
final threadHead = allMessages
.where((message) => message.id == initialThreadRootId)
.firstOrNull;
if (threadHead == null) return null;
didOpenInitialThread.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
initialMessageId: initialMessageId,
),
),
);
});
return null;
}, [initialThreadRootId, allMessages]);
useEffect(() {
final targetIndex = reversedIndexOf(initialMessageId);
if (initialThreadRootId != null ||
targetIndex == null ||
didJumpToInitialMessage.value) {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !itemScrollController.isAttached) return;
didJumpToInitialMessage.value = true;
followsLatest.value = false;
hasUserScrolled.value = false;
isAtLatest.value = false;
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
});
return null;
}, [initialMessageId, initialThreadRootId, entries.length]);
useEffect(() {
final previous = previousLatestEntryId.value;
previousLatestEntryId.value = latestEntryId;
if (previous == null ||
latestEntryId == null ||
previous == latestEntryId ||
!isAtLatest.value) {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) scrollToLatest();
});
return null;
}, [latestEntryId]);
if (entries.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.messageSquare,
size: Grid.xl,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xxs),
Text(
context.l10n.noMessages,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.half),
Text(
context.l10n.beFirstMessage,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
// Build channel names map once for all message bubbles.
final channelsAsync = ref.watch(channelsProvider);
final channelNamesMap = <String, String>{};
channelsAsync.whenData((channels) {
for (final ch in channels) {
channelNamesMap[ch.name.toLowerCase()] = ch.id;
}
});
return Stack(
children: [
NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is UserScrollNotification &&
notification.direction != ScrollDirection.idle) {
hasUserScrolled.value = true;
followsLatest.value = false;
if (showUnreadNavigation) {
detachedWhileUnreadShown.value = true;
}
} else if (notification is ScrollEndNotification &&
hasUserScrolled.value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !latestIsAtBoundary()) return;
hasUserScrolled.value = false;
followsLatest.value = true;
if (!isAtLatest.value) isAtLatest.value = true;
});
}
return false;
},
child: KeyboardDismissOnDrag(
child: ScrollablePositionedList.builder(
key: const ValueKey('channel-message-list'),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
reverse: true,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
),
bottom: composerBottomInset,
),
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at the top (last index in reversed list).
if (index >= displayEntries.length) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
child: Center(
child: BuzzLoadingIndicator(
size: 24,
semanticLabel: context.l10n.loadingOlderMessages,
),
),
);
}
// Reversed list: index 0 = newest (bottom of screen).
final chronIdx = displayEntries.length - 1 - index;
final entryGroup = displayEntries[chronIdx];
final entry = entryGroup.first;
final message = entry.message;
// Day boundary check — applies to all messages including system.
final prevEntry = chronIdx > 0
? displayEntries[chronIdx - 1].last
: null;
final prevMessage = prevEntry?.message;
final showDayDivider =
prevMessage == null ||
!isSameDay(prevMessage.createdAt, message.createdAt);
final showAuthor =
!message.isSystem &&
(message.hasAttachments ||
prevMessage == null ||
prevMessage.isSystem ||
showDayDivider ||
prevMessage.pubkey.toLowerCase() !=
message.pubkey.toLowerCase() ||
(message.createdAt - prevMessage.createdAt) > 300);
return Padding(
key: ValueKey('channel-message-group-${message.id}'),
padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(
label: formatDayHeading(
message.createdAt,
l10n: context.l10n,
),
),
if (message.isSystem)
_SystemMessageRow(
message: message,
groupedMessages: entryGroup.length > 1
? entryGroup
.map((entry) => entry.message)
.toList()
: null,
channelId: channelId,
currentPubkey: currentPubkey,
allMessages: null,
isMember: isMember,
isArchived: isArchived,
)
else ...[
_MessageBubble(
message: message,
showAuthor: showAuthor,
channelNames: channelNamesMap,
currentChannelId: channelId,
currentPubkey: currentPubkey,
allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
),
if (entry.summary != null)
_ThreadSummaryRow(
summary: entry.summary!,
message: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
],
],
),
);
},
),
),
),
if (showUnreadNavigation)
Positioned(
left: 0,
right: 0,
top:
frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
) +
Grid.xs,
child: Center(
child: IconButton.filled(
key: const ValueKey('channel-jump-to-oldest-unread'),
onPressed: scrollToOldestUnread,
tooltip: context.l10n.jumpOldestUnread,
style: IconButton.styleFrom(
backgroundColor: context.colors.primaryContainer,
foregroundColor: context.colors.onPrimaryContainer,
),
icon: const Icon(LucideIcons.chevronUp, size: 20),
),
),
)
else if (!isAtLatest.value)
Positioned(
left: 0,
right: 0,
bottom: composerBottomInset + Grid.xs,
child: Center(
child: _JumpToLatestButton(
key: const ValueKey('channel-jump-to-latest'),
onPressed: scrollToLatest,
),
),
),
],
);
}
}
class _JumpToLatestButton extends StatelessWidget {
final VoidCallback onPressed;
const _JumpToLatestButton({required this.onPressed, super.key});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(Radii.full);
return Semantics(
button: true,
child: ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
key: const ValueKey('channel-jump-to-latest-surface'),
decoration: BoxDecoration(
color: context.colors.surface.withValues(alpha: 0.5),
borderRadius: borderRadius,
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onPressed,
borderRadius: borderRadius,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.arrowDown,
size: 16,
color: context.colors.onSurface,
),
const SizedBox(width: Grid.half),
Text(
context.l10n.jumpLatest,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
),
),
),
),
),
);
}
}
class _ChannelLatestMetricsObserver with WidgetsBindingObserver {
final VoidCallback onMetricsChanged;
_ChannelLatestMetricsObserver({required this.onMetricsChanged});
@override
void didChangeMetrics() => onMetricsChanged();
}
@@ -0,0 +1,649 @@
part of '../channel_detail_page.dart';
class _SystemMessageRow extends HookConsumerWidget {
final TimelineMessage message;
final List<TimelineMessage>? groupedMessages;
final String channelId;
final String? currentPubkey;
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
const _SystemMessageRow({
required this.message,
this.groupedMessages,
required this.channelId,
this.currentPubkey,
this.allMessages,
this.isMember = false,
this.isArchived = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final spotlightKey = useMemoized(() => GlobalKey());
final systemEvent = message.systemEvent;
if (systemEvent == null) return const SizedBox.shrink();
final userCache = ref.watch(userCacheProvider);
final sourceMessages = groupedMessages ?? [message];
final groupedMembership = _membershipDisplayEvent(sourceMessages);
final messageStyleAction = switch (systemEvent.type) {
SystemEventType.channelCreated => context.l10n.systemCreatedThisChannel,
SystemEventType.huddleStarted => context.l10n.systemStartedHuddle,
SystemEventType.huddleEnded => context.l10n.systemEndedHuddle,
_ => null,
};
final messageStyleActor = messageStyleAction == null
? null
: systemEvent.actorPubkey?.trim();
final usesMessageStyleLayout =
groupedMembership != null ||
(messageStyleActor != null && messageStyleActor.isNotEmpty);
String resolveLabel(String? pubkey) {
if (pubkey == null) return context.l10n.systemSomeone;
final profile =
userCache[pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(pubkey.toLowerCase());
return profile?.label ?? shortPubkey(pubkey);
}
final reactions = groupedMessages == null
? message.reactions
: _aggregateSystemMessageReactions(sourceMessages);
void toggleGroupedReaction(String emoji) {
final actions = ref.read(channelActionsProvider);
final reactedMessages = sourceMessages.where(
(source) => source.reactions.any(
(reaction) =>
reaction.emoji == emoji &&
reaction.reactedByCurrentUser &&
reaction.currentUserReactionId != null,
),
);
if (reactedMessages.isEmpty) {
actions.addReaction(message.id, emoji);
return;
}
for (final source in reactedMessages) {
final reaction = source.reactions.firstWhere(
(candidate) =>
candidate.emoji == emoji &&
candidate.reactedByCurrentUser &&
candidate.currentUserReactionId != null,
);
actions.removeReaction(reaction.currentUserReactionId!, emoji);
}
}
void openReactionPopover(Rect anchorRect) {
final spotlightRenderObject = spotlightKey.currentContext
?.findRenderObject();
final spotlightRect =
spotlightRenderObject is RenderBox && spotlightRenderObject.hasSize
? spotlightRenderObject.localToGlobal(Offset.zero) &
spotlightRenderObject.size
: anchorRect;
showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: spotlightRect,
popoverSpotlightPadding: EdgeInsets.fromLTRB(
Grid.xxs,
Grid.xxs,
Grid.xxs,
reactions.isEmpty ? Grid.xxs : Grid.quarter,
),
);
}
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
clipBehavior: Clip.antiAlias,
child: MessageLongPressInkWell(
key: ValueKey('system-message-row-${message.id}'),
onLongPress: openReactionPopover,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
child: Padding(
padding: EdgeInsets.only(
top: usesMessageStyleLayout ? Grid.xs : Grid.xxs,
bottom: usesMessageStyleLayout ? 0 : Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
KeyedSubtree(
key: spotlightKey,
child: groupedMembership != null
? _MembershipSystemMessageContent(
event: groupedMembership,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
)
: messageStyleActor != null &&
messageStyleActor.isNotEmpty &&
messageStyleAction != null
? _MessageStyleSystemMessageContent(
displayPubkey: messageStyleActor,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: [TextSpan(text: messageStyleAction)],
)
: Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
systemEvent.describeLocalized(
context.l10n,
resolveLabel,
),
style: systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
_messageTimestamp(
context,
message.createdAt,
key: ValueKey(
'system-message-timestamp-${message.id}',
),
),
],
),
),
if (reactions.isNotEmpty)
Padding(
padding: EdgeInsets.only(
left:
(usesMessageStyleLayout ? messageAvatarSize : 36) +
(usesMessageStyleLayout
? messageAvatarContentGap
: Grid.xxs),
),
child: ReactionRow(
messageId: message.id,
reactions: reactions,
onToggle: groupedMessages == null
? (emoji) => toggleReaction(ref, message, emoji)
: toggleGroupedReaction,
),
),
],
),
),
),
);
}
}
const _maxVisibleAdditionalMemberNames = 3;
class _MembershipDisplayEvent {
final String? actorPubkey;
final List<String> targetPubkeys;
final bool isSelfJoin;
const _MembershipDisplayEvent({
required this.actorPubkey,
required this.targetPubkeys,
required this.isSelfJoin,
});
}
_MembershipDisplayEvent? _membershipDisplayEvent(
List<TimelineMessage> messages,
) {
if (messages.isEmpty) return null;
final first = messages.first.systemEvent;
final firstActor = first?.actorPubkey?.trim().toLowerCase();
final firstTarget = first?.targetPubkey?.trim().toLowerCase();
if (first?.type != SystemEventType.memberJoined ||
firstActor == null ||
firstActor.isEmpty ||
firstTarget == null ||
firstTarget.isEmpty) {
return null;
}
final isSelfJoin = firstActor == firstTarget;
final targets = <String>[];
for (final message in messages) {
final event = message.systemEvent;
final actor = event?.actorPubkey?.trim().toLowerCase();
final target = event?.targetPubkey?.trim().toLowerCase();
if (event?.type != SystemEventType.memberJoined ||
actor == null ||
actor.isEmpty ||
target == null ||
target.isEmpty ||
(isSelfJoin
? actor != target
: actor != firstActor || actor == target)) {
return null;
}
targets.add(target);
}
return _MembershipDisplayEvent(
actorPubkey: isSelfJoin ? null : firstActor,
targetPubkeys: targets,
isSelfJoin: isSelfJoin,
);
}
List<TimelineReaction> _aggregateSystemMessageReactions(
List<TimelineMessage> messages,
) {
final byEmoji = <String, List<TimelineReaction>>{};
for (final message in messages) {
for (final reaction in message.reactions) {
byEmoji.putIfAbsent(reaction.emoji, () => []).add(reaction);
}
}
return [
for (final entry in byEmoji.entries)
() {
final reactions = entry.value;
final userPubkeys = {
for (final reaction in reactions) ...reaction.userPubkeys,
}.toList();
final reactedByCurrentUser = reactions.any(
(reaction) => reaction.reactedByCurrentUser,
);
final currentUserReactionId = reactions
.where((reaction) => reaction.currentUserReactionId != null)
.firstOrNull
?.currentUserReactionId;
final fallbackCount = reactions.fold<int>(
0,
(total, reaction) => total + reaction.count,
);
return TimelineReaction(
emoji: entry.key,
count: userPubkeys.isEmpty ? fallbackCount : userPubkeys.length,
reactedByCurrentUser: reactedByCurrentUser,
userPubkeys: userPubkeys,
emojiUrl: reactions.first.emojiUrl,
currentUserReactionId: currentUserReactionId,
);
}(),
];
}
class _MembershipSystemMessageContent extends StatelessWidget {
final _MembershipDisplayEvent event;
final int createdAt;
final String Function(String? pubkey) resolveLabel;
final Map<String, UserProfile> userCache;
const _MembershipSystemMessageContent({
required this.event,
required this.createdAt,
required this.resolveLabel,
required this.userCache,
});
@override
Widget build(BuildContext context) {
final firstTarget = event.targetPubkeys.first;
final additionalTargets = event.targetPubkeys.skip(1).toList();
final visibleTargets = additionalTargets
.take(_maxVisibleAdditionalMemberNames)
.toList();
final hiddenTargets = additionalTargets
.skip(_maxVisibleAdditionalMemberNames)
.toList();
final actionStyle = _systemActionTextStyle(context);
final actionSpans = <InlineSpan>[
TextSpan(
text: event.isSelfJoin
? context.l10n.systemJoinedChannelAction
// No "was": the name renders on the line above via
// MessageAuthorMeta, so this reads as a status line rather than a
// sentence continuing across the metadata row. Matches desktop's
// SystemMessageRow. `SystemEvent.describe` keeps "was added by"
// because it builds subject and predicate into one string.
: context.l10n.systemAddedBy(resolveLabel(event.actorPubkey)),
),
if (additionalTargets.isNotEmpty)
TextSpan(
text: event.isSelfJoin
? context.l10n.systemAlongWith
: context.l10n.systemCommaAlongWith,
),
..._memberNameSpans(
context,
visibleTargets: visibleTargets,
hiddenTargets: hiddenTargets,
resolveLabel: resolveLabel,
style: actionStyle,
),
];
return _MessageStyleSystemMessageContent(
displayPubkey: firstTarget,
createdAt: createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: actionSpans,
);
}
}
TextStyle? _systemActionTextStyle(BuildContext context) {
return systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
);
}
class _MessageStyleSystemMessageContent extends StatelessWidget {
final String displayPubkey;
final int createdAt;
final String Function(String? pubkey) resolveLabel;
final Map<String, UserProfile> userCache;
final List<InlineSpan> actionSpans;
const _MessageStyleSystemMessageContent({
required this.displayPubkey,
required this.createdAt,
required this.resolveLabel,
required this.userCache,
required this.actionSpans,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, displayPubkey),
child: _UserAvatar(
profile: userCache[displayPubkey.toLowerCase()],
pubkey: displayPubkey,
size: messageAvatarSize,
),
),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Transform.translate(
offset: const Offset(0, -Grid.quarter),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MessageAuthorMeta(
displayName: resolveLabel(displayPubkey),
username: messageUsernameLabel(
userCache[displayPubkey.toLowerCase()],
),
timestamp: formatMessageTime(createdAt, l10n: context.l10n),
nameColor: context.colors.onSurface,
metadataColor: context.colors.onSurfaceVariant,
nameStyle: systemMessageHeadingTextStyle,
displayNameKey: ValueKey(
'system-message-author-$displayPubkey',
),
usernameKey: ValueKey(
'system-message-username-$displayPubkey',
),
timestampKey: ValueKey(
'system-message-timestamp-$displayPubkey',
),
),
Text.rich(
TextSpan(
style: _systemActionTextStyle(context),
children: actionSpans,
),
),
],
),
),
),
],
);
}
}
List<InlineSpan> _memberNameSpans(
BuildContext context, {
required List<String> visibleTargets,
required List<String> hiddenTargets,
required String Function(String? pubkey) resolveLabel,
required TextStyle? style,
}) {
final spans = <InlineSpan>[];
for (var index = 0; index < visibleTargets.length; index++) {
final isLast = index == visibleTargets.length - 1;
final separator = index == 0
? ''
: isLast && hiddenTargets.isEmpty
? (visibleTargets.length == 2
? context.l10n.systemAnd
: context.l10n.systemCommaAnd)
: ', ';
spans.add(
TextSpan(text: '$separator${resolveLabel(visibleTargets[index])}'),
);
}
if (hiddenTargets.isNotEmpty) {
final hiddenLabels = hiddenTargets.map(resolveLabel).toList();
spans
..add(TextSpan(text: context.l10n.systemCommaAnd))
..add(
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: Tooltip(
message: hiddenLabels.join('\n'),
triggerMode: TooltipTriggerMode.tap,
child: Text(
context.l10n.othersCount(hiddenTargets.length),
key: const Key('membership-overflow'),
style: style?.copyWith(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
),
),
),
),
);
}
return spans;
}
Widget _systemEventAvatar(
BuildContext context,
SystemEvent event,
Map<String, UserProfile> userCache,
) {
final hasTarget =
event.targetPubkey != null && event.targetPubkey != event.actorPubkey;
if (event.actorPubkey != null && hasTarget) {
// Two-avatar stack: actor + target (e.g. "Alice added Bob").
return SizedBox(
width: 32,
height: 20,
child: Stack(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
child: SmallAvatar(
pubkey: event.actorPubkey!,
userCache: userCache,
),
),
Positioned(
left: 12,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, event.targetPubkey!),
child: SmallAvatar(
pubkey: event.targetPubkey!,
userCache: userCache,
),
),
),
],
),
);
}
if (event.actorPubkey != null) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
child: SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache),
);
}
// Fallback: generic icon when no actor is available.
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.arrowLeftRight,
size: 12,
color: context.colors.onSurfaceVariant,
),
);
}
class _ThreadSummaryRow extends ConsumerWidget {
final ThreadSummary summary;
final TimelineMessage message;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _ThreadSummaryRow({
required this.summary,
required this.message,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
child: Padding(
key: ValueKey('thread-summary-${message.id}'),
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.half,
bottom: Grid.xs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Stacked participant avatars.
SizedBox(
width: 32.0 + (summary.participantPubkeys.length - 1) * 20.0,
height: 32,
child: Stack(
children: [
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 20.0,
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
size: 32,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
context.l10n.replyCount(summary.replyCount),
style: replyPreviewTextStyle.copyWith(
color: context.colors.primary,
),
),
if (summary.lastReplyAt case final lastReplyAt?) ...[
TextSpan(
text: ' · ',
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
TextSpan(
text: context.l10n.lastReply(
formatThreadSummaryLastReplyTime(
lastReplyAt,
l10n: context.l10n,
),
),
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'channel.dart';
import 'channel_detail_page.dart';
import 'channels_provider.dart';
import '../../shared/l10n/l10n.dart';
void openChannelLink({
required BuildContext context,
required WidgetRef ref,
required String channelId,
required String currentChannelId,
}) {
if (channelId == currentChannelId) return;
final channelsAsync = ref.read(channelsProvider);
final channels = channelsAsync.hasValue ? channelsAsync.value : null;
Channel? targetChannel;
for (final channel in channels ?? const <Channel>[]) {
if (channel.id == channelId) {
targetChannel = channel;
break;
}
}
if (targetChannel == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.channelCouldNotOpen)),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: targetChannel!),
),
);
}
@@ -0,0 +1,857 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/auth/auth.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../profile/profile_provider.dart';
import 'channel.dart';
import 'channels_provider.dart';
String _relayErrorMessage(Object error) =>
error.toString().replaceFirst('Exception: ', '');
/// Raised when one or more kind:9000 adds were rejected, keyed by pubkey.
///
/// Callers surface [message] to the user — a relay rejection here (e.g. a plain
/// member trying to add someone to a private channel) is a real outcome, not a
/// crash to swallow.
@immutable
class AddMembersException implements Exception {
final Map<String, String> failures;
const AddMembersException(this.failures);
String get message => failures.entries
.map(
(entry) =>
'${entry.key.length > 8 ? '${entry.key.substring(0, 8)}' : entry.key}: ${entry.value}',
)
.join('; ');
@override
String toString() => 'AddMembersException($message)';
}
@immutable
class ChannelMember {
final String pubkey;
final String role;
final DateTime joinedAt;
final String? displayName;
const ChannelMember({
required this.pubkey,
required this.role,
required this.joinedAt,
this.displayName,
});
bool get isBot => role == 'bot';
bool get isOwner => role == 'owner';
bool get isElevated => role == 'owner' || role == 'admin';
String labelFor(String? currentPubkey, {String youLabel = 'You'}) {
if (currentPubkey != null &&
currentPubkey.toLowerCase() == pubkey.toLowerCase()) {
return youLabel;
}
if (displayName case final name? when name.trim().isNotEmpty) {
return name.trim();
}
return pubkey.length > 8 ? '${pubkey.substring(0, 8)}' : pubkey;
}
}
@immutable
class ChannelCanvas {
final String? content;
final DateTime? updatedAt;
final String? authorPubkey;
const ChannelCanvas({
required this.content,
required this.updatedAt,
required this.authorPubkey,
});
}
@immutable
class DirectoryUser {
final String pubkey;
final String? displayName;
final String? avatarUrl;
final String? nip05Handle;
const DirectoryUser({
required this.pubkey,
this.displayName,
this.avatarUrl,
this.nip05Handle,
});
String get label {
final display = displayName?.trim();
if (display != null && display.isNotEmpty) {
return display;
}
final nip05 = nip05Handle?.trim();
if (nip05 != null && nip05.isNotEmpty) {
return nip05;
}
return pubkey.length > 8 ? '${pubkey.substring(0, 8)}' : pubkey;
}
String get secondaryLabel {
final nip05 = nip05Handle?.trim();
if (nip05 != null && nip05.isNotEmpty && nip05 != label) {
return nip05;
}
return pubkey.length > 16 ? '${pubkey.substring(0, 16)}' : pubkey;
}
/// First visible character used when no avatar image is available.
String get initial => label.isNotEmpty ? label[0].toUpperCase() : '?';
}
/// Whether the mobile DM directory should show local preview identities.
const bool mockDmDirectoryEnabled =
kDebugMode && bool.fromEnvironment('BUZZ_MOCK_DM_DIRECTORY');
/// Whether the new-DM picker should use local preview identities.
///
/// Debug builds fall back automatically while disconnected. Production builds
/// always use the active relay directory.
final dmDirectoryPreviewEnabledProvider = Provider<bool>((ref) {
if (mockDmDirectoryEnabled) {
return true;
}
final relayStatus = ref.watch(
relaySessionProvider.select((session) => session.status),
);
return kDebugMode && relayStatus != SessionStatus.connected;
});
String _mockEmojiAvatar(String emoji, String color) {
return Uri.dataFromString(
'<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128">'
'<rect width="128" height="128" fill="$color"/>'
'<text x="64" y="80" text-anchor="middle" font-size="58">$emoji</text>'
'</svg>',
mimeType: 'image/svg+xml',
encoding: utf8,
).toString();
}
/// Local identities used to preview the new-DM picker without a relay.
final dmDirectoryPreviewUsers = List<DirectoryUser>.unmodifiable([
DirectoryUser(
pubkey: '1111111111111111111111111111111111111111111111111111111111111111',
displayName: 'Maya Chen',
avatarUrl: _mockEmojiAvatar('🎨', '#8AADF4'),
nip05Handle: 'maya@demo.buzz',
),
DirectoryUser(
pubkey: '2222222222222222222222222222222222222222222222222222222222222222',
displayName: 'Jordan Brooks',
avatarUrl: _mockEmojiAvatar('🌱', '#A6DA95'),
nip05Handle: 'jordan@demo.buzz',
),
const DirectoryUser(
pubkey: '3333333333333333333333333333333333333333333333333333333333333333',
displayName: 'Priya Shah',
nip05Handle: 'priya@demo.buzz',
),
DirectoryUser(
pubkey: '4444444444444444444444444444444444444444444444444444444444444444',
displayName: 'Theo Martin',
avatarUrl: _mockEmojiAvatar('💻', '#C6A0F6'),
nip05Handle: 'theo@demo.buzz',
),
const DirectoryUser(
pubkey: '5555555555555555555555555555555555555555555555555555555555555555',
displayName: 'Sam Rivera',
nip05Handle: 'sam@demo.buzz',
),
]);
final currentPubkeyProvider = Provider<String?>((ref) {
// Prefer the explicitly-derived pubkey from nsec — this is the signing
// identity used for events.
final myPk = ref.watch(myPubkeyProvider);
if (myPk != null) return myPk.toLowerCase();
final profile = ref.watch(profileProvider).whenData((value) => value).value;
final profilePubkey = profile?.pubkey.trim();
if (profilePubkey != null && profilePubkey.isNotEmpty) {
return profilePubkey.toLowerCase();
}
final authState = ref.watch(authProvider).whenData((value) => value).value;
final credentialPubkey = authState?.community?.pubkey?.trim();
if (credentialPubkey != null && credentialPubkey.isNotEmpty) {
return credentialPubkey.toLowerCase();
}
return null;
});
/// Extracts the unique member pubkeys advertised by relay membership events.
///
/// Buzz relays use `member` tags, while older NIP-29-compatible relays may
/// still expose the same directory through `p` tags.
@visibleForTesting
List<String> relayMemberPubkeysFromEvents(List<NostrEvent> events) {
final pubkeys = <String>{};
for (final event in events) {
for (final tag in event.tags) {
if (tag.length < 2 || (tag[0] != 'member' && tag[0] != 'p')) {
continue;
}
final pubkey = tag[1].trim().toLowerCase();
if (pubkey.isNotEmpty) {
pubkeys.add(pubkey);
}
}
}
return pubkeys.toList();
}
/// Converts kind:0 events into a deduplicated, alphabetized people directory.
@visibleForTesting
List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) {
continue;
}
final pubkey = event.pubkey.toLowerCase();
final current = latestByPubkey[pubkey];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pubkey] = event;
}
}
return [
for (final event in latestByPubkey.values)
if (ProfileData.fromEvent(event) case final profile)
DirectoryUser(
pubkey: profile.pubkey.toLowerCase(),
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
}
/// People and agents discoverable on the active relay.
///
/// This mirrors desktop's empty-query people directory by listing kind:0
/// profiles through the HTTP bridge. The relay membership snapshot remains a
/// fallback for older relays that do not support directory listing.
///
/// autoDispose so the cached listing is dropped when the New message sheet
/// closes, and the [relayConfigProvider] watch invalidates it at the
/// community boundary. [relaySessionProvider.notifier] is a stable Notifier
/// instance and [currentPubkeyProvider] keeps its value when two communities
/// share a signing key, so neither triggers a refetch on its own.
final relayDirectoryUsersProvider =
FutureProvider.autoDispose<List<DirectoryUser>>((ref) async {
if (mockDmDirectoryEnabled) {
return dmDirectoryPreviewUsers;
}
// Rebuild whenever the active relay/community configuration changes.
ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final directoryEvents = await session.queryRelay([
const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}),
]);
var users = directoryUsersFromProfileEvents(directoryEvents);
if (users.isNotEmpty) {
return users
.where((user) => user.pubkey.toLowerCase() != currentPubkey)
.toList();
}
final membershipEvents = await session.fetchHistory(
NostrFilters.relayMembers(),
);
final memberPubkeys = relayMemberPubkeysFromEvents(
membershipEvents,
).where((pubkey) => pubkey != currentPubkey).toList();
if (memberPubkeys.isEmpty) {
return const [];
}
final profileEvents = await session.queryRelay([
NostrFilters.profilesBatch(memberPubkeys),
]);
final profilesByPubkey = {
for (final event in profileEvents)
event.pubkey.toLowerCase(): ProfileData.fromEvent(event),
};
users =
[
for (final pubkey in memberPubkeys)
if (profilesByPubkey[pubkey] case final profile?)
DirectoryUser(
pubkey: pubkey,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
)
else
DirectoryUser(pubkey: pubkey),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
return users;
});
/// Prefix-searches the active relay's kind:0 people directory.
///
/// autoDispose family: each distinct query would otherwise cache a provider
/// instance for the whole session (the mention search provider is autoDispose
/// for the same reason). The [relayConfigProvider] watch invalidates cached
/// results at the community boundary.
final relayDirectorySearchProvider = FutureProvider.autoDispose
.family<List<DirectoryUser>, String>((ref, query) async {
final trimmed = query.trim();
if (mockDmDirectoryEnabled) {
final normalizedQuery = trimmed.toLowerCase();
return dmDirectoryPreviewUsers
.where(
(user) =>
user.label.toLowerCase().contains(normalizedQuery) ||
user.secondaryLabel.toLowerCase().contains(normalizedQuery),
)
.toList();
}
if (trimmed.isEmpty) {
return ref.watch(relayDirectoryUsersProvider.future);
}
// Rebuild whenever the active relay/community configuration changes.
ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final events = await session.queryRelay([
NostrFilters.searchUsers(trimmed, limit: 50),
]);
return directoryUsersFromProfileEvents(
events,
).where((user) => user.pubkey.toLowerCase() != currentPubkey).toList();
});
/// Build [ChannelDetails] from a kind:39000 metadata event.
///
/// Exposed as a pure function so the mapping can be unit-tested without
/// Riverpod / WebSocket scaffolding. Make sure all fields parsed by
/// [ChannelData.fromEvent] that exist on [ChannelDetails] are propagated —
/// any omission silently drops state when [Channel.mergeDetails] is called.
@visibleForTesting
ChannelDetails channelDetailsFromEvent(NostrEvent event) {
final data = ChannelData.fromEvent(event);
final eventTime = DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
);
return ChannelDetails(
id: data.id,
name: data.name,
channelType: data.channelType,
visibility: data.visibility,
description: data.description,
topic: data.topic,
createdBy: event.pubkey,
createdAt: eventTime,
memberCount: 0,
// Same archival-timestamp convention as `_channelFromMeta` — the event's
// `createdAt` is when the relay republished the metadata. Without this,
// `Channel.mergeDetails(details)` would clobber the archived state set
// on the base channel and the detail view would show compose/manage
// actions for expired/archived channels.
archivedAt: data.isArchived ? eventTime : null,
ttlSeconds: data.ttlSeconds,
ttlDeadline: data.ttlDeadline,
);
}
/// Single channel's metadata via kind:39000.
final channelDetailsProvider = FutureProvider.family<ChannelDetails, String>((
ref,
channelId,
) async {
final session = ref.watch(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilter(
kinds: [39000],
tags: {
'#d': [channelId],
},
limit: 1,
),
);
if (events.isEmpty) {
throw Exception('Channel not found: $channelId');
}
return channelDetailsFromEvent(events.first);
});
/// Channel members from kind:39002 NIP-29 members event.
final channelMembersProvider = FutureProvider.autoDispose
.family<List<ChannelMember>, String>((ref, channelId) async {
ref.watch(channelMembershipUpdateProvider(channelId));
final session = ref.watch(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.channelMembers(channelId),
);
if (events.isEmpty) return const [];
final event = events.first;
final joinedAt = DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
);
return membersFromEvent(event)
.map(
(m) => ChannelMember(
pubkey: m.pubkey,
role: m.role,
joinedAt: joinedAt,
),
)
.toList();
});
/// Channel canvas (kind:40100 for the channel).
final channelCanvasProvider = FutureProvider.family<ChannelCanvas, String>((
ref,
channelId,
) async {
final session = ref.watch(relaySessionProvider.notifier);
final events = await session.fetchHistory(NostrFilters.canvas(channelId));
if (events.isEmpty) {
return const ChannelCanvas(
content: null,
updatedAt: null,
authorPubkey: null,
);
}
final event = events.first;
return ChannelCanvas(
content: event.content,
updatedAt: DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
),
authorPubkey: event.pubkey,
);
});
/// Channel-scoped kind:5 deletion tags. The `h` tag lets channel-scoped
/// subscriptions observe the delete; the `e` tag points at the target event.
@visibleForTesting
List<List<String>> buildDeleteMessageTags({
required String channelId,
required String eventId,
}) {
return [
['h', channelId],
['e', eventId],
];
}
/// Builds the signed kind:9007 tags used by mobile channel creation.
List<List<String>> buildCreateChannelTags({
required String channelId,
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
}) {
if (ttlSeconds != null && ttlSeconds <= 0) {
throw ArgumentError.value(ttlSeconds, 'ttlSeconds', 'must be positive');
}
return [
['h', channelId],
['name', name],
['visibility', visibility],
['channel_type', channelType],
if (description case final about? when about.trim().isNotEmpty)
['about', about.trim()],
if (ttlSeconds != null) ['ttl', ttlSeconds.toString()],
];
}
/// Builds the relay tags for setting the archived state of [channelId].
List<List<String>> buildSetChannelArchivedTags(
String channelId, {
required bool archived,
}) => [
['h', channelId],
['archived', archived.toString()],
];
/// Builds the relay tags for deleting [channelId].
List<List<String>> buildDeleteChannelTags(String channelId) => [
['h', channelId],
];
class ChannelActions {
final Ref _ref;
final RelaySessionNotifier _session;
final SignedEventRelay _signedEventRelay;
final String? _currentPubkey;
final bool Function()? _isCommunityValid;
ChannelActions({
required Ref ref,
required RelaySessionNotifier session,
required SignedEventRelay signedEventRelay,
required String? currentPubkey,
bool Function()? isCommunityValid,
}) : _ref = ref,
_session = session,
_signedEventRelay = signedEventRelay,
_currentPubkey = currentPubkey,
_isCommunityValid = isCommunityValid;
Future<Channel> createChannel({
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
}) async {
final channelId = _newUuidV4();
final tags = buildCreateChannelTags(
channelId: channelId,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
ttlSeconds: ttlSeconds,
);
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
return _refreshChannelsAndRead(channelId);
}
/// Open (or create) a DM channel with the given pubkeys.
///
/// This submits a kind:41010 command event; the relay responds with an OK
/// message whose content carries `response:{...}` containing the new
/// `channel_id`.
Future<Channel> openDm({required List<String> pubkeys}) async {
final result = await _signedEventRelay.submit(
kind: 41010,
content: '',
tags: pubkeys.map((pk) => ['p', pk]).toList(),
);
final response = parseCommandResponse(result.content);
final channelId = response?['channel_id'] as String?;
if (channelId == null || channelId.isEmpty) {
throw Exception('Relay did not return a DM channel id');
}
return _refreshChannelsAndRead(channelId);
}
Future<void> addMembers({
required String channelId,
required List<String> pubkeys,
String role = 'member',
}) async {
final normalizedRole = role.trim();
if (normalizedRole.isEmpty) {
throw ArgumentError.value(role, 'role', 'must not be empty');
}
final normalizedPubkeys = {
for (final pubkey in pubkeys)
if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(),
};
_ensureCommunityValid();
// Per-pubkey failures are collected rather than thrown on the spot: one
// relay rejection must not skip the remaining adds or the invalidation
// below, which would leave the members list stale for the adds that landed.
final failures = <String, String>{};
for (final pubkey in normalizedPubkeys) {
// Outside the catch: a community switch mid-loop must abort the whole
// add, not be recorded as this pubkey's rejection.
_ensureCommunityValid();
try {
await _signedEventRelay.submit(
kind: 9000,
content: '',
tags: [
['h', channelId],
['p', pubkey],
['role', normalizedRole],
],
);
} catch (error) {
failures[pubkey] = _relayErrorMessage(error);
}
}
_ensureCommunityValid();
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
if (failures.isNotEmpty) {
throw AddMembersException(failures);
}
}
void _ensureCommunityValid() {
if (_isCommunityValid?.call() == false) {
throw StateError(
'Channel action cancelled because the active community changed',
);
}
}
Future<void> joinChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9021,
content: '',
tags: [
['h', channelId],
],
);
await _refreshChannelState(channelId);
}
Future<void> leaveChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9022,
content: '',
tags: [
['h', channelId],
],
);
await _refreshChannelState(channelId);
}
/// Archives the channel and refreshes its cached state.
Future<void> archiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: true);
/// Unarchives the channel and refreshes its cached state.
Future<void> unarchiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: false);
Future<void> _setChannelArchived(
String channelId, {
required bool archived,
}) async {
await _signedEventRelay.submit(
kind: 9002,
content: '',
tags: buildSetChannelArchivedTags(channelId, archived: archived),
);
await _refreshChannelState(channelId);
}
/// Deletes the channel and refreshes its cached state.
Future<void> deleteChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9008,
content: '',
tags: buildDeleteChannelTags(channelId),
);
await _refreshChannelState(channelId);
}
Future<void> setCanvas({
required String channelId,
required String content,
}) async {
await _signedEventRelay.submit(
kind: 40100,
content: content,
tags: [
['h', channelId],
],
);
_ref.invalidate(channelCanvasProvider(channelId));
}
/// User search via NIP-50 over kind:0 profile events.
Future<List<DirectoryUser>> searchUsers(String query, {int limit = 8}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final events = await _session.queryRelay([
NostrFilters.searchUsers(trimmed, limit: limit),
]);
return directoryUsersFromProfileEvents(events)
.where(
(user) =>
_currentPubkey == null ||
user.pubkey.toLowerCase() != _currentPubkey,
)
.toList();
}
Future<Channel> _refreshChannelsAndRead(String channelId) async {
await _ref.read(channelsProvider.notifier).refresh();
final channels = await _ref.read(channelsProvider.future);
return channels.firstWhere(
(channel) => channel.id == channelId,
orElse: () =>
throw Exception('Channel was created but is not visible yet'),
);
}
Future<void> _refreshChannelState(String channelId) async {
await _ref.read(channelsProvider.notifier).refresh();
_ref.invalidate(channelDetailsProvider(channelId));
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
_ref.invalidate(channelCanvasProvider(channelId));
}
String _newUuidV4() {
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex.substring(0, 8)}-'
'${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-'
'${hex.substring(20, 32)}';
}
Future<void> changeMemberRole({
required String channelId,
required String pubkey,
required String role,
}) async {
await _signedEventRelay.submit(
kind: 9000,
content: '',
tags: [
['h', channelId],
['p', pubkey.toLowerCase()],
['role', role],
],
);
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> removeMember({
required String channelId,
required String pubkey,
}) async {
await _signedEventRelay.submit(
kind: 9001,
content: '',
tags: [
['h', channelId],
['p', pubkey.toLowerCase()],
],
);
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> addReaction(String eventId, String emoji) async {
final shortcode = normalizeShortcode(emoji);
final emojiUrl = reactionEmojiUrl(
emoji,
_ref.read(customEmojiListProvider),
);
await _signedEventRelay.submit(
kind: EventKind.reaction,
content: emoji,
tags: [
['e', eventId],
if (shortcode != null && emojiUrl != null)
['emoji', shortcode, emojiUrl],
],
);
}
Future<void> removeReaction(String reactionEventId, String emoji) async {
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: [
['e', reactionEventId],
],
);
}
Future<void> editMessage({
required String channelId,
required String eventId,
required String content,
List<List<String>> mediaTags = const [],
}) async {
await _signedEventRelay.submit(
kind: EventKind.streamMessageEdit,
content: content,
tags: [
['h', channelId],
['e', eventId],
...mediaTags,
],
);
}
Future<void> deleteMessage({
required String channelId,
required String eventId,
}) async {
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: buildDeleteMessageTags(channelId: channelId, eventId: eventId),
);
}
static final Random _random = Random.secure();
}
final channelActionsProvider = Provider<ChannelActions>((ref) {
final relayConfig = ref.watch(relayConfigProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
final session = ref.read(relaySessionProvider.notifier);
return ChannelActions(
ref: ref,
session: session,
signedEventRelay: SignedEventRelay(
session: session,
nsec: relayConfig.nsec,
),
currentPubkey: currentPubkey,
isCommunityValid: () {
final currentConfig = ref.read(relayConfigProvider);
return currentConfig.baseUrl == relayConfig.baseUrl &&
currentConfig.nsec == relayConfig.nsec;
},
);
});
@@ -0,0 +1,509 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import 'pending_local_messages_provider.dart';
import 'channel_window.dart';
import 'thread_replies_provider.dart';
const _channelLiveEventKinds = [
...EventKind.channelEventKinds,
EventKind.channelThreadSummary,
];
/// Provides the message list for a specific channel. Registers a live
/// subscription first, then syncs history via the server-assembled channel
/// window fast path, falling back to the legacy websocket history path when the
/// relay does not return a valid NIP-CW bounds overlay.
class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
final String channelId;
void Function()? _unsubscribe;
bool _reachedOldest = false;
bool _initInFlight = false;
bool _usingChannelWindow = false;
bool _initialWindowQueryInFlight = false;
int _initVersion = 0;
ChannelWindowStore _windowStore = const ChannelWindowStore.empty();
final Set<String> _liveSummaryRootsDuringInitialWindowQuery = {};
final Map<String, NostrEvent> _deepLinkEvents = {};
final Set<String> _retainedDeepLinkEventIds = {};
ChannelMessagesNotifier(this.channelId);
/// Last successfully loaded messages, preserved across reconnections so the
/// UI can show stale data instead of a blank loading spinner.
List<NostrEvent>? _lastKnownMessages;
/// Whether this channel has completed at least one message history load.
///
/// This distinguishes a genuinely loaded empty channel from the synthetic
/// empty value returned while the relay is not yet connected.
bool get hasLoadedMessages => _lastKnownMessages != null;
Map<String, ChannelWindowThreadSummary> get threadSummaries =>
channelWindowThreadSummaries(_windowStore);
@override
AsyncValue<List<NostrEvent>> build() {
final sessionState = ref.watch(relaySessionProvider);
ref.onDispose(() {
_initVersion++;
_clearSubscription();
});
if (sessionState.status != SessionStatus.connected) {
_initVersion++;
_initInFlight = false;
_initialWindowQueryInFlight = false;
_liveSummaryRootsDuringInitialWindowQuery.clear();
return AsyncData(_lastKnownMessages ?? const []);
}
_reachedOldest = false;
_windowStore = const ChannelWindowStore.empty();
_usingChannelWindow = false;
_initialWindowQueryInFlight = false;
_liveSummaryRootsDuringInitialWindowQuery.clear();
_init();
if (_lastKnownMessages case final cached? when cached.isNotEmpty) {
return AsyncData(cached);
}
return const AsyncLoading();
}
Future<void> _init() async {
final initVersion = ++_initVersion;
_initInFlight = true;
_clearSubscription();
try {
final session = ref.read(relaySessionProvider.notifier);
try {
final unsubscribe = await session.subscribe(
NostrFilter(
kinds: _channelLiveEventKinds,
tags: {
'#h': [channelId],
},
since: _currentUnixSeconds(),
limit: 200,
),
_handleLiveEvent,
);
if (!_isCurrentInit(initVersion)) {
unsubscribe();
return;
}
_unsubscribe = unsubscribe;
} catch (error) {
if (!_isCurrentInit(initVersion)) return;
debugPrint(
'[ChannelMessagesNotifier] live subscription failed for $channelId: $error',
);
}
final history = await _fetchNewestHistory(session);
if (!_isCurrentInit(initVersion)) return;
_confirmLocalMessages(history.map((event) => event.id));
final existing = state.value ?? const <NostrEvent>[];
final existingIds = existing.map((event) => event.id).toSet();
final merged = _withDeepLinkEvents([
...existing,
...history.where((event) => existingIds.add(event.id)),
]);
_lastKnownMessages = merged;
state = AsyncData(merged);
} catch (e, st) {
if (!_isCurrentInit(initVersion)) return;
final fallbackMessages = state.value ?? _lastKnownMessages;
if (fallbackMessages != null) {
debugPrint(
'[ChannelMessagesNotifier] history sync failed for $channelId: $e',
);
state = AsyncData(fallbackMessages);
return;
}
state = AsyncError(e, st);
} finally {
if (_isCurrentInit(initVersion)) {
_initInFlight = false;
}
}
}
Future<List<NostrEvent>> _fetchNewestHistory(
RelaySessionNotifier session,
) async {
try {
_initialWindowQueryInFlight = true;
final page = await _fetchWindowPage(session, null);
_initialWindowQueryInFlight = false;
_windowStore = replaceNewestChannelWindow(
_windowStore,
page,
retainLiveSummaryRootIds: _liveSummaryRootsDuringInitialWindowQuery,
);
_liveSummaryRootsDuringInitialWindowQuery.clear();
_usingChannelWindow = true;
_reachedOldest = !channelWindowHasMore(_windowStore);
return flattenChannelWindowEvents(_windowStore);
} catch (error) {
_initialWindowQueryInFlight = false;
_liveSummaryRootsDuringInitialWindowQuery.clear();
debugPrint(
'[ChannelMessagesNotifier] channel window unavailable for $channelId, falling back to WS history: $error',
);
_usingChannelWindow = false;
final history = await session.fetchHistory(
NostrFilters.messages(channelId),
);
history.sort((a, b) => a.createdAt.compareTo(b.createdAt));
return history;
}
}
Future<ChannelWindowPage> _fetchWindowPage(
RelaySessionNotifier session,
ChannelPageCursor? cursor,
) async {
final events = await session.queryRelay([_channelWindowFilter(cursor)]);
return parseChannelWindowResponse(events, channelId, cursor);
}
NostrFilter _channelWindowFilter(ChannelPageCursor? cursor) => NostrFilter(
kinds: EventKind.channelTimelineContentKinds,
tags: {
'#h': [channelId],
},
limit: 50,
until: cursor?.createdAt,
extensions: {
'top_level': true,
'include_summaries': true,
'include_aux': true,
if (cursor != null) 'before_id': cursor.eventId,
},
);
void _handleLiveEvent(NostrEvent event, {bool authoritative = true}) {
// A live summary can race the initial channel-window query. Buffer it in
// the window store even before that query installs its first page, rather
// than treating metadata as an ordinary websocket timeline event.
if (event.kind == EventKind.channelThreadSummary && !_usingChannelWindow) {
final rootId = _initialWindowQueryInFlight
? event.getTagValue('e')
: null;
if (_mergeWindowEventIntoStore(event)) {
if (rootId != null) {
_liveSummaryRootsDuringInitialWindowQuery.add(rootId);
}
if (_initInFlight) return;
final current =
state.value ?? _lastKnownMessages ?? const <NostrEvent>[];
_lastKnownMessages = current;
state = AsyncData(current);
}
return;
}
// Reply ownership and its thread-local overlay must transition together.
// The authoritative thread query performs both confirmations after it
// contains the reply; a live echo only triggers that query below.
if (authoritative && event.threadReference.parentId == null) {
_confirmLocalMessages([event.id]);
}
if (_usingChannelWindow) {
_handleWindowLiveEvent(event);
} else {
final current = state.value ?? _lastKnownMessages ?? const <NostrEvent>[];
final merged = _mergeEvent(current, event);
_lastKnownMessages = merged;
state = AsyncData(merged);
}
}
void _handleWindowLiveEvent(NostrEvent event) {
if (!_mergeWindowEventIntoStore(event)) return;
final flattened = _withDeepLinkEvents(
flattenChannelWindowEvents(_windowStore),
);
_lastKnownMessages = flattened;
state = AsyncData(flattened);
}
bool _mergeWindowEventIntoStore(NostrEvent event) {
final isTimelineRow = EventKind.channelTimelineContentKinds.contains(
event.kind,
);
final thread = isTimelineRow ? event.threadReference : null;
if (thread?.parentId != null) {
final rootId = thread?.rootId;
if (rootId != null) {
ref.invalidate(
threadRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
),
);
}
final parentId = thread?.parentId;
if (parentId != null && parentId != rootId) {
ref.invalidate(
threadRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: parentId),
),
);
}
// Replies are kept in the store rather than dropped here, matching
// desktop: the main timeline filters them out at render
// (`buildMainTimelineEntries`), and their parent's "N replies" row needs
// them as the local half of the summary merge when the relay's
// best-effort recount is delayed, lost, or older than this reply.
}
// Thread summaries are neither a timeline row nor an aux event, but they are
// how the root's "N replies" row learns a reply landed — a reply itself
// never reaches the main timeline. Dropping them here meant the count only
// appeared after leaving the channel and coming back, which refetched.
if (!isTimelineRow &&
event.kind != EventKind.channelThreadSummary &&
!EventKind.channelAuxEventKinds.contains(event.kind)) {
return false;
}
final next = mergeLiveChannelWindowEvent(
_windowStore,
event,
isTimelineRow: isTimelineRow,
);
if (identical(next, _windowStore)) return false;
_windowStore = next;
return true;
}
void _confirmLocalMessages(Iterable<String> eventIds) {
ref
.read(pendingLocalMessagesProvider(channelId).notifier)
.confirm(eventIds);
}
/// Adds a just-signed outgoing message before the relay acknowledges it.
/// The live relay echo is deduplicated by event id.
void addLocalMessage(NostrEvent event) {
ref.read(pendingLocalMessagesProvider(channelId).notifier).add(event);
final thread = event.threadReference;
if (thread.parentId != null) {
final rootId = thread.rootId;
if (rootId == null) {
throw StateError('Reply ${event.id} has a parent but no thread root.');
}
ref
.read(
threadLocalRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
).notifier,
)
.add(event);
return;
}
final isTimelineRow = EventKind.channelTimelineContentKinds.contains(
event.kind,
);
if (!_usingChannelWindow && isTimelineRow) {
_windowStore = mergeLiveChannelWindowEvent(
_windowStore,
event,
isTimelineRow: true,
);
}
_handleLiveEvent(event, authoritative: false);
}
/// Releases rollback ownership after the publish future succeeds. The
/// optimistic row (and any thread overlay) remains visible until relay data
/// replaces it, because OK and EVENT delivery are unordered.
void completeLocalMessage(String eventId) {
_confirmLocalMessages([eventId]);
}
/// Rolls back a local message when its publish is rejected or times out.
void removeLocalMessage(String eventId) {
final pending = ref
.read(pendingLocalMessagesProvider(channelId).notifier)
.take(eventId);
if (pending == null) return;
final thread = pending.threadReference;
if (thread.parentId != null) {
final rootId = thread.rootId;
if (rootId == null) {
throw StateError('Reply $eventId has a parent but no thread root.');
}
ref
.read(
threadLocalRepliesProvider(
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
).notifier,
)
.remove(eventId);
return;
}
final nextOverlay = _windowStore.liveOverlay
.where((event) => event.id != eventId)
.toList();
if (nextOverlay.length != _windowStore.liveOverlay.length) {
_windowStore = ChannelWindowStore(
pages: _windowStore.pages,
liveOverlay: nextOverlay,
liveAux: _windowStore.liveAux,
liveThreadSummaries: _windowStore.liveThreadSummaries,
);
}
final current = state.value ?? _lastKnownMessages ?? const <NostrEvent>[];
final next = current.where((event) => event.id != eventId).toList();
_lastKnownMessages = next;
state = AsyncData(next);
}
static List<NostrEvent> _mergeEvent(
List<NostrEvent> current,
NostrEvent incoming,
) {
if (current.any((e) => e.id == incoming.id)) return current;
final updated = [...current, incoming];
updated.sort((a, b) {
final createdAt = a.createdAt.compareTo(b.createdAt);
return createdAt != 0 ? createdAt : a.id.compareTo(b.id);
});
return updated;
}
bool _isCurrentInit(int initVersion) => initVersion == _initVersion;
void _clearSubscription() {
_unsubscribe?.call();
_unsubscribe = null;
}
bool get reachedOldest => _reachedOldest;
/// Loads specific deep-link targets that may fall outside the newest window.
Future<void> loadEventsById(Iterable<String> eventIds) async {
final ids = eventIds.where((id) => id.isNotEmpty).toSet();
if (ids.isEmpty) return;
_retainedDeepLinkEventIds.addAll(ids);
final existing = state.value ?? const <NostrEvent>[];
for (final event in existing) {
if (ids.contains(event.id)) _deepLinkEvents[event.id] = event;
}
ids.removeAll(_deepLinkEvents.keys);
if (ids.isEmpty) return;
final events = await ref
.read(relaySessionProvider.notifier)
.fetchHistory(
NostrFilter(
kinds: EventKind.channelTimelineContentKinds,
ids: ids.toList(),
limit: ids.length,
),
);
for (final event in events) {
if (event.channelId == channelId &&
_retainedDeepLinkEventIds.contains(event.id)) {
_deepLinkEvents[event.id] = event;
}
}
// Let the initial history load publish the complete timeline once it
// finishes. Publishing a target-only list here would make the UI consume
// its one-shot jump against a provisional ordering.
if (_initInFlight) return;
final merged = _withDeepLinkEvents(
state.value ?? _lastKnownMessages ?? const [],
);
_lastKnownMessages = merged;
state = AsyncData(merged);
}
/// Stops pinning deep-link-only events into subsequent window rebuilds.
void releaseDeepLinkEvents(Iterable<String> eventIds) {
for (final id in eventIds) {
_retainedDeepLinkEventIds.remove(id);
_deepLinkEvents.remove(id);
}
}
List<NostrEvent> _withDeepLinkEvents(List<NostrEvent> events) {
final ids = events.map((event) => event.id).toSet();
return [
...events,
..._deepLinkEvents.values.where((event) => ids.add(event.id)),
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
}
Future<bool> fetchOlder() async {
if (_reachedOldest || _initInFlight) return false;
final session = ref.read(relaySessionProvider.notifier);
if (_usingChannelWindow) {
final cursor = channelWindowNextCursor(_windowStore);
if (cursor == null) {
_reachedOldest = true;
return false;
}
try {
final page = await _fetchWindowPage(session, cursor);
_windowStore = appendOlderChannelWindow(_windowStore, page);
_reachedOldest = !channelWindowHasMore(_windowStore);
final flattened = _withDeepLinkEvents(
flattenChannelWindowEvents(_windowStore),
);
_lastKnownMessages = flattened;
state = AsyncData(flattened);
return page.rows.isNotEmpty || page.aux.isNotEmpty;
} catch (error) {
debugPrint(
'[ChannelMessagesNotifier] failed to fetch older channel window page for $channelId: $error',
);
return false;
}
}
final currentEvents = state.value;
if (currentEvents == null || currentEvents.isEmpty) return false;
final oldest = currentEvents.first.createdAt;
final older = await session.fetchHistory(
NostrFilters.messages(channelId, limit: 100, until: oldest),
);
if (older.isEmpty) {
_reachedOldest = true;
return false;
}
final currentIds = state.value?.map((e) => e.id).toSet() ?? {};
final deduped = older.where((e) => !currentIds.contains(e.id)).toList();
if (deduped.isEmpty) {
_reachedOldest = true;
return false;
}
state = state.whenData((events) {
final merged = [...deduped, ...events];
merged.sort((a, b) => a.createdAt.compareTo(b.createdAt));
_lastKnownMessages = merged;
return merged;
});
return true;
}
}
int _currentUnixSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
final channelMessagesProvider =
NotifierProvider.family<
ChannelMessagesNotifier,
AsyncValue<List<NostrEvent>>,
String
>(ChannelMessagesNotifier.new);
@@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../../shared/crypto/nip44.dart';
import '../../../shared/relay/relay.dart';
import '../../../shared/read_state/read_state_time.dart';
import 'channel_mutes_storage.dart';
class ChannelMutesCrypto {
final Uint8List _conversationKey;
ChannelMutesCrypto(String nsec, String pubkey)
: _conversationKey = _deriveKey(nsec, pubkey);
static Uint8List _deriveKey(String nsec, String pubkey) {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
return getConversationKey(privkeyHex, pubkey);
}
String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext);
String decrypt(String ciphertext) =>
nip44Decrypt(_conversationKey, ciphertext);
}
class ChannelMutesManager {
final String pubkey;
final ChannelMutesStorage _storage;
final ChannelMutesCrypto _crypto;
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final VoidCallback _onChanged;
ChannelMuteStore _store;
ChannelMuteStore? _lastPublishedStore;
Timer? _publishDebounce;
int _lastRemoteCreatedAt = 0;
String? _lastRemoteEventId;
void Function()? _unsubscribe;
bool _disposed = false;
ChannelMutesManager({
required this.pubkey,
required SharedPreferences prefs,
required ChannelMutesCrypto crypto,
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
}) : _storage = ChannelMutesStorage(prefs),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged,
_store = ChannelMutesStorage(prefs).read(pubkey);
ChannelMuteStore get store => _store;
Future<void> initialize() async {
if (_disposed) return;
if (!_remoteEnabled || _relaySession == null) {
_onChanged();
return;
}
await _fetchAndMerge();
await _startLiveSubscription();
_onChanged();
}
void dispose({bool flushPending = true}) {
if (_disposed) return;
_disposed = true;
final hadPending = _publishDebounce != null;
_publishDebounce?.cancel();
_publishDebounce = null;
if (flushPending && hadPending && _remoteEnabled) {
unawaited(_publish(allowDisposed: true));
}
_unsubscribe?.call();
_unsubscribe = null;
}
void muteChannel(String channelId) {
if (_disposed) return;
final entry = ChannelMuteEntry(
muted: true,
updatedAt: currentUnixSeconds(),
);
_store = ChannelMuteStore(channels: {..._store.channels, channelId: entry});
_persist();
markDirty();
}
void unmuteChannel(String channelId) {
if (_disposed) return;
final entry = ChannelMuteEntry(
muted: false,
updatedAt: currentUnixSeconds(),
);
_store = ChannelMuteStore(channels: {..._store.channels, channelId: entry});
_persist();
markDirty();
}
void markDirty() {
if (!_remoteEnabled || _disposed) return;
_publishDebounce?.cancel();
_publishDebounce = Timer(const Duration(seconds: 5), () {
_publishDebounce = null;
unawaited(_publish());
});
}
Future<void> _fetchAndMerge() async {
if (_relaySession == null) return;
try {
final events = await _relaySession.fetchHistory(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': ['channel-mutes'],
},
limit: 1,
),
);
_mergeEvents(events);
_persist();
if (!_disposed) _onChanged();
} catch (_) {
// Local state remains usable when relay is unavailable.
}
}
Future<void> _startLiveSubscription() async {
if (_relaySession == null) return;
try {
_unsubscribe = await _relaySession.subscribe(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': ['channel-mutes'],
},
limit: 1,
),
_handleIncomingEvent,
);
} catch (_) {
// Non-fatal — local state and history still work.
}
}
void _mergeEvents(List<NostrEvent> events) {
for (final event in events) {
if (event.pubkey != pubkey) continue;
_mergeEvent(event);
}
}
void _mergeEvent(NostrEvent event) {
// Only process channel-mutes d-tag events.
final dTag = event.getTagValue('d');
if (dTag != 'channel-mutes') return;
try {
final plaintext = _crypto.decrypt(event.content);
final parsed = jsonDecode(plaintext);
if (parsed is! Map<String, dynamic>) return;
final incoming = ChannelMuteStore.fromJson(parsed);
// Gate on createdAt: ignore events older than what we've already seen.
final isNewer =
event.createdAt > _lastRemoteCreatedAt ||
(event.createdAt == _lastRemoteCreatedAt &&
event.id.compareTo(_lastRemoteEventId ?? '') > 0);
if (isNewer) {
_lastRemoteCreatedAt = event.createdAt;
_lastRemoteEventId = event.id;
// Per-channel merge: keep the entry with the highest updatedAt for each channel.
_store = mergeStores(_store, incoming);
_persist();
}
} catch (_) {
// Decryption failure or parse error — keep existing state.
}
}
void _handleIncomingEvent(NostrEvent event) {
if (_disposed) return;
_mergeEvent(event);
if (!_disposed) _onChanged();
}
bool _isIdenticalToLastPublished() {
final last = _lastPublishedStore;
if (last == null) return false;
if (last.channels.length != _store.channels.length) return false;
for (final key in _store.channels.keys) {
final lastEntry = last.channels[key];
final currentEntry = _store.channels[key];
if (lastEntry == null ||
lastEntry.muted != currentEntry!.muted ||
lastEntry.updatedAt != currentEntry.updatedAt) {
return false;
}
}
return true;
}
Future<void> _publish({bool allowDisposed = false}) async {
if ((!allowDisposed && _disposed) ||
!_remoteEnabled ||
_signedEventRelay == null) {
return;
}
// Read-before-write: merge remote state before publishing
await _fetchAndMerge();
// No-op suppression: skip if nothing changed
if (_isIdenticalToLastPublished()) return;
try {
final payload = jsonEncode(_store.toJson());
final ciphertext = _crypto.encrypt(payload);
final createdAt = max(currentUnixSeconds(), _lastRemoteCreatedAt + 1);
await _signedEventRelay.submit(
kind: EventKind.readState,
content: ciphertext,
tags: [
['d', 'channel-mutes'],
['t', 'channel-mutes'],
],
createdAt: createdAt,
);
_lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt);
_lastPublishedStore = ChannelMuteStore(channels: Map.of(_store.channels));
} catch (error) {
debugPrint('[ChannelMutesManager] publish failed: $error');
}
}
void _persist() {
_storage.write(pubkey, _store);
}
}
@@ -0,0 +1,115 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart';
import '../../../shared/community/community_provider.dart';
import 'channel_mutes_manager.dart';
import 'channel_mutes_storage.dart';
class ChannelMutesState {
final bool isReady;
final ChannelMuteStore store;
/// Bumped on every change to force downstream rebuilds.
final int version;
const ChannelMutesState({
this.isReady = false,
this.store = const ChannelMuteStore(),
this.version = 0,
});
}
class ChannelMutesNotifier extends Notifier<ChannelMutesState> {
ChannelMutesManager? _manager;
@override
ChannelMutesState build() {
_manager?.dispose(flushPending: false);
_manager = null;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
// Rebuild when the active community changes (pubkey may differ).
ref.watch(activeCommunityProvider);
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
return const ChannelMutesState();
}
final pubkey = _safePubkeyFromNsec(nsec);
if (pubkey == null || pubkey.isEmpty) {
return const ChannelMutesState();
}
final ChannelMutesCrypto crypto;
try {
crypto = ChannelMutesCrypto(nsec, pubkey);
} catch (_) {
return const ChannelMutesState();
}
final prefs = ref.read(savedPrefsProvider);
final signedRelay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
);
late final ChannelMutesManager manager;
manager = ChannelMutesManager(
pubkey: pubkey,
prefs: prefs,
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: sessionState.status == SessionStatus.connected,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
ref.onDispose(() {
manager.dispose();
if (_manager == manager) {
_manager = null;
}
});
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_emitManagerState(manager);
});
return ChannelMutesState(isReady: false, store: manager.store, version: 1);
}
void muteChannel(String channelId) => _manager?.muteChannel(channelId);
void unmuteChannel(String channelId) => _manager?.unmuteChannel(channelId);
void _emitManagerState(ChannelMutesManager manager) {
if (_manager != manager) return;
state = ChannelMutesState(
isReady: true,
store: manager.store,
version: state.version + 1,
);
}
}
final channelMutesProvider =
NotifierProvider<ChannelMutesNotifier, ChannelMutesState>(
ChannelMutesNotifier.new,
);
String? _safePubkeyFromNsec(String nsec) {
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) {
return null;
}
}
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
String channelMutesKey(String pubkey) => 'buzz.channel-mutes.v1:$pubkey';
class ChannelMuteEntry {
final bool muted;
final int updatedAt;
const ChannelMuteEntry({required this.muted, required this.updatedAt});
Map<String, dynamic> toJson() => {'muted': muted, 'updatedAt': updatedAt};
factory ChannelMuteEntry.fromJson(Map<String, dynamic> json) =>
ChannelMuteEntry(
muted: json['muted'] as bool,
updatedAt: json['updatedAt'] as int,
);
}
class ChannelMuteStore {
final int version;
final Map<String, ChannelMuteEntry> channels;
const ChannelMuteStore({this.version = 1, this.channels = const {}});
Map<String, dynamic> toJson() => {
'version': version,
'channels': {for (final e in channels.entries) e.key: e.value.toJson()},
};
factory ChannelMuteStore.fromJson(Map<String, dynamic> json) {
final rawChannels = json['channels'];
final channels = <String, ChannelMuteEntry>{};
if (rawChannels is Map) {
for (final entry in rawChannels.entries) {
if (entry.key is String && entry.value is Map<String, dynamic>) {
final v = entry.value as Map<String, dynamic>;
if (v['muted'] is bool && v['updatedAt'] is int) {
channels[entry.key as String] = ChannelMuteEntry.fromJson(v);
}
}
}
}
return ChannelMuteStore(version: 1, channels: channels);
}
}
ChannelMuteStore mergeStores(ChannelMuteStore local, ChannelMuteStore remote) {
// Per-channel max-updatedAt merge:
// For each channel ID in the union, keep the entry with the highest updatedAt.
final merged = <String, ChannelMuteEntry>{...local.channels};
for (final entry in remote.channels.entries) {
final existing = merged[entry.key];
if (existing == null || entry.value.updatedAt > existing.updatedAt) {
merged[entry.key] = entry.value;
}
}
return ChannelMuteStore(channels: merged);
}
class ChannelMutesStorage {
final SharedPreferences _prefs;
ChannelMutesStorage(this._prefs);
ChannelMuteStore read(String pubkey) {
final raw = _prefs.getString(channelMutesKey(pubkey));
if (raw == null || raw.isEmpty) {
return const ChannelMuteStore();
}
try {
final parsed = jsonDecode(raw);
if (parsed is! Map<String, dynamic>) {
return const ChannelMuteStore();
}
if (parsed['version'] != 1) {
return const ChannelMuteStore();
}
return ChannelMuteStore.fromJson(parsed);
} catch (_) {
return const ChannelMuteStore();
}
}
void write(String pubkey, ChannelMuteStore store) {
_prefs.setString(channelMutesKey(pubkey), jsonEncode(store.toJson()));
}
}
@@ -0,0 +1,501 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../../../shared/crypto/nip44.dart';
import '../../../shared/relay/relay.dart';
import '../../../shared/read_state/read_state_time.dart';
import 'channel_sections_storage.dart';
const _uuid = Uuid();
class ChannelSectionsCrypto {
final Uint8List _conversationKey;
ChannelSectionsCrypto(String nsec, String pubkey)
: _conversationKey = _deriveKey(nsec, pubkey);
static Uint8List _deriveKey(String nsec, String pubkey) {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
return getConversationKey(privkeyHex, pubkey);
}
String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext);
String decrypt(String ciphertext) =>
nip44Decrypt(_conversationKey, ciphertext);
}
class ChannelSectionsManager {
final String pubkey;
final ChannelSectionsStorage _storage;
final ChannelSectionsCrypto _crypto;
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final VoidCallback _onChanged;
ChannelSectionStore _store;
ChannelSectionStore? _lastPublishedStore;
Timer? _publishDebounce;
int _lastRemoteCreatedAt = 0;
String? _lastRemoteEventId;
void Function()? _unsubscribe;
bool _disposed = false;
/// Base delay for the startup-sync retry backoff. Overridable in tests.
final Duration _startupRetryBaseDelay;
Timer? _startupRetryTimer;
int _startupRetryAttempt = 0;
bool _startupFetchSucceeded = false;
Future<void>? _syncInFlight;
bool _syncAgain = false;
int _subscriptionGeneration = 0;
ChannelSectionsManager({
required this.pubkey,
required SharedPreferences prefs,
required ChannelSectionsCrypto crypto,
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
@visibleForTesting
Duration startupRetryBaseDelay = const Duration(seconds: 2),
}) : _storage = ChannelSectionsStorage(prefs),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged,
_startupRetryBaseDelay = startupRetryBaseDelay,
_store = ChannelSectionsStorage(prefs).read(pubkey);
ChannelSectionStore get store => _store;
Future<void> initialize() async {
if (_disposed) return;
if (!_remoteEnabled || _relaySession == null) {
_onChanged();
return;
}
await _syncWithRelay();
_onChanged();
}
/// One startup-sync attempt: fetch the remote blob, then start the live
/// subscription. Either step can lose a transient race on cold start (the
/// relay rate-limits the burst of per-channel subscriptions and rejects
/// with `rate-limited: quota exceeded`) — retry with backoff instead of
/// silently giving up, which left desktop-created groups invisible until
/// an unrelated refetch.
///
/// Retries are intentionally unbounded for the manager's lifetime: this
/// sync must eventually land for groups to appear at all, and at the 30s
/// delay ceiling a persistent retry is cheap. Do not "fix" this into a
/// bounded loop — giving up permanently is the exact bug this replaces.
Future<void> _syncWithRelay() {
if (_disposed) return Future.value();
final inFlight = _syncInFlight;
if (inFlight != null) {
_syncAgain = true;
return inFlight;
}
final sync = _runSyncWithRelay();
_syncInFlight = sync;
return sync.whenComplete(() {
_syncInFlight = null;
if (_disposed || !_syncAgain) return;
_syncAgain = false;
unawaited(_syncWithRelay());
});
}
Future<void> _runSyncWithRelay() async {
if (!_startupFetchSucceeded) {
final fetched = await _fetchAndMerge();
if (_disposed) return;
_startupFetchSucceeded = fetched;
}
final subscribed = _unsubscribe != null || await _startLiveSubscription();
if (_disposed) return;
if (!_startupFetchSucceeded || !subscribed) {
_scheduleStartupRetry();
} else {
// Fully recovered — later transient failures (e.g. a late relay
// CLOSED) start backing off from the base delay again instead of the
// ceiling the cold start climbed to.
_startupRetryAttempt = 0;
}
}
void _scheduleStartupRetry() {
if (_disposed) return;
_startupRetryTimer?.cancel();
// The inner shift cap is overflow protection, not the delay policy: the
// consecutive-failure counter is unbounded, and an unchecked `<<`
// past 62 wraps negative, which would make the Timer fire immediately in
// a hot loop. At the default 2s base the outer 30s clamp is what callers
// actually observe (2s, 4s, …, 30s); the shift cap only bites for the
// tiny injected bases used in tests.
final delayMs = min(
_startupRetryBaseDelay.inMilliseconds << min(_startupRetryAttempt, 5),
30000,
);
_startupRetryAttempt++;
debugPrint(
'[ChannelSectionsManager] startup sync incomplete; '
'retrying in ${delayMs}ms (attempt $_startupRetryAttempt)',
);
_startupRetryTimer = Timer(Duration(milliseconds: delayMs), () {
_startupRetryTimer = null;
unawaited(
_syncWithRelay().then((_) {
if (!_disposed) _onChanged();
}),
);
});
}
void dispose({bool flushPending = true}) {
if (_disposed) return;
_disposed = true;
_subscriptionGeneration++;
_syncAgain = false;
_startupRetryTimer?.cancel();
_startupRetryTimer = null;
final hadPending = _publishDebounce != null;
_publishDebounce?.cancel();
_publishDebounce = null;
if (flushPending && hadPending && _remoteEnabled) {
unawaited(_publish(allowDisposed: true));
}
_unsubscribe?.call();
_unsubscribe = null;
}
void createSection(String name) {
if (_disposed) return;
final maxOrder = _store.sections.fold<int>(
-1,
(max, s) => s.order > max ? s.order : max,
);
final section = ChannelSection(
id: _uuid.v4(),
name: name.trim(),
order: maxOrder + 1,
);
_store = ChannelSectionStore(
sections: [..._store.sections, section],
assignments: _store.assignments,
);
_persist();
markDirty();
}
void renameSection(String sectionId, String newName) {
if (_disposed) return;
_store = ChannelSectionStore(
sections: [
for (final s in _store.sections)
if (s.id == sectionId)
ChannelSection(
id: s.id,
name: newName.trim(),
icon: s.icon,
order: s.order,
)
else
s,
],
assignments: _store.assignments,
);
_persist();
markDirty();
}
void deleteSection(String sectionId) {
if (_disposed) return;
final updatedAssignments = Map<String, String>.from(_store.assignments)
..removeWhere((_, sid) => sid == sectionId);
_store = ChannelSectionStore(
sections: [
for (final s in _store.sections)
if (s.id != sectionId) s,
],
assignments: updatedAssignments,
);
_persist();
markDirty();
}
void moveSectionUp(String sectionId) {
if (_disposed) return;
final sorted = _sortedSections();
final idx = sorted.indexWhere((s) => s.id == sectionId);
if (idx <= 0) return;
_swapOrders(sorted, idx, idx - 1);
markDirty();
}
void moveSectionDown(String sectionId) {
if (_disposed) return;
final sorted = _sortedSections();
final idx = sorted.indexWhere((s) => s.id == sectionId);
if (idx < 0 || idx >= sorted.length - 1) return;
_swapOrders(sorted, idx, idx + 1);
markDirty();
}
void assignChannel(String channelId, String sectionId) {
if (_disposed) return;
final updated = Map<String, String>.from(_store.assignments)
..[channelId] = sectionId;
_store = ChannelSectionStore(
sections: _store.sections,
assignments: updated,
);
_persist();
markDirty();
}
void unassignChannel(String channelId) {
if (_disposed) return;
final updated = Map<String, String>.from(_store.assignments)
..remove(channelId);
_store = ChannelSectionStore(
sections: _store.sections,
assignments: updated,
);
_persist();
markDirty();
}
void markDirty() {
if (!_remoteEnabled || _disposed) return;
_publishDebounce?.cancel();
_publishDebounce = Timer(const Duration(seconds: 5), () {
_publishDebounce = null;
unawaited(_publish());
});
}
/// Returns whether the fetch reached the relay (regardless of whether a
/// remote blob exists).
Future<bool> _fetchAndMerge({bool allowDisposed = false}) async {
if (_relaySession == null) return false;
try {
final events = await _relaySession.fetchHistory(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': ['channel-sections'],
},
limit: 1,
),
);
if (_disposed && !allowDisposed) return false;
_mergeEvents(events);
_persist();
if (!_disposed) _onChanged();
return true;
} catch (error) {
debugPrint('[ChannelSectionsManager] fetch failed: $error');
// Local state remains usable when relay is unavailable.
return false;
}
}
/// Returns whether the live subscription was established.
Future<bool> _startLiveSubscription() async {
if (_relaySession == null || _disposed) return false;
final generation = ++_subscriptionGeneration;
try {
final unsubscribe = await _relaySession.subscribe(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': ['channel-sections'],
},
limit: 1,
),
_handleIncomingEvent,
onClosed: (message) => _handleSubscriptionClosed(generation, message),
);
if (_disposed || generation != _subscriptionGeneration) {
unsubscribe();
return false;
}
_unsubscribe = unsubscribe;
return true;
} catch (error) {
debugPrint('[ChannelSectionsManager] live subscription failed: $error');
// Non-fatal — local state and history still work; retried by the
// startup-sync backoff.
return false;
}
}
/// A relay `CLOSED` can arrive after `subscribe()` already reported
/// success: the 500ms readiness wait times out silently under load, and
/// the rate-limit rejection lands later. Without this handler the manager
/// would keep a dead subscription and never retry — the exact
/// load-correlated cold-start failure this retry exists for.
void _handleSubscriptionClosed(int generation, String message) {
if (_disposed || generation != _subscriptionGeneration) return;
debugPrint(
'[ChannelSectionsManager] live subscription closed by relay: $message',
);
_unsubscribe = null;
_subscriptionGeneration++;
_scheduleStartupRetry();
}
void _mergeEvents(List<NostrEvent> events) {
for (final event in events) {
if (event.pubkey != pubkey) continue;
_mergeEvent(event);
}
}
void _mergeEvent(NostrEvent event) {
// Only process channel-sections d-tag events.
final dTag = event.getTagValue('d');
if (dTag != 'channel-sections') return;
try {
final plaintext = _crypto.decrypt(event.content);
final parsed = jsonDecode(plaintext);
if (parsed is! Map<String, dynamic>) return;
final incoming = ChannelSectionStore.fromJson(parsed);
// Last-write-wins: newer createdAt wins; tie-break by event ID.
final isNewer =
event.createdAt > _lastRemoteCreatedAt ||
(event.createdAt == _lastRemoteCreatedAt &&
event.id.compareTo(_lastRemoteEventId ?? '') > 0);
if (isNewer) {
_lastRemoteCreatedAt = event.createdAt;
_lastRemoteEventId = event.id;
_store = incoming;
_persist();
}
} catch (_) {
// Decryption failure or parse error — keep existing state.
}
}
void _handleIncomingEvent(NostrEvent event) {
if (_disposed) return;
_mergeEvent(event);
if (!_disposed) _onChanged();
}
bool _isIdenticalToLastPublished() {
final last = _lastPublishedStore;
if (last == null) return false;
if (last.sections.length != _store.sections.length) return false;
if (last.assignments.length != _store.assignments.length) return false;
for (var i = 0; i < _store.sections.length; i++) {
final a = last.sections[i];
final b = _store.sections[i];
if (a.id != b.id ||
a.name != b.name ||
a.icon != b.icon ||
a.order != b.order) {
return false;
}
}
for (final key in _store.assignments.keys) {
if (last.assignments[key] != _store.assignments[key]) return false;
}
return true;
}
Future<void> _publish({bool allowDisposed = false}) async {
if ((!allowDisposed && _disposed) ||
!_remoteEnabled ||
_signedEventRelay == null) {
return;
}
// Read-before-write: merge remote state before publishing
await _fetchAndMerge(allowDisposed: allowDisposed);
// No-op suppression: skip if nothing changed
if (_isIdenticalToLastPublished()) return;
try {
final payload = jsonEncode(_store.toJson());
final ciphertext = _crypto.encrypt(payload);
final createdAt = max(currentUnixSeconds(), _lastRemoteCreatedAt + 1);
await _signedEventRelay.submit(
kind: EventKind.readState,
content: ciphertext,
tags: [
['d', 'channel-sections'],
['t', 'channel-sections'],
],
createdAt: createdAt,
);
_lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt);
_lastPublishedStore = ChannelSectionStore(
sections: List.of(_store.sections),
assignments: Map.of(_store.assignments),
);
} catch (error) {
debugPrint('[ChannelSectionsManager] publish failed: $error');
}
}
void _persist() {
_storage.write(pubkey, _store);
}
List<ChannelSection> _sortedSections() {
final sorted = _store.sections.toList()
..sort((a, b) => a.order.compareTo(b.order));
return sorted;
}
void _swapOrders(List<ChannelSection> sorted, int indexA, int indexB) {
final orderA = sorted[indexA].order;
final orderB = sorted[indexB].order;
final idA = sorted[indexA].id;
final idB = sorted[indexB].id;
_store = ChannelSectionStore(
sections: [
for (final s in _store.sections)
if (s.id == idA)
ChannelSection(id: s.id, name: s.name, icon: s.icon, order: orderB)
else if (s.id == idB)
ChannelSection(id: s.id, name: s.name, icon: s.icon, order: orderA)
else
s,
],
assignments: _store.assignments,
);
_persist();
}
}
@@ -0,0 +1,133 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart';
import '../../../shared/community/community_provider.dart';
import 'channel_sections_manager.dart';
import 'channel_sections_storage.dart';
class ChannelSectionsState {
final bool isReady;
final ChannelSectionStore store;
/// Bumped on every change to force downstream rebuilds.
final int version;
const ChannelSectionsState({
this.isReady = false,
this.store = const ChannelSectionStore(),
this.version = 0,
});
}
class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
ChannelSectionsManager? _manager;
@override
ChannelSectionsState build() {
_manager?.dispose(flushPending: false);
_manager = null;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
// Rebuild when the active community changes (pubkey may differ).
ref.watch(activeCommunityProvider);
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
return const ChannelSectionsState();
}
final pubkey = _safePubkeyFromNsec(nsec);
if (pubkey == null || pubkey.isEmpty) {
return const ChannelSectionsState();
}
final ChannelSectionsCrypto crypto;
try {
crypto = ChannelSectionsCrypto(nsec, pubkey);
} catch (_) {
return const ChannelSectionsState();
}
final prefs = ref.read(savedPrefsProvider);
final signedRelay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
);
late final ChannelSectionsManager manager;
manager = ChannelSectionsManager(
pubkey: pubkey,
prefs: prefs,
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: sessionState.status == SessionStatus.connected,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
ref.onDispose(() {
manager.dispose();
if (_manager == manager) {
_manager = null;
}
});
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_emitManagerState(manager);
});
return ChannelSectionsState(
isReady: false,
store: manager.store,
version: 1,
);
}
void createSection(String name) => _manager?.createSection(name);
void renameSection(String sectionId, String newName) =>
_manager?.renameSection(sectionId, newName);
void deleteSection(String sectionId) => _manager?.deleteSection(sectionId);
void moveSectionUp(String sectionId) => _manager?.moveSectionUp(sectionId);
void moveSectionDown(String sectionId) =>
_manager?.moveSectionDown(sectionId);
void assignChannel(String channelId, String sectionId) =>
_manager?.assignChannel(channelId, sectionId);
void unassignChannel(String channelId) =>
_manager?.unassignChannel(channelId);
void _emitManagerState(ChannelSectionsManager manager) {
if (_manager != manager) return;
state = ChannelSectionsState(
isReady: true,
store: manager.store,
version: state.version + 1,
);
}
}
final channelSectionsProvider =
NotifierProvider<ChannelSectionsNotifier, ChannelSectionsState>(
ChannelSectionsNotifier.new,
);
String? _safePubkeyFromNsec(String nsec) {
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) {
return null;
}
}
@@ -0,0 +1,116 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
String channelSectionsKey(String pubkey) => 'buzz.channel-sections.v1:$pubkey';
class ChannelSection {
final String id;
final String name;
final String? icon;
final int order;
const ChannelSection({
required this.id,
required this.name,
this.icon,
required this.order,
});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (icon != null) 'icon': icon,
'order': order,
};
factory ChannelSection.fromJson(Map<String, dynamic> json) => ChannelSection(
id: json['id'] as String,
name: json['name'] as String,
icon: json['icon'] is String ? json['icon'] as String : null,
order: json['order'] as int,
);
}
class ChannelSectionStore {
final int version;
final List<ChannelSection> sections;
final Map<String, String> assignments;
const ChannelSectionStore({
this.version = 1,
this.sections = const [],
this.assignments = const {},
});
Map<String, dynamic> toJson() => {
'version': version,
'sections': sections.map((s) => s.toJson()).toList(),
'assignments': assignments,
};
factory ChannelSectionStore.fromJson(Map<String, dynamic> json) {
final rawSections = json['sections'];
final sections = <ChannelSection>[];
if (rawSections is List) {
for (final entry in rawSections) {
if (entry is Map<String, dynamic> &&
entry['id'] is String &&
entry['name'] is String &&
entry['order'] is int) {
sections.add(ChannelSection.fromJson(entry));
}
}
}
final rawAssignments = json['assignments'];
final assignments = <String, String>{};
if (rawAssignments is Map) {
for (final entry in rawAssignments.entries) {
if (entry.key is String && entry.value is String) {
assignments[entry.key as String] = entry.value as String;
}
}
}
// Strip assignments referencing sections that don't exist.
final sectionIds = {for (final s in sections) s.id};
assignments.removeWhere((_, sectionId) => !sectionIds.contains(sectionId));
return ChannelSectionStore(
version: 1,
sections: sections,
assignments: assignments,
);
}
}
class ChannelSectionsStorage {
final SharedPreferences _prefs;
ChannelSectionsStorage(this._prefs);
ChannelSectionStore read(String pubkey) {
final raw = _prefs.getString(channelSectionsKey(pubkey));
if (raw == null || raw.isEmpty) {
return const ChannelSectionStore();
}
try {
final parsed = jsonDecode(raw);
if (parsed is! Map<String, dynamic>) {
return const ChannelSectionStore();
}
if (parsed['version'] != 1) {
return const ChannelSectionStore();
}
return ChannelSectionStore.fromJson(parsed);
} catch (_) {
return const ChannelSectionStore();
}
}
void write(String pubkey, ChannelSectionStore store) {
_prefs.setString(channelSectionsKey(pubkey), jsonEncode(store.toJson()));
}
}
@@ -0,0 +1,322 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../../shared/crypto/nip44.dart';
import '../../../shared/relay/relay.dart';
import '../../../shared/read_state/read_state_time.dart';
import 'channel_sort_storage.dart';
const _dTag = 'channel-sort';
const _maxClockDriftSeconds = 300;
class ChannelSortCrypto {
final Uint8List _conversationKey;
ChannelSortCrypto(String nsec, String pubkey)
: _conversationKey = _deriveKey(nsec, pubkey);
static Uint8List _deriveKey(String nsec, String pubkey) {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
return getConversationKey(privkeyHex, pubkey);
}
String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext);
String decrypt(String ciphertext) =>
nip44Decrypt(_conversationKey, ciphertext);
}
/// Desktop-compatible encrypted NIP-78 sync for per-group sort preferences.
/// Remote state is ordinary whole-blob LWW: unlike the rejected #2829 design,
/// local state never vetoes a newer remote blob or leapfrogs an unseen edit.
class ChannelSortManager {
final String pubkey;
final String relayUrl;
final ChannelSortStorage _storage;
final ChannelSortCrypto _crypto;
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final VoidCallback _onChanged;
final Duration _startupRetryBaseDelay;
final Duration _publishDelay;
ChannelSortStore _store;
ChannelSortSyncState _syncState;
Timer? _publishDebounce;
Timer? _startupRetryTimer;
int _startupRetryAttempt = 0;
int _lastRemoteCreatedAt = 0;
String _lastRemoteEventId = '';
int _generation = 0;
void Function()? _unsubscribe;
bool _disposed = false;
ChannelSortManager({
required this.pubkey,
required this.relayUrl,
required SharedPreferences prefs,
required ChannelSortCrypto crypto,
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
@visibleForTesting
Duration startupRetryBaseDelay = const Duration(seconds: 2),
@visibleForTesting Duration publishDelay = const Duration(seconds: 2),
}) : _storage = ChannelSortStorage(prefs),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged,
_startupRetryBaseDelay = startupRetryBaseDelay,
_publishDelay = publishDelay,
_store = ChannelSortStorage(prefs).read(pubkey, relayUrl),
_syncState = ChannelSortStorage(prefs).readSyncState(pubkey, relayUrl) {
_lastRemoteCreatedAt = _syncState.updatedAt;
_lastRemoteEventId = _syncState.eventId;
}
ChannelSortStore get store => _store;
ChannelSortMode sortModeFor(String groupKey) =>
_store.groups[groupKey] ?? kDefaultSortMode;
Future<void> initialize() async {
if (_disposed || !_remoteEnabled || _relaySession == null) {
if (!_disposed) _onChanged();
return;
}
await _syncWithRelay();
if (!_disposed) _onChanged();
}
Future<void> _syncWithRelay() async {
final firstFetch = await _fetchAndApply();
final subscribed = _unsubscribe != null || await _startLiveSubscription();
// Fetch again after the subscription is ready. This closes the event gap
// between history and live setup (and catches anything published while a
// rate-limited subscription was retrying).
final secondFetch = subscribed ? await _fetchAndApply() : null;
if (firstFetch == null || !subscribed || secondFetch == null) {
_scheduleStartupRetry();
return;
}
_startupRetryAttempt = 0;
if (_syncState.hasPendingLocalChanges ||
(!firstFetch && !secondFetch && _store.groups.isNotEmpty)) {
_schedulePublish();
}
}
void _scheduleStartupRetry() {
if (_disposed) return;
_startupRetryTimer?.cancel();
final delayMs = min(
_startupRetryBaseDelay.inMilliseconds << min(_startupRetryAttempt, 5),
30000,
);
_startupRetryAttempt++;
_startupRetryTimer = Timer(Duration(milliseconds: delayMs), () {
_startupRetryTimer = null;
unawaited(
_syncWithRelay().then((_) {
if (!_disposed) _onChanged();
}),
);
});
}
void setSortModeFor(
String groupKey,
ChannelSortMode mode, {
Iterable<String>? liveSectionIds,
}) {
if (_disposed || _store.groups[groupKey] == mode) return;
final updated = ChannelSortStore(
groups: {..._store.groups, groupKey: mode},
);
_store = liveSectionIds == null
? updated
: stripOrphanedSectionModes(updated, liveSectionIds);
final now = currentUnixSeconds();
final pendingUpdatedAt = min(
max(now, _syncState.pendingUpdatedAt + 1),
now + _maxClockDriftSeconds,
);
_syncState = ChannelSortSyncState(
updatedAt: _syncState.updatedAt,
eventId: _syncState.eventId,
hasPendingLocalChanges: true,
pendingUpdatedAt: pendingUpdatedAt,
);
_generation++;
_persist();
_schedulePublish();
_onChanged();
}
void _schedulePublish() {
if (!_remoteEnabled || _disposed) return;
_publishDebounce?.cancel();
_publishDebounce = Timer(_publishDelay, () {
_publishDebounce = null;
unawaited(_publish());
});
}
Future<bool?> _fetchAndApply() async {
if (_relaySession == null) return null;
try {
final events = await _relaySession.fetchHistory(_filter());
var found = false;
for (final event in events) {
if (event.pubkey != pubkey || event.getTagValue('d') != _dTag) continue;
found = true;
_applyRemote(event);
}
return found;
} catch (error) {
debugPrint('[ChannelSortManager] fetch failed: $error');
return null;
}
}
Future<bool> _startLiveSubscription() async {
if (_relaySession == null) return false;
try {
_unsubscribe = await _relaySession.subscribe(
_filter(),
_handleIncomingEvent,
onClosed: (_) {
if (_disposed) return;
_unsubscribe?.call();
_unsubscribe = null;
_scheduleStartupRetry();
},
);
return true;
} catch (error) {
debugPrint('[ChannelSortManager] subscribe failed: $error');
return false;
}
}
NostrFilter _filter() => NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': [_dTag],
},
limit: 1,
);
void _applyRemote(NostrEvent event) {
if (event.createdAt > currentUnixSeconds() + _maxClockDriftSeconds) return;
if (_syncState.hasPendingLocalChanges &&
event.createdAt <= _syncState.pendingUpdatedAt) {
return;
}
final isNewer =
event.createdAt > _lastRemoteCreatedAt ||
(event.createdAt == _lastRemoteCreatedAt &&
event.id.compareTo(_lastRemoteEventId) < 0);
if (!isNewer) return;
try {
final parsed = jsonDecode(_crypto.decrypt(event.content));
if (parsed is! Map<String, dynamic> || parsed['version'] != 1) return;
final incoming = ChannelSortStore.fromJson(parsed);
_lastRemoteCreatedAt = event.createdAt;
_lastRemoteEventId = event.id;
_syncState = ChannelSortSyncState(
updatedAt: event.createdAt,
eventId: event.id,
);
_publishDebounce?.cancel();
_publishDebounce = null;
_store = incoming;
_generation++;
_persist();
} catch (_) {
// Ignore malformed or undecryptable blobs without advancing the cursor.
}
}
void _handleIncomingEvent(NostrEvent event) {
if (_disposed ||
event.pubkey != pubkey ||
event.getTagValue('d') != _dTag) {
return;
}
final before = _generation;
_applyRemote(event);
if (_generation != before && !_disposed) _onChanged();
}
Future<void> _publish() async {
if (_disposed || !_remoteEnabled || _signedEventRelay == null) return;
final generationAtStart = _generation;
// A newer remote blob wins. If one arrives during this read, _generation
// changes and we abort rather than overwriting it.
final preflight = await _fetchAndApply();
if (_disposed || _generation != generationAtStart) return;
if (preflight == null) {
_schedulePublish();
return;
}
try {
final now = currentUnixSeconds();
final createdAt = max(now, _lastRemoteCreatedAt + 1);
if (createdAt > now + _maxClockDriftSeconds) {
debugPrint(
'[ChannelSortManager] publish delayed: relay cursor '
'is ${createdAt - now}s ahead of local time',
);
_schedulePublish();
return;
}
final ciphertext = _crypto.encrypt(jsonEncode(_store.toJson()));
String? submittedEventId;
await _signedEventRelay.submit(
kind: EventKind.readState,
content: ciphertext,
tags: const [
['d', _dTag],
['t', _dTag],
],
createdAt: createdAt,
onSigned: (event) => submittedEventId = event.id,
);
if (_disposed || _generation != generationAtStart) return;
// Keep local publications strictly ordered for this manager lifetime, as
// desktop does, but never persist that volatile publication cursor.
_lastRemoteCreatedAt = createdAt;
_lastRemoteEventId = submittedEventId ?? '';
_syncState = ChannelSortSyncState(
updatedAt: _syncState.updatedAt,
eventId: _syncState.eventId,
);
_persist();
} catch (error) {
debugPrint('[ChannelSortManager] publish failed: $error');
}
}
void _persist() {
_storage.write(pubkey, relayUrl, _store);
_storage.writeSyncState(pubkey, relayUrl, _syncState);
}
void dispose() {
if (_disposed) return;
_disposed = true;
_publishDebounce?.cancel();
_startupRetryTimer?.cancel();
_unsubscribe?.call();
_unsubscribe = null;
}
}
@@ -0,0 +1,126 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart';
import '../../../shared/community/community_provider.dart';
import 'channel_sort_manager.dart';
import 'channel_sort_storage.dart';
class ChannelSortState {
final bool isReady;
final ChannelSortStore store;
/// Bumped on every change to force downstream rebuilds.
final int version;
const ChannelSortState({
this.isReady = false,
this.store = const ChannelSortStore(),
this.version = 0,
});
ChannelSortMode sortModeFor(String groupKey) =>
store.groups[groupKey] ?? kDefaultSortMode;
}
class ChannelSortNotifier extends Notifier<ChannelSortState> {
ChannelSortManager? _manager;
@override
ChannelSortState build() {
_manager?.dispose();
_manager = null;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
final activeCommunity = ref.watch(activeCommunityProvider).value;
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
return const ChannelSortState();
}
final pubkey = _safePubkeyFromNsec(nsec);
if (pubkey == null || pubkey.isEmpty) {
return const ChannelSortState();
}
final ChannelSortCrypto crypto;
try {
crypto = ChannelSortCrypto(nsec, pubkey);
} catch (_) {
return const ChannelSortState();
}
final relayUrl = activeCommunity?.relayUrl.trim();
if (relayUrl == null || relayUrl.isEmpty) {
return const ChannelSortState();
}
final prefs = ref.read(savedPrefsProvider);
final signedRelay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
);
late final ChannelSortManager manager;
manager = ChannelSortManager(
pubkey: pubkey,
relayUrl: relayUrl,
prefs: prefs,
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: sessionState.status == SessionStatus.connected,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
ref.onDispose(() {
manager.dispose();
if (_manager == manager) {
_manager = null;
}
});
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_emitManagerState(manager);
});
return ChannelSortState(isReady: false, store: manager.store, version: 1);
}
void setSortModeFor(
String groupKey,
ChannelSortMode mode, {
Iterable<String>? liveSectionIds,
}) =>
_manager?.setSortModeFor(groupKey, mode, liveSectionIds: liveSectionIds);
void _emitManagerState(ChannelSortManager manager) {
if (_manager != manager) return;
state = ChannelSortState(
isReady: true,
store: manager.store,
version: state.version + 1,
);
}
}
final channelSortProvider =
NotifierProvider<ChannelSortNotifier, ChannelSortState>(
ChannelSortNotifier.new,
);
String? _safePubkeyFromNsec(String nsec) {
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) {
return null;
}
}
@@ -0,0 +1,219 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../channel.dart';
String normalizeChannelSortRelayUrl(String relayUrl) =>
relayUrl.trim().replaceFirst(RegExp(r'/+$'), '').toLowerCase();
String channelSortKey(String pubkey, String relayUrl) =>
'buzz.channel-sort.v1:$pubkey:${Uri.encodeComponent(normalizeChannelSortRelayUrl(relayUrl))}';
String legacyChannelSortKey(String pubkey) => 'buzz.channel-sort.v1:$pubkey';
/// Per-group sidebar sort mode. The wire values match desktop exactly.
enum ChannelSortMode {
alpha('alpha'),
recent('recent');
final String wireValue;
const ChannelSortMode(this.wireValue);
static ChannelSortMode? fromWire(Object? value) {
if (value == 'alpha') return ChannelSortMode.alpha;
if (value == 'recent') return ChannelSortMode.recent;
return null;
}
}
const ChannelSortMode kDefaultSortMode = ChannelSortMode.alpha;
String sectionSortGroupKey(String sectionId) => 'section:$sectionId';
class ChannelSortStore {
final int version;
final Map<String, ChannelSortMode> groups;
const ChannelSortStore({this.version = 1, this.groups = const {}});
Map<String, dynamic> toJson() => {
'version': version,
'groups': {
for (final entry in groups.entries) entry.key: entry.value.wireValue,
},
};
factory ChannelSortStore.fromJson(Map<String, dynamic> json) {
final groups = <String, ChannelSortMode>{};
final rawGroups = json['groups'];
if (rawGroups is Map) {
for (final entry in rawGroups.entries) {
final mode = ChannelSortMode.fromWire(entry.value);
if (entry.key is String && mode != null) {
groups[entry.key as String] = mode;
}
}
}
return ChannelSortStore(groups: groups);
}
}
ChannelSortStore stripOrphanedSectionModes(
ChannelSortStore store,
Iterable<String> liveSectionIds,
) {
final liveKeys = {for (final id in liveSectionIds) sectionSortGroupKey(id)};
final kept = <String, ChannelSortMode>{
for (final entry in store.groups.entries)
if (!entry.key.startsWith('section:') || liveKeys.contains(entry.key))
entry.key: entry.value,
};
if (kept.length == store.groups.length) return store;
return ChannelSortStore(groups: kept);
}
/// Mobile and desktop both use a case-insensitive deterministic ordering.
/// The id tie-break keeps equal folded names stable across clients.
int compareChannelsByName(Channel left, Channel right) {
final name = left.name.toLowerCase().compareTo(right.name.toLowerCase());
return name != 0 ? name : left.id.compareTo(right.id);
}
List<Channel> sortChannelsForList(
List<Channel> channels,
ChannelSortMode mode,
) {
final sorted = channels.toList();
if (mode == ChannelSortMode.alpha) {
sorted.sort(compareChannelsByName);
return sorted;
}
sorted.sort((left, right) {
final leftMs = left.lastMessageAt?.millisecondsSinceEpoch;
final rightMs = right.lastMessageAt?.millisecondsSinceEpoch;
if (leftMs != null && rightMs != null && leftMs != rightMs) {
return rightMs.compareTo(leftMs);
}
if (leftMs != null && rightMs == null) return -1;
if (leftMs == null && rightMs != null) return 1;
return compareChannelsByName(left, right);
});
return sorted;
}
class ChannelSortSyncState {
final int updatedAt;
final String eventId;
final bool hasPendingLocalChanges;
final int pendingUpdatedAt;
const ChannelSortSyncState({
this.updatedAt = 0,
this.eventId = '',
this.hasPendingLocalChanges = false,
this.pendingUpdatedAt = 0,
});
Map<String, dynamic> toJson() => {
'updatedAt': updatedAt,
'eventId': eventId,
'hasPendingLocalChanges': hasPendingLocalChanges,
'pendingUpdatedAt': pendingUpdatedAt,
};
factory ChannelSortSyncState.fromJson(Map<String, dynamic> json) {
final updatedAt = json['updatedAt'] is int ? json['updatedAt'] as int : 0;
final hasPendingLocalChanges = json['hasPendingLocalChanges'] == true;
final storedPendingUpdatedAt = json['pendingUpdatedAt'];
// Older builds used updatedAt for both the remote cursor and local edit
// stamp. Preserve the pending guard while resetting that ambiguous cursor.
final isLegacyPending =
hasPendingLocalChanges && storedPendingUpdatedAt is! int;
return ChannelSortSyncState(
updatedAt: isLegacyPending ? 0 : updatedAt,
eventId: isLegacyPending
? ''
: (json['eventId'] is String ? json['eventId'] as String : ''),
hasPendingLocalChanges: hasPendingLocalChanges,
pendingUpdatedAt: storedPendingUpdatedAt is int
? storedPendingUpdatedAt
: (hasPendingLocalChanges ? updatedAt : 0),
);
}
}
class ChannelSortStorage {
final SharedPreferences _prefs;
ChannelSortStorage(this._prefs);
ChannelSortStore read(String pubkey, String relayUrl) {
final scopedKey = channelSortKey(pubkey, relayUrl);
final scoped = _readKey(scopedKey);
if (scoped != null) return scoped;
// One-time read-through migration from #2829 development builds. The
// first active relay claims the legacy value; removing it prevents the
// same unscoped preferences from bleeding into later communities.
final legacyKey = legacyChannelSortKey(pubkey);
final legacy = _readKey(legacyKey);
if (legacy != null) {
write(pubkey, relayUrl, legacy);
_prefs.remove(legacyKey);
return legacy;
}
return const ChannelSortStore();
}
ChannelSortStore? _readKey(String key) {
final raw = _prefs.getString(key);
if (raw == null || raw.isEmpty) {
return null;
}
try {
final parsed = jsonDecode(raw);
if (parsed is! Map<String, dynamic> || parsed['version'] != 1) {
return null;
}
return ChannelSortStore.fromJson(parsed);
} catch (_) {
return null;
}
}
void write(String pubkey, String relayUrl, ChannelSortStore store) {
_prefs.setString(
channelSortKey(pubkey, relayUrl),
jsonEncode(store.toJson()),
);
}
ChannelSortSyncState readSyncState(String pubkey, String relayUrl) {
final raw = _prefs.getString(_syncStateKey(pubkey, relayUrl));
if (raw == null || raw.isEmpty) return const ChannelSortSyncState();
try {
final parsed = jsonDecode(raw);
return parsed is Map<String, dynamic>
? ChannelSortSyncState.fromJson(parsed)
: const ChannelSortSyncState();
} catch (_) {
return const ChannelSortSyncState();
}
}
void writeSyncState(
String pubkey,
String relayUrl,
ChannelSortSyncState state,
) {
_prefs.setString(
_syncStateKey(pubkey, relayUrl),
jsonEncode(state.toJson()),
);
}
String _syncStateKey(String pubkey, String relayUrl) =>
'${channelSortKey(pubkey, relayUrl)}:sync';
}
@@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../../shared/crypto/nip44.dart';
import '../../../shared/relay/relay.dart';
import '../../../shared/read_state/read_state_time.dart';
import 'channel_stars_storage.dart';
class ChannelStarsCrypto {
final Uint8List _conversationKey;
ChannelStarsCrypto(String nsec, String pubkey)
: _conversationKey = _deriveKey(nsec, pubkey);
static Uint8List _deriveKey(String nsec, String pubkey) {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
return getConversationKey(privkeyHex, pubkey);
}
String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext);
String decrypt(String ciphertext) =>
nip44Decrypt(_conversationKey, ciphertext);
}
class ChannelStarsManager {
final String pubkey;
final ChannelStarsStorage _storage;
final ChannelStarsCrypto _crypto;
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final VoidCallback _onChanged;
ChannelStarStore _store;
ChannelStarStore? _lastPublishedStore;
Timer? _publishDebounce;
int _lastRemoteCreatedAt = 0;
String? _lastRemoteEventId;
void Function()? _unsubscribe;
bool _disposed = false;
ChannelStarsManager({
required this.pubkey,
required SharedPreferences prefs,
required ChannelStarsCrypto crypto,
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
}) : _storage = ChannelStarsStorage(prefs),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged,
_store = ChannelStarsStorage(prefs).read(pubkey);
ChannelStarStore get store => _store;
Future<void> initialize() async {
if (_disposed) return;
if (!_remoteEnabled || _relaySession == null) {
_onChanged();
return;
}
await _fetchAndMerge();
await _startLiveSubscription();
_onChanged();
}
void dispose({bool flushPending = true}) {
if (_disposed) return;
_disposed = true;
final hadPending = _publishDebounce != null;
_publishDebounce?.cancel();
_publishDebounce = null;
if (flushPending && hadPending && _remoteEnabled) {
unawaited(_publish(allowDisposed: true));
}
_unsubscribe?.call();
_unsubscribe = null;
}
void starChannel(String channelId) {
if (_disposed) return;
final entry = ChannelStarEntry(
starred: true,
updatedAt: currentUnixSeconds(),
);
_store = ChannelStarStore(channels: {..._store.channels, channelId: entry});
_persist();
markDirty();
}
void unstarChannel(String channelId) {
if (_disposed) return;
final entry = ChannelStarEntry(
starred: false,
updatedAt: currentUnixSeconds(),
);
_store = ChannelStarStore(channels: {..._store.channels, channelId: entry});
_persist();
markDirty();
}
void markDirty() {
if (!_remoteEnabled || _disposed) return;
_publishDebounce?.cancel();
_publishDebounce = Timer(const Duration(seconds: 5), () {
_publishDebounce = null;
unawaited(_publish());
});
}
Future<void> _fetchAndMerge() async {
if (_relaySession == null) return;
try {
final events = await _relaySession.fetchHistory(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': ['channel-stars'],
},
limit: 1,
),
);
_mergeEvents(events);
_persist();
if (!_disposed) _onChanged();
} catch (_) {
// Local state remains usable when relay is unavailable.
}
}
Future<void> _startLiveSubscription() async {
if (_relaySession == null) return;
try {
_unsubscribe = await _relaySession.subscribe(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': ['channel-stars'],
},
limit: 1,
),
_handleIncomingEvent,
);
} catch (_) {
// Non-fatal — local state and history still work.
}
}
void _mergeEvents(List<NostrEvent> events) {
for (final event in events) {
if (event.pubkey != pubkey) continue;
_mergeEvent(event);
}
}
void _mergeEvent(NostrEvent event) {
// Only process channel-stars d-tag events.
final dTag = event.getTagValue('d');
if (dTag != 'channel-stars') return;
try {
final plaintext = _crypto.decrypt(event.content);
final parsed = jsonDecode(plaintext);
if (parsed is! Map<String, dynamic>) return;
final incoming = ChannelStarStore.fromJson(parsed);
// Gate on createdAt: ignore events older than what we've already seen.
final isNewer =
event.createdAt > _lastRemoteCreatedAt ||
(event.createdAt == _lastRemoteCreatedAt &&
event.id.compareTo(_lastRemoteEventId ?? '') > 0);
if (isNewer) {
_lastRemoteCreatedAt = event.createdAt;
_lastRemoteEventId = event.id;
// Per-channel merge: keep the entry with the highest updatedAt for each channel.
_store = mergeStores(_store, incoming);
_persist();
}
} catch (_) {
// Decryption failure or parse error — keep existing state.
}
}
void _handleIncomingEvent(NostrEvent event) {
if (_disposed) return;
_mergeEvent(event);
if (!_disposed) _onChanged();
}
bool _isIdenticalToLastPublished() {
final last = _lastPublishedStore;
if (last == null) return false;
if (last.channels.length != _store.channels.length) return false;
for (final key in _store.channels.keys) {
final lastEntry = last.channels[key];
final currentEntry = _store.channels[key];
if (lastEntry == null ||
lastEntry.starred != currentEntry!.starred ||
lastEntry.updatedAt != currentEntry.updatedAt) {
return false;
}
}
return true;
}
Future<void> _publish({bool allowDisposed = false}) async {
if ((!allowDisposed && _disposed) ||
!_remoteEnabled ||
_signedEventRelay == null) {
return;
}
// Read-before-write: merge remote state before publishing
await _fetchAndMerge();
// No-op suppression: skip if nothing changed
if (_isIdenticalToLastPublished()) return;
try {
final payload = jsonEncode(_store.toJson());
final ciphertext = _crypto.encrypt(payload);
final createdAt = max(currentUnixSeconds(), _lastRemoteCreatedAt + 1);
await _signedEventRelay.submit(
kind: EventKind.readState,
content: ciphertext,
tags: [
['d', 'channel-stars'],
['t', 'channel-stars'],
],
createdAt: createdAt,
);
_lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt);
_lastPublishedStore = ChannelStarStore(channels: Map.of(_store.channels));
} catch (error) {
debugPrint('[ChannelStarsManager] publish failed: $error');
}
}
void _persist() {
_storage.write(pubkey, _store);
}
}
@@ -0,0 +1,115 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart';
import '../../../shared/community/community_provider.dart';
import 'channel_stars_manager.dart';
import 'channel_stars_storage.dart';
class ChannelStarsState {
final bool isReady;
final ChannelStarStore store;
/// Bumped on every change to force downstream rebuilds.
final int version;
const ChannelStarsState({
this.isReady = false,
this.store = const ChannelStarStore(),
this.version = 0,
});
}
class ChannelStarsNotifier extends Notifier<ChannelStarsState> {
ChannelStarsManager? _manager;
@override
ChannelStarsState build() {
_manager?.dispose(flushPending: false);
_manager = null;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
// Rebuild when the active community changes (pubkey may differ).
ref.watch(activeCommunityProvider);
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
return const ChannelStarsState();
}
final pubkey = _safePubkeyFromNsec(nsec);
if (pubkey == null || pubkey.isEmpty) {
return const ChannelStarsState();
}
final ChannelStarsCrypto crypto;
try {
crypto = ChannelStarsCrypto(nsec, pubkey);
} catch (_) {
return const ChannelStarsState();
}
final prefs = ref.read(savedPrefsProvider);
final signedRelay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
);
late final ChannelStarsManager manager;
manager = ChannelStarsManager(
pubkey: pubkey,
prefs: prefs,
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: sessionState.status == SessionStatus.connected,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
ref.onDispose(() {
manager.dispose();
if (_manager == manager) {
_manager = null;
}
});
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_emitManagerState(manager);
});
return ChannelStarsState(isReady: false, store: manager.store, version: 1);
}
void starChannel(String channelId) => _manager?.starChannel(channelId);
void unstarChannel(String channelId) => _manager?.unstarChannel(channelId);
void _emitManagerState(ChannelStarsManager manager) {
if (_manager != manager) return;
state = ChannelStarsState(
isReady: true,
store: manager.store,
version: state.version + 1,
);
}
}
final channelStarsProvider =
NotifierProvider<ChannelStarsNotifier, ChannelStarsState>(
ChannelStarsNotifier.new,
);
String? _safePubkeyFromNsec(String nsec) {
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) {
return null;
}
}
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
String channelStarsKey(String pubkey) => 'buzz.channel-stars.v1:$pubkey';
class ChannelStarEntry {
final bool starred;
final int updatedAt;
const ChannelStarEntry({required this.starred, required this.updatedAt});
Map<String, dynamic> toJson() => {'starred': starred, 'updatedAt': updatedAt};
factory ChannelStarEntry.fromJson(Map<String, dynamic> json) =>
ChannelStarEntry(
starred: json['starred'] as bool,
updatedAt: json['updatedAt'] as int,
);
}
class ChannelStarStore {
final int version;
final Map<String, ChannelStarEntry> channels;
const ChannelStarStore({this.version = 1, this.channels = const {}});
Map<String, dynamic> toJson() => {
'version': version,
'channels': {for (final e in channels.entries) e.key: e.value.toJson()},
};
factory ChannelStarStore.fromJson(Map<String, dynamic> json) {
final rawChannels = json['channels'];
final channels = <String, ChannelStarEntry>{};
if (rawChannels is Map) {
for (final entry in rawChannels.entries) {
if (entry.key is String && entry.value is Map<String, dynamic>) {
final v = entry.value as Map<String, dynamic>;
if (v['starred'] is bool && v['updatedAt'] is int) {
channels[entry.key as String] = ChannelStarEntry.fromJson(v);
}
}
}
}
return ChannelStarStore(version: 1, channels: channels);
}
}
ChannelStarStore mergeStores(ChannelStarStore local, ChannelStarStore remote) {
// Per-channel max-updatedAt merge:
// For each channel ID in the union, keep the entry with the highest updatedAt.
final merged = <String, ChannelStarEntry>{...local.channels};
for (final entry in remote.channels.entries) {
final existing = merged[entry.key];
if (existing == null || entry.value.updatedAt > existing.updatedAt) {
merged[entry.key] = entry.value;
}
}
return ChannelStarStore(channels: merged);
}
class ChannelStarsStorage {
final SharedPreferences _prefs;
ChannelStarsStorage(this._prefs);
ChannelStarStore read(String pubkey) {
final raw = _prefs.getString(channelStarsKey(pubkey));
if (raw == null || raw.isEmpty) {
return const ChannelStarStore();
}
try {
final parsed = jsonDecode(raw);
if (parsed is! Map<String, dynamic>) {
return const ChannelStarStore();
}
if (parsed['version'] != 1) {
return const ChannelStarStore();
}
return ChannelStarStore.fromJson(parsed);
} catch (_) {
return const ChannelStarStore();
}
}
void write(String pubkey, ChannelStarStore store) {
_prefs.setString(channelStarsKey(pubkey), jsonEncode(store.toJson()));
}
}
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../profile/user_cache_provider.dart';
import 'channel_typing_provider.dart';
import 'small_avatar.dart';
/// Composer-adjacent status for people currently typing in a channel or thread.
class ChannelTypingIndicator extends ConsumerWidget {
final List<TypingEntry> entries;
const ChannelTypingIndicator({super.key, required this.entries});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
final names = entries.map((entry) {
final profile =
userCache[entry.pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(entry.pubkey.toLowerCase());
return profile?.label ?? shortPubkey(entry.pubkey);
}).toList();
final text = switch (names.length) {
1 => context.l10n.typingOne(names[0]),
2 => context.l10n.typingTwo(names[0], names[1]),
_ => context.l10n.typingMany(names[0], names.length - 1),
};
final visibleEntries = entries.take(3).toList();
final avatarCount = visibleEntries.length;
return Padding(
padding: const EdgeInsets.only(
left: Grid.twelve,
right: Grid.twelve,
bottom: Grid.xxs,
),
child: Container(
key: const ValueKey('channel-typing-indicator'),
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.xxs,
),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: Row(
children: [
SizedBox(
width: 24.0 + (avatarCount - 1) * 14.0,
height: 24,
child: Stack(
children: [
for (var i = 0; i < avatarCount; i++)
Positioned(
left: i * 14.0,
child: SmallAvatar(
pubkey: visibleEntries[i].pubkey,
userCache: userCache,
size: 24,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: _TypingTextShimmer(
text,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
);
}
}
class _TypingTextShimmer extends HookWidget {
final String text;
final TextStyle? style;
const _TypingTextShimmer(this.text, {this.style});
@override
Widget build(BuildContext context) {
final animation = useAnimationController(
duration: const Duration(milliseconds: 2600),
);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final baseColor = style?.color ?? context.colors.onSurfaceVariant;
final highlightColor =
Color.lerp(context.colors.surface, baseColor, 0.4) ?? baseColor;
useEffect(() {
if (reducedMotion) {
animation
..stop()
..value = 0;
} else {
animation.repeat();
}
return animation.stop;
}, [animation, reducedMotion]);
final label = Text(text, style: style, overflow: TextOverflow.ellipsis);
if (reducedMotion) return label;
return RepaintBoundary(
child: AnimatedBuilder(
animation: animation,
child: label,
builder: (context, child) {
final center = 1.5 - (animation.value * 3);
return ShaderMask(
key: const ValueKey('channel-typing-shimmer'),
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => LinearGradient(
begin: Alignment(center - 1, 0),
end: Alignment(center + 1, 0),
colors: [
baseColor,
baseColor,
highlightColor,
baseColor,
baseColor,
],
stops: const [0, 0.34, 0.5, 0.66, 1],
).createShader(bounds),
child: child,
);
},
),
);
}
}
@@ -0,0 +1,113 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
/// A single typing indicator entry.
@immutable
class TypingEntry {
final String pubkey;
final String? threadHeadId;
final int expiresAtMs;
const TypingEntry({
required this.pubkey,
this.threadHeadId,
required this.expiresAtMs,
});
}
/// Tracks who is currently typing in a specific channel.
///
/// Subscribes to kind:20002 (typing indicator) events via websocket.
/// Entries expire after 8 seconds (matching the desktop TTL).
class ChannelTypingNotifier extends Notifier<List<TypingEntry>> {
static const _ttlMs = 8000;
static const _pruneIntervalMs = 1000;
final String channelId;
void Function()? _unsubscribe;
Timer? _pruneTimer;
ChannelTypingNotifier(this.channelId);
@override
List<TypingEntry> build() {
final sessionState = ref.watch(relaySessionProvider);
ref.onDispose(() {
_unsubscribe?.call();
_unsubscribe = null;
_pruneTimer?.cancel();
_pruneTimer = null;
});
if (sessionState.status == SessionStatus.connected) {
_subscribeLive();
}
return [];
}
void _subscribeLive() async {
final session = ref.read(relaySessionProvider.notifier);
_unsubscribe = await session.subscribe(
NostrFilter(
kinds: [EventKind.typingIndicator],
tags: {
'#h': [channelId],
},
limit: 10,
),
_handleTypingEvent,
);
}
void _handleTypingEvent(NostrEvent event) {
final now = DateTime.now().millisecondsSinceEpoch;
final entry = TypingEntry(
pubkey: event.pubkey,
threadHeadId: event.getTagValue('e'),
expiresAtMs: now + _ttlMs,
);
// Upsert: replace existing entry for same pubkey+thread, or add.
final updated =
state
.where(
(e) =>
!(e.pubkey == entry.pubkey &&
e.threadHeadId == entry.threadHeadId),
)
.toList()
..add(entry);
state = updated;
_ensurePruneTimer();
}
void _ensurePruneTimer() {
_pruneTimer ??= Timer.periodic(
const Duration(milliseconds: _pruneIntervalMs),
(_) => _prune(),
);
}
void _prune() {
final now = DateTime.now().millisecondsSinceEpoch;
final pruned = state.where((e) => e.expiresAtMs > now).toList();
state = pruned;
if (pruned.isEmpty) {
_pruneTimer?.cancel();
_pruneTimer = null;
}
}
}
final channelTypingProvider =
NotifierProvider.family<ChannelTypingNotifier, List<TypingEntry>, String>(
ChannelTypingNotifier.new,
);
@@ -0,0 +1,453 @@
import 'dart:convert';
import '../../shared/relay/relay.dart';
class ChannelPageCursor {
final int createdAt;
final String eventId;
const ChannelPageCursor({required this.createdAt, required this.eventId});
}
class ChannelWindowThreadSummary {
final int replyCount;
final int descendantCount;
final int? lastReplyAt;
final List<String> participantPubkeys;
const ChannelWindowThreadSummary({
required this.replyCount,
required this.descendantCount,
required this.lastReplyAt,
required this.participantPubkeys,
});
}
/// A thread-summary snapshot received from the live channel subscription.
///
/// [createdAt] is the relay event timestamp used to reject delayed snapshots
/// after a fresher recount has already been displayed. Recounts created in the
/// same second retain their live delivery order, because relay timestamps have
/// second precision.
class ChannelWindowLiveThreadSummary {
final ChannelWindowThreadSummary summary;
final int createdAt;
const ChannelWindowLiveThreadSummary({
required this.summary,
required this.createdAt,
});
}
class ChannelWindowRow {
final NostrEvent event;
final ChannelWindowThreadSummary? thread;
final int? threadSummaryCreatedAt;
const ChannelWindowRow({
required this.event,
this.thread,
this.threadSummaryCreatedAt,
});
ChannelWindowRow copyWith({
ChannelWindowThreadSummary? thread,
int? threadSummaryCreatedAt,
}) => ChannelWindowRow(
event: event,
thread: thread ?? this.thread,
threadSummaryCreatedAt:
threadSummaryCreatedAt ?? this.threadSummaryCreatedAt,
);
}
class ChannelWindowPage {
final ChannelPageCursor? startCursor;
final List<ChannelWindowRow> rows;
final List<NostrEvent> aux;
final ChannelPageCursor? nextCursor;
final bool hasMore;
const ChannelWindowPage({
required this.startCursor,
required this.rows,
required this.aux,
required this.nextCursor,
required this.hasMore,
});
}
class ChannelWindowStore {
final List<ChannelWindowPage> pages;
final List<NostrEvent> liveOverlay;
final List<NostrEvent> liveAux;
/// Thread summaries that arrived over the live socket, keyed by root event id.
///
/// Kept beside the pages rather than folded into [ChannelWindowRow.thread]
/// because a root can be in [liveOverlay] instead — a message you just sent
/// has no row yet, and its reply count has to land somewhere.
final Map<String, ChannelWindowLiveThreadSummary> liveThreadSummaries;
const ChannelWindowStore({
required this.pages,
required this.liveThreadSummaries,
required this.liveOverlay,
required this.liveAux,
});
const ChannelWindowStore.empty()
: pages = const [],
liveOverlay = const [],
liveAux = const [],
liveThreadSummaries = const {};
}
ChannelWindowPage parseChannelWindowResponse(
List<NostrEvent> events,
String channelId,
ChannelPageCursor? startCursor,
) {
var rows = [
for (final event in events)
if (EventKind.channelTimelineContentKinds.contains(event.kind))
ChannelWindowRow(event: event),
];
final rowIndexesById = <String, int>{
for (var i = 0; i < rows.length; i++) rows[i].event.id: i,
};
for (final event in events) {
if (event.kind != EventKind.channelThreadSummary) continue;
final rootId = event.getTagValue('e');
final rowIndex = rootId == null ? null : rowIndexesById[rootId];
if (rowIndex == null) continue;
rows[rowIndex] = rows[rowIndex].copyWith(
thread: parseChannelWindowThreadSummary(event),
threadSummaryCreatedAt: event.createdAt,
);
}
final boundsEvents = events
.where((event) => event.kind == EventKind.channelWindowBounds)
.toList();
if (boundsEvents.length != 1) {
throw Exception(
'Channel window response must contain exactly one bounds event.',
);
}
final boundsEvent = boundsEvents.single;
if (boundsEvent.getTagValue('d') !=
_expectedBoundsKey(channelId, startCursor)) {
throw Exception('Channel window bounds do not match the request cursor.');
}
final bounds = _parseJsonMap(boundsEvent, 'window bounds');
final hasMore = bounds['has_more'] as bool;
final nextCursor = _parseCursor(bounds['next_cursor']);
if (hasMore != (nextCursor != null)) {
throw Exception('Channel window bounds has_more and next_cursor disagree.');
}
return ChannelWindowPage(
startCursor: startCursor,
rows: rows,
aux: [
for (final event in events)
if (EventKind.channelAuxEventKinds.contains(event.kind)) event,
],
nextCursor: nextCursor,
hasMore: hasMore,
);
}
ChannelWindowStore replaceNewestChannelWindow(
ChannelWindowStore current,
ChannelWindowPage page, {
Set<String> retainLiveSummaryRootIds = const {},
}) {
if (page.startCursor != null) {
throw Exception('Newest channel page must have a null start cursor.');
}
_assertValidPage(page);
final rowIds = page.rows.map((row) => row.event.id).toSet();
final auxIds = page.aux.map((event) => event.id).toSet();
return ChannelWindowStore(
pages: [page],
liveOverlay: current.liveOverlay
.where((event) => !rowIds.contains(event.id))
.toList(),
liveAux: current.liveAux
.where((event) => !auxIds.contains(event.id))
.toList(),
// A summary received while the page query was in flight can describe a
// mutation after that query's snapshot. Retain just those summaries even
// when the relay timestamp matches the page overlay; subscription replay
// received before the query still compares timestamps normally.
liveThreadSummaries: _retainLiveSummariesAfterPage(
current,
page.rows,
retainLiveSummaryRootIds: retainLiveSummaryRootIds,
),
);
}
Map<String, ChannelWindowLiveThreadSummary> _retainLiveSummariesAfterPage(
ChannelWindowStore current,
List<ChannelWindowRow> rows, {
required Set<String> retainLiveSummaryRootIds,
}) {
final rowsById = {for (final row in rows) row.event.id: row};
return {
for (final entry in current.liveThreadSummaries.entries)
if (retainLiveSummaryRootIds.contains(entry.key) ||
rowsById[entry.key] == null ||
rowsById[entry.key]!.thread == null ||
entry.value.createdAt >
(rowsById[entry.key]!.threadSummaryCreatedAt ?? -1))
entry.key: entry.value,
};
}
ChannelWindowStore appendOlderChannelWindow(
ChannelWindowStore current,
ChannelWindowPage page,
) {
_assertValidPage(page);
if (current.pages.isEmpty) {
throw Exception('Load the newest channel page first.');
}
final tail = current.pages.last;
if (!tail.hasMore || tail.nextCursor == null) {
throw Exception('The channel window is already complete.');
}
if (!_cursorsEqual(page.startCursor, tail.nextCursor)) {
throw Exception(
'Channel page does not continue the retained cursor chain.',
);
}
final retainedIds = current.pages
.expand((page) => page.rows)
.map((row) => row.event.id)
.toSet();
for (final row in page.rows) {
if (retainedIds.contains(row.event.id)) {
throw Exception('Channel row ${row.event.id} overlaps a retained page.');
}
}
final pageIds = page.rows.map((row) => row.event.id).toSet();
return ChannelWindowStore(
pages: [...current.pages, page],
liveOverlay: current.liveOverlay
.where((event) => !pageIds.contains(event.id))
.toList(),
liveAux: current.liveAux,
// A live recount can arrive while this older page is in flight. Keep it
// when it is fresher than the page's embedded snapshot, just as a newest
// page replacement does.
liveThreadSummaries: _retainLiveSummariesAfterPage(
current,
page.rows,
retainLiveSummaryRootIds: const {},
),
);
}
/// Decode a kind-39005 thread summary. Same payload whether it came down as a
/// channel-window page overlay or over the live socket — one contract, two doors.
ChannelWindowThreadSummary parseChannelWindowThreadSummary(NostrEvent event) {
final payload = _parseJsonMap(event, 'thread summary');
final participants = payload['participants'];
return ChannelWindowThreadSummary(
replyCount: (payload['reply_count'] as num).toInt(),
descendantCount: (payload['descendant_count'] as num).toInt(),
lastReplyAt: (payload['last_reply_at'] as num?)?.toInt(),
participantPubkeys: participants is List
? participants.whereType<String>().toList()
: const [],
);
}
ChannelWindowStore mergeLiveChannelWindowEvent(
ChannelWindowStore current,
NostrEvent event, {
required bool isTimelineRow,
}) {
// A reply doesn't reach the main timeline itself — the root's "N replies" row
// comes from this summary event, which the relay re-emits on every reply. It
// has to be merged, not stored as an aux event: it is a replaceable snapshot
// keyed by root, not another row in the timeline.
if (event.kind == EventKind.channelThreadSummary) {
final rootId = event.getTagValue('e');
if (rootId == null) return current;
final ChannelWindowThreadSummary summary;
try {
summary = parseChannelWindowThreadSummary(event);
} catch (_) {
return current;
}
final existing = current.liveThreadSummaries[rootId];
// A newer timestamp wins. Equal timestamps are ordered by live delivery:
// two mutations can produce relay-signed recounts in the same second, and
// the later delivery is the authoritative snapshot for that root.
if (existing != null && existing.createdAt > event.createdAt) {
return current;
}
return ChannelWindowStore(
pages: current.pages,
liveOverlay: current.liveOverlay,
liveAux: current.liveAux,
liveThreadSummaries: {
...current.liveThreadSummaries,
rootId: ChannelWindowLiveThreadSummary(
summary: summary,
createdAt: event.createdAt,
),
},
);
}
if (!isTimelineRow) {
final alreadyKnown =
current.liveAux.any((candidate) => candidate.id == event.id) ||
current.pages.any(
(page) => page.aux.any((candidate) => candidate.id == event.id),
);
if (alreadyKnown) return current;
return ChannelWindowStore(
pages: current.pages,
liveOverlay: current.liveOverlay,
liveAux: [...current.liveAux, event],
liveThreadSummaries: current.liveThreadSummaries,
);
}
final inPages = current.pages.any(
(page) => page.rows.any((row) => row.event.id == event.id),
);
if (inPages) return current;
final oldestPage = current.pages.isEmpty ? null : current.pages.last;
final oldest = oldestPage?.rows.isEmpty ?? true
? null
: oldestPage!.rows.last.event;
if (oldest != null && _compareRelayOrder(event, oldest) >= 0) return current;
final overlay =
current.liveOverlay
.where((candidate) => candidate.id != event.id)
.toList()
..add(event)
..sort(_compareRelayOrder);
return ChannelWindowStore(
pages: current.pages,
liveOverlay: overlay,
liveAux: current.liveAux,
liveThreadSummaries: current.liveThreadSummaries,
);
}
List<NostrEvent> flattenChannelWindowEvents(ChannelWindowStore store) {
final byId = <String, NostrEvent>{};
for (final page in store.pages) {
for (final row in page.rows) {
byId[row.event.id] = row.event;
}
for (final event in page.aux) {
byId[event.id] = event;
}
}
for (final event in store.liveOverlay) {
byId[event.id] = event;
}
for (final event in store.liveAux) {
byId[event.id] = event;
}
return byId.values.toList()
..sort((left, right) => _compareRelayOrder(right, left));
}
bool channelWindowHasMore(ChannelWindowStore store) =>
store.pages.isNotEmpty && store.pages.last.hasMore;
ChannelPageCursor? channelWindowNextCursor(ChannelWindowStore store) =>
store.pages.isEmpty ? null : store.pages.last.nextCursor;
Map<String, ChannelWindowThreadSummary> channelWindowThreadSummaries(
ChannelWindowStore store,
) {
return {
for (final page in store.pages)
for (final row in page.rows)
if (row.thread != null) row.event.id: row.thread!,
// Live entries last: they are the newer snapshot for any root they cover.
for (final entry in store.liveThreadSummaries.entries)
entry.key: entry.value.summary,
};
}
Map<String, dynamic> _parseJsonMap(NostrEvent event, String label) {
try {
final decoded = jsonDecode(event.content);
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {}
throw Exception('Invalid $label event ${event.id}.');
}
ChannelPageCursor? _parseCursor(Object? value) {
if (value == null) return null;
if (value is! Map<String, dynamic>) {
throw Exception('Invalid channel window cursor.');
}
return ChannelPageCursor(
createdAt: (value['created_at'] as num).toInt(),
eventId: value['id'] as String,
);
}
String _expectedBoundsKey(String channelId, ChannelPageCursor? cursor) {
final suffix = cursor == null
? 'head'
: '${cursor.createdAt}:${cursor.eventId.toLowerCase()}';
return '${channelId.toLowerCase()}:$suffix';
}
bool _cursorsEqual(ChannelPageCursor? left, ChannelPageCursor? right) =>
identical(left, right) ||
(left != null &&
right != null &&
left.createdAt == right.createdAt &&
left.eventId == right.eventId);
void _assertValidPage(ChannelWindowPage page) {
if (page.hasMore != (page.nextCursor != null)) {
throw Exception('Channel window hasMore and nextCursor disagree.');
}
final seen = <String>{};
for (var index = 0; index < page.rows.length; index++) {
final event = page.rows[index].event;
if (!seen.add(event.id)) {
throw Exception('Duplicate channel row ${event.id}.');
}
final startCursor = page.startCursor;
if (startCursor != null && !_isStrictlyOlder(event, startCursor)) {
throw Exception(
'Channel row ${event.id} is outside its cursor interval.',
);
}
if (index > 0 &&
_compareRelayOrder(page.rows[index - 1].event, event) > 0) {
throw Exception('Channel window rows are not in relay order.');
}
}
}
bool _isStrictlyOlder(NostrEvent event, ChannelPageCursor cursor) =>
event.createdAt < cursor.createdAt ||
(event.createdAt == cursor.createdAt &&
event.id.compareTo(cursor.eventId) > 0);
int _compareRelayOrder(NostrEvent left, NostrEvent right) {
if (left.createdAt != right.createdAt) {
return right.createdAt - left.createdAt;
}
return left.id.compareTo(right.id);
}
@@ -0,0 +1,461 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' show max, min, pi;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/physics.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/auth/auth.dart';
import '../../shared/community/community_icon_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/skeleton.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/l10n/l10n.dart';
import '../profile/profile_avatar.dart';
import '../profile/profile_provider.dart';
import '../profile/presence_cache_provider.dart';
import '../profile/user_cache_provider.dart';
import '../pairing/pairing_page.dart';
import '../pairing/pairing_provider.dart';
import 'channel.dart';
import 'channel_actions_sheet.dart';
import 'channel_detail_page.dart';
import 'channel_management_provider.dart';
import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'channel_mutes/channel_mutes_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_sections/channel_sections_storage.dart';
import 'channel_sort/channel_sort_provider.dart';
import 'channel_sort/channel_sort_storage.dart';
import 'channel_stars/channel_stars_provider.dart';
import 'channels_provider.dart';
import '../../shared/read_state/deferred_read_state_update.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/read_state/read_state_time.dart';
import 'unread_badge/observed_unread_event.dart';
part 'channels_page/body.dart';
part 'channels_page/sections.dart';
part 'channels_page/channel_tile.dart';
part 'channels_page/sheets.dart';
part 'channels_page/badges.dart';
part 'channels_page/skeleton.dart';
part 'channels_page/community.dart';
part 'channels_page/quick_actions.dart';
part 'channels_page/quick_actions_launcher.dart';
enum _QuickAction { createChannel, newDm }
const double _kChannelSectionInset = Grid.gutter;
const double _kChannelLeadingWidth = 22.0;
const double _kChannelIconSize = 18.0;
const double _kChannelLabelGap = Grid.xxs;
const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter;
const double _kSectionSpacingTightening = Grid.half;
const double _kSectionHeaderVerticalPadding =
_kChannelRowVerticalPadding - _kSectionSpacingTightening;
// Section headers include touch targets for their actions, so their visual
// centre sits lower than a channel row's. This keeps an expanded section's
// final row equally spaced from the following divider.
const double _kExpandedSectionTrailingPadding =
11.0 - _kSectionSpacingTightening;
const double _kChannelLabelInset =
_kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap;
/// DM avatars are circles, so they fill their box edge to edge where a channel
/// glyph leaves 4dp of slack inside the same 22dp leading column. Sizing them to
/// the glyph's ink width keeps the icon-to-label distance identical across both
/// sections while the labels stay on [_kChannelLabelInset].
const double _kDmAvatarSize = _kChannelIconSize;
const double _kTopSectionAvatarSize = 40.0;
const double _kTopSectionBottomPadding = Grid.xxs;
/// The top section's avatars are 40dp circles, which fill their box edge to
/// edge; the channel rows below lead with an 18dp glyph left-aligned in a 22dp
/// box at [_kChannelSectionInset]. Edge-aligning the two leaves the circles
/// looking pushed outward, so the bar is pulled in to sit the avatar's centre
/// near the channel-icon column.
const double _kTopSectionInset = Grid.twelve;
const Duration _kSectionExpandDuration = Duration(milliseconds: 220);
const Duration _kSectionCollapseDuration = Duration(milliseconds: 170);
const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1);
const Curve _kSectionCollapseCurve = Curves.easeInCubic;
const double _kSectionCollapsedScaleY = 0.98;
const double _kHeaderFrostScrollDistance = Grid.xxl;
const double _kHeaderFrostMaxBlurSigma = 23.12;
class _UnreadChannelState {
final Set<String> ids;
const _UnreadChannelState({required this.ids});
}
_UnreadChannelState _computeUnreadChannelState({
required Iterable<Channel> channels,
required ReadStateState readState,
required ChannelsNotifier channelsNotifier,
}) {
if (!readState.isReady) {
return const _UnreadChannelState(ids: {});
}
final latestObservedByChannel = channelsNotifier.latestObservedByChannel;
final observedEventsByChannel =
channelsNotifier.observedUnreadEventsByChannel;
final ids = <String>{};
for (final channel in channels) {
if (readState.locallyForcedChannelIds.contains(channel.id)) {
ids.add(channel.id);
continue;
}
final latestObserved = latestObservedByChannel[channel.id];
if (latestObserved == null) continue;
final channelReadAt = readState.effectiveTimestamp(channel.id);
if (channelReadAt != null && latestObserved <= channelReadAt) continue;
final observedEvents = observedEventsByChannel[channel.id];
int? readAtForObservedEvent(ObservedUnreadEvent event) =>
observedUnreadEventReadAt(
event,
channelReadAt,
(rootId) => readState.effectiveTimestamp(threadContextKey(rootId)),
(messageId) => readState.effectiveTimestamp(msgContextKey(messageId)),
);
final unreadCount = countUnreadObservedEvents(
observedEvents,
readAtForObservedEvent,
);
if (unreadCount == 0) continue;
ids.add(channel.id);
}
return _UnreadChannelState(ids: ids);
}
class ChannelsPage extends HookConsumerWidget {
const ChannelsPage({
required this.settingsPageBuilder,
required this.onSettingsTransitionProgress,
this.tabReselection,
super.key,
});
final WidgetBuilder settingsPageBuilder;
/// Reports the Settings route's raw animation progress from 0 to 1.
final ValueChanged<double> onSettingsTransitionProgress;
/// Notifies this page when its already-selected tab is tapped again.
final ValueListenable<int>? tabReselection;
@override
Widget build(BuildContext context, WidgetRef ref) {
final channelsAsync = ref.watch(channelsProvider);
final sessionState = ref.watch(relaySessionProvider);
final currentPubkey = ref
.watch(profileProvider)
.whenData((value) => value?.pubkey)
.value;
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
color: navigationPrimaryForeground(context),
);
final topSectionHeight = frostedAppBarHeight(
context,
titleStyle: headerTitleStyle,
bottomHeight: _kTopSectionBottomPadding,
);
final channelsScrollController = useScrollController();
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final headerFrostProgress = useState(0.0);
useEffect(() {
void updateHeaderTreatment() {
final nextProgress = !channelsScrollController.hasClients
? 0.0
: (channelsScrollController.offset / _kHeaderFrostScrollDistance)
.clamp(0.0, 1.0)
.toDouble();
if ((headerFrostProgress.value - nextProgress).abs() > 0.001) {
headerFrostProgress.value = nextProgress;
}
}
channelsScrollController.addListener(updateHeaderTreatment);
return () =>
channelsScrollController.removeListener(updateHeaderTreatment);
}, [channelsScrollController]);
useEffect(() {
final tabReselection = this.tabReselection;
if (tabReselection == null) return null;
void scrollToTop() {
if (!channelsScrollController.hasClients) return;
final position = channelsScrollController.position;
if (position.pixels <= position.minScrollExtent + 0.5) return;
if (reducedMotion) {
channelsScrollController.jumpTo(position.minScrollExtent);
return;
}
unawaited(
channelsScrollController.animateTo(
position.minScrollExtent,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
}
tabReselection.addListener(scrollToTop);
return () => tabReselection.removeListener(scrollToTop);
}, [tabReselection, channelsScrollController, reducedMotion]);
// Cache the last successfully loaded channels so the UI never flashes
// back to a loading state when the provider rebuilds (e.g. reconnect).
// Clear the cache on community switch so we show a full loader instead of
// stale channels from the previous community. unwrapPrevious() ensures the
// selector sees null during loading (not the previous community's ID).
final activeCommunityId = ref.watch(
activeCommunityProvider.select((v) => v.unwrapPrevious().value?.id),
);
final cachedChannels = useRef<List<Channel>?>(null);
final lastCommunityId = useRef<String?>(null);
if (lastCommunityId.value != activeCommunityId) {
cachedChannels.value = null;
lastCommunityId.value = activeCommunityId;
}
if (channelsAsync.asData?.value case final data?) {
cachedChannels.value = data;
}
final channels = cachedChannels.value;
Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
}
// Only surface fetch errors while the relay is stably connected. During a
// reconnect the session owns recovery, so a cancelled in-flight query must
// not turn into a manual Retry page.
final showError = useState(false);
final hasError = channelsAsync.hasError && channels == null;
final canSurfaceError =
hasError &&
sessionState.status != SessionStatus.connecting &&
sessionState.status != SessionStatus.reconnecting;
useEffect(() {
if (!canSurfaceError) {
showError.value = false;
return null;
}
final timer = Timer(const Duration(seconds: 2), () {
showError.value = true;
});
return timer.cancel;
}, [canSurfaceError]);
// Keep cached content steady through brief socket flaps. A sustained
// reconnect swaps to element-shaped skeletons that match desktop.
final showConnectionSkeleton = useState(false);
final isReconnectingWithContent =
channels != null &&
(sessionState.status == SessionStatus.connecting ||
sessionState.status == SessionStatus.reconnecting);
useEffect(() {
if (!isReconnectingWithContent) {
showConnectionSkeleton.value = false;
return null;
}
final timer = Timer(const Duration(seconds: 2), () {
showConnectionSkeleton.value = true;
});
return timer.cancel;
}, [isReconnectingWithContent]);
void openCommunitySwitcher() {
unawaited(HapticFeedback.selectionClick());
ref.invalidate(communityIconProvider);
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _CommunitySwitcherSheet(),
);
}
final topSectionGradient = context.appColors.topSectionGradient;
final usesPinnedGradient = topSectionGradient != null;
return FrostedScaffold(
backgroundColor: usesPinnedGradient
? Colors.transparent
: context.colors.surface,
backgroundGradient: topSectionGradient,
appBar: FrostedAppBar(
horizontalInset: _kTopSectionInset,
// Let the full Buzz gradient show at rest. Once the list begins to
// move beneath this row, build up blur over the first 64dp of scroll
// without adding the usual white frosted wash. The Buzz list is
// transparent, so the blurred pixels remain a continuation of the
// pinned gradient instead of turning into a white header.
frosted: !usesPinnedGradient || headerFrostProgress.value > 0,
frostedSurfaceOpacity: usesPinnedGradient ? 0 : 0.5,
frostedBlurSigma: usesPinnedGradient
? _kHeaderFrostMaxBlurSigma * headerFrostProgress.value
: 20,
showBottomDivider: false,
leading: _CommunityIndicator(onTap: openCommunitySwitcher),
titleStyle: headerTitleStyle,
title: _CommunityHeaderTitle(
style: headerTitleStyle,
onTap: openCommunitySwitcher,
),
actions: [
SizedBox(
width: Grid.xl,
height: Grid.xl,
child: Center(
child: ProfileAvatar(
size: _kTopSectionAvatarSize,
onTap: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).push(
_SettingsPageRoute(
builder: settingsPageBuilder,
onTransitionProgress: onSettingsTransitionProgress,
),
);
},
),
),
),
],
bottomHeight: _kTopSectionBottomPadding,
bottom: const SizedBox.expand(),
),
body: _ChannelsBody(
channels: channels,
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
showConnectionSkeleton: showConnectionSkeleton.value,
currentPubkey: currentPubkey,
topSectionHeight: topSectionHeight,
usesPinnedGradient: usesPinnedGradient,
scrollController: channelsScrollController,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
),
);
}
}
/// A custom route deliberately avoids [MaterialPageRoute]'s platform exit
/// transition on Home. Settings has a centered scale-and-fade transition, not
/// a lateral page push.
class _SettingsPageRoute extends PageRouteBuilder<void> {
_SettingsPageRoute({
required WidgetBuilder builder,
required this.onTransitionProgress,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) =>
builder(context),
transitionsBuilder: _buildSettingsTransition,
opaque: false,
transitionDuration: const Duration(milliseconds: 190),
reverseTransitionDuration: const Duration(milliseconds: 190),
);
final ValueChanged<double> onTransitionProgress;
Animation<double>? _progressAnimation;
@override
void install() {
super.install();
_progressAnimation = animation?..addListener(_reportProgress);
_reportProgress();
}
void _reportProgress() {
onTransitionProgress(_progressAnimation?.value ?? 0);
}
@override
void dispose() {
_progressAnimation?.removeListener(_reportProgress);
super.dispose();
}
static Widget _buildSettingsTransition(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
if (MediaQuery.disableAnimationsOf(context)) return child;
final incoming = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeOutCubic,
);
return FadeTransition(
key: const ValueKey('settings-transition-opacity'),
opacity: _SettingsOpacityAnimation(incoming),
child: RepaintBoundary(
key: const ValueKey('settings-transition-layer'),
child: ScaleTransition(
scale: Tween<double>(begin: 1.04, end: 1).animate(incoming),
alignment: Alignment.center,
child: child,
),
),
);
}
}
/// Keeps Settings already composed on entry while retaining a complete exit
/// fade. Reading the parent live also keeps opacity synchronized with scale on
/// the route's first frame.
class _SettingsOpacityAnimation extends Animation<double>
with AnimationWithParentMixin<double> {
_SettingsOpacityAnimation(this.parent);
@override
final Animation<double> parent;
@override
double get value {
final progress = parent.value;
return parent.status == AnimationStatus.reverse
? progress
: 0.8 + (0.2 * progress);
}
}
@@ -0,0 +1,87 @@
part of '../channels_page.dart';
class _EphemeralBadge extends StatelessWidget {
final Channel channel;
const _EphemeralBadge({required this.channel});
@override
Widget build(BuildContext context) {
final display = ephemeralChannelDisplay(channel, l10n: context.l10n);
if (display == null) return const SizedBox.shrink();
return Tooltip(
message: display.tooltipLabel,
child: Icon(
LucideIcons.clockFading,
key: Key('channel-ephemeral-${channel.id}'),
size: 16,
color: context.colors.onSurfaceVariant,
),
);
}
}
class _ErrorView extends StatelessWidget {
final Object error;
final VoidCallback onRetry;
const _ErrorView({required this.error, required this.onRetry});
static String _userMessage(BuildContext context, Object error) {
final l10n = context.l10n;
if (error is RelayException) {
if (error.statusCode == 401) {
return l10n.notAuthorizedApi;
}
if (error.statusCode == 403) {
return l10n.accessDenied;
}
return l10n.serverErrorRetry('${error.statusCode}');
}
if (error is SocketException) {
return l10n.relayUnreachable;
}
return l10n.connectionSomethingWrong;
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.wifiOff,
size: Grid.xl,
color: context.colors.error,
),
const SizedBox(height: Grid.xs),
Text(
context.l10n.channelsLoadFailed,
style: context.textTheme.titleMedium,
),
const SizedBox(height: Grid.xxs),
Text(
_userMessage(context, error),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: Grid.xs),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(LucideIcons.refreshCw),
label: Text(context.l10n.retry),
),
],
),
),
);
}
}
@@ -0,0 +1,386 @@
part of '../channels_page.dart';
class _ChannelsBody extends StatelessWidget {
final List<Channel>? channels;
final AsyncValue<List<Channel>> channelsAsync;
final bool showError;
final SessionStatus sessionStatus;
final bool showConnectionSkeleton;
final String? currentPubkey;
final double topSectionHeight;
final bool usesPinnedGradient;
final ScrollController scrollController;
final Future<void> Function() onRefresh;
final Future<void> Function(Channel channel) onSelectChannel;
const _ChannelsBody({
required this.channels,
required this.channelsAsync,
required this.showError,
required this.sessionStatus,
required this.showConnectionSkeleton,
required this.currentPubkey,
required this.topSectionHeight,
required this.usesPinnedGradient,
required this.scrollController,
required this.onRefresh,
required this.onSelectChannel,
});
@override
Widget build(BuildContext context) {
final barHeight = topSectionHeight;
final loadedChannels = channels;
final loading =
showConnectionSkeleton || (loadedChannels == null && !showError);
final content = showError && channelsAsync.hasError
? Padding(
padding: EdgeInsets.only(top: barHeight),
child: _ErrorView(error: channelsAsync.error!, onRetry: onRefresh),
)
: loadedChannels == null
? const SizedBox.shrink()
: BeeRefreshIndicator(
edgeOffset: barHeight,
onRefresh: onRefresh,
child: CustomScrollView(
controller: scrollController,
// The transparent gap shows the top section and must not absorb
// taps meant for the community or profile controls beneath it.
hitTestBehavior: HitTestBehavior.deferToChild,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
if (usesPinnedGradient)
_SliverChannelsList(
channels: loadedChannels,
currentPubkey: currentPubkey,
onSelectChannel: onSelectChannel,
)
else
DecoratedSliver(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
),
sliver: _SliverChannelsList(
channels: loadedChannels,
currentPubkey: currentPubkey,
onSelectChannel: onSelectChannel,
),
),
],
),
);
return SkeletonReveal(
loading: loading,
shimmerEnabled: sessionStatus != SessionStatus.disconnected,
skeleton: _ChannelsSkeleton(
channels: loadedChannels,
topInset: barHeight,
status: sessionStatus,
),
content: content,
);
}
}
class _SliverChannelsList extends HookConsumerWidget {
final List<Channel> channels;
final String? currentPubkey;
final Future<void> Function(Channel channel) onSelectChannel;
const _SliverChannelsList({
required this.channels,
required this.currentPubkey,
required this.onSelectChannel,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final readState = ref.watch(readStateProvider);
final sectionsState = ref.watch(channelSectionsProvider);
final mutesState = ref.watch(channelMutesProvider);
final mutedChannelIds = {
for (final entry in mutesState.store.channels.entries)
if (entry.value.muted) entry.key,
};
final starsState = ref.watch(channelStarsProvider);
final starredChannelIds = {
for (final entry in starsState.store.channels.entries)
if (entry.value.starred) entry.key,
};
final visibleChannels = channels
.where((channel) => channel.isMember && !channel.isArchived)
.toList();
final streamChannels = visibleChannels
.where((channel) => channel.isStream)
.toList();
final dmChannels = sortDmChannelsByDisplayLabel(
visibleChannels.where((channel) => channel.isDm),
currentPubkey: currentPubkey,
);
final starredExpanded = useState(true);
final channelsExpanded = useState(true);
final dmsExpanded = useState(true);
final sortState = ref.watch(channelSortProvider);
final initialSeedComplete = useState(false);
final seededPubkey = useRef<String?>(null);
final seedCompleteForPubkey =
seededPubkey.value == readState.pubkey && initialSeedComplete.value;
useEffect(() {
if (!readState.isReady) {
return null;
}
return deferReadStateUpdate(context, () {
if (seededPubkey.value != readState.pubkey) {
seededPubkey.value = readState.pubkey;
initialSeedComplete.value = false;
}
if (initialSeedComplete.value) {
return;
}
final notifier = ref.read(readStateProvider.notifier);
for (final channel in visibleChannels) {
if (readState.effectiveTimestamp(channel.id) != null) {
continue;
}
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt != null) {
notifier.seedContextRead(channel.id, lastMessageAt);
}
}
initialSeedComplete.value = true;
});
}, [readState.isReady, readState.pubkey, visibleChannels]);
final unreadState = _computeUnreadChannelState(
channels: visibleChannels,
readState: readState,
channelsNotifier: ref.read(channelsProvider.notifier),
);
final unreadChannelIds = {
for (final channelId in unreadState.ids)
if (seedCompleteForPubkey ||
readState.effectiveTimestamp(channelId) != null)
channelId,
};
// Build sorted user-defined sections and compute which stream channels
// belong to each section. Channels not assigned to any valid section fall
// through to the built-in "Channels" list.
final userSections = sectionsState.store.sections.toList()
..sort((a, b) => a.order.compareTo(b.order));
final sectionAssignments = sectionsState.store.assignments;
final validSectionIds = {for (final s in userSections) s.id};
final assignedChannelIds = {
for (final entry in sectionAssignments.entries)
if (validSectionIds.contains(entry.value)) entry.key,
};
// Starred is exclusive: a starred channel lives only in the Starred section,
// not in its custom section or the default Channels list.
final starredStreamChannels = sortChannelsForList(
streamChannels.where((c) => starredChannelIds.contains(c.id)).toList(),
sortState.sortModeFor('starred'),
);
final ungroupedStreamChannels = sortChannelsForList(
streamChannels
.where(
(c) =>
!assignedChannelIds.contains(c.id) &&
!starredChannelIds.contains(c.id),
)
.toList(),
sortState.sortModeFor('channels'),
);
// DMs default to the display-label alphabetical order (labels can differ
// from channel names); Recent mode reorders by last message time.
final sortedDmChannels =
sortState.sortModeFor('dms') == ChannelSortMode.recent
? sortChannelsForList(dmChannels, ChannelSortMode.recent)
: dmChannels;
final liveSectionIds = [for (final s in userSections) s.id];
void setSortMode(String groupKey, ChannelSortMode mode) {
ref
.read(channelSortProvider.notifier)
.setSortModeFor(groupKey, mode, liveSectionIds: liveSectionIds);
}
final sectionExpandedStates = useState<Map<String, bool>>({});
bool sectionExpanded(String sectionId) =>
sectionExpandedStates.value[sectionId] ?? true;
void toggleSection(String sectionId) {
sectionExpandedStates.value = {
...sectionExpandedStates.value,
sectionId: !sectionExpanded(sectionId),
};
}
return SliverPadding(
padding: EdgeInsets.only(
top: Grid.xxs,
bottom: MediaQuery.paddingOf(context).bottom,
),
sliver: SliverList.list(
children: [
if (visibleChannels.isEmpty)
const _EmptyState()
else ...[
// Starred channels (exclusive — pinned above all sections).
if (starredStreamChannels.isNotEmpty)
_ChannelSection(
title: context.l10n.starred,
icon: LucideIcons.star,
showTopDivider: false,
expanded: starredExpanded.value,
onToggle: () => starredExpanded.value = !starredExpanded.value,
channels: starredStreamChannels,
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: '',
sortMode: sortState.sortModeFor('starred'),
onSortModeChange: (mode) => setSortMode('starred', mode),
onSelectChannel: onSelectChannel,
),
// User-defined sections for stream channels, in user-defined order.
for (final section in userSections)
_CustomChannelSection(
section: section,
channels: sortChannelsForList(
streamChannels
.where(
(c) =>
sectionAssignments[c.id] == section.id &&
!starredChannelIds.contains(c.id),
)
.toList(),
sortState.sortModeFor(sectionSortGroupKey(section.id)),
),
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
expanded: sectionExpanded(section.id),
isFirst: userSections.first.id == section.id,
isLast: userSections.last.id == section.id,
showTopDivider:
starredStreamChannels.isNotEmpty ||
userSections.first.id != section.id,
onToggle: () => toggleSection(section.id),
onRename: () async {
final name = await showBuzzDialog<String>(
context: context,
builder: (_) => _SectionNameDialog(
title: context.l10n.renameSection,
confirmLabel: context.l10n.rename,
initialValue: section.name,
),
);
if (name != null && name.isNotEmpty) {
ref
.read(channelSectionsProvider.notifier)
.renameSection(section.id, name);
}
},
onDelete: () async {
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(context.l10n.deleteSectionNamed(section.name)),
content: Text(context.l10n.deleteSectionConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
context.l10n.delete,
style: TextStyle(color: context.colors.error),
),
),
],
),
);
if (confirmed == true) {
ref
.read(channelSectionsProvider.notifier)
.deleteSection(section.id);
}
},
onMoveUp: () => ref
.read(channelSectionsProvider.notifier)
.moveSectionUp(section.id),
onMoveDown: () => ref
.read(channelSectionsProvider.notifier)
.moveSectionDown(section.id),
sortMode: sortState.sortModeFor(
sectionSortGroupKey(section.id),
),
onSortModeChange: (mode) =>
setSortMode(sectionSortGroupKey(section.id), mode),
onSelectChannel: onSelectChannel,
onMarkChannelRead: (channel) {
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
if (ts != null) {
ref
.read(readStateProvider.notifier)
.markContextRead(
channel.id,
ts,
clearForcedMessages: true,
);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(channel.id, ts);
}
},
),
_ChannelSection(
title: context.l10n.searchChannels,
icon: LucideIcons.hash,
showTopDivider:
starredStreamChannels.isNotEmpty || userSections.isNotEmpty,
expanded: channelsExpanded.value,
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
channels: ungroupedStreamChannels,
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: context.l10n.noStreamChannels,
sortMode: sortState.sortModeFor('channels'),
onSortModeChange: (mode) => setSortMode('channels', mode),
onSelectChannel: onSelectChannel,
),
_ChannelSection(
title: context.l10n.directMessages,
icon: LucideIcons.messagesSquare,
showTopDivider: true,
expanded: dmsExpanded.value,
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
channels: sortedDmChannels,
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: context.l10n.noDirectMessages,
sortMode: sortState.sortModeFor('dms'),
onSortModeChange: (mode) => setSortMode('dms', mode),
onSelectChannel: onSelectChannel,
),
],
],
),
);
}
}
@@ -0,0 +1,241 @@
part of '../channels_page.dart';
class _ChannelTile extends ConsumerWidget {
final Channel channel;
final bool isUnread;
final bool isMuted;
final String? currentPubkey;
final VoidCallback onTap;
/// Called when the user requests to mark this channel read (from long-press
/// actions menu). Null for channels in built-in sections.
final VoidCallback? onMarkRead;
/// The user-defined section this channel currently belongs to, or null.
final String? sectionId;
const _ChannelTile({
required this.channel,
required this.isUnread,
required this.currentPubkey,
required this.onTap,
this.isMuted = false,
this.onMarkRead,
this.sectionId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final contentColor = isMuted
? navigationSecondaryForeground(context)
: navigationPrimaryForeground(
context,
).withValues(alpha: isUnread ? 1 : 0.8);
return InkWell(
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
onLongPress: () => _showChannelActions(context, ref),
child: Padding(
padding: const EdgeInsets.only(
left: _kChannelSectionInset,
right: _kChannelSectionInset,
top: _kChannelRowVerticalPadding,
bottom: _kChannelRowVerticalPadding,
),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: channel.isDm
? _DmAvatar(channel: channel, currentPubkey: currentPubkey)
: Icon(
channelIcon(channel),
key: ValueKey('channel-icon-${channel.id}'),
size: _kChannelIconSize,
color: contentColor,
),
),
),
const SizedBox(width: _kChannelLabelGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: contentListTitleTextStyle.copyWith(
color: contentColor,
fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400,
),
),
],
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.xxs),
_EphemeralBadge(channel: channel),
],
if (isMuted) ...[
const SizedBox(width: Grid.xxs),
Icon(
LucideIcons.bellOff,
size: 12,
color: context.colors.onSurface.withValues(alpha: 0.4),
),
],
if (!channel.isMember && !channel.isDm)
Padding(
padding: const EdgeInsets.only(right: Grid.xxs),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.half + 2,
vertical: 3,
),
decoration: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Text(
context.l10n.openChannel,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
}
void _showChannelActions(BuildContext context, WidgetRef ref) {
showChannelActionsSheet(
context: context,
channel: channel,
isUnread: isUnread,
onMarkRead: onMarkRead,
sectionId: sectionId,
);
}
}
class _DmAvatar extends ConsumerWidget {
final Channel channel;
final String? currentPubkey;
const _DmAvatar({required this.channel, required this.currentPubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profiles = ref.watch(userCacheProvider);
final presenceMap = ref.watch(presenceCacheProvider);
final normalizedCurrent = currentPubkey?.toLowerCase();
final otherPubkeys = [
for (final pk in channel.participantPubkeys)
if (pk.toLowerCase() != normalizedCurrent) pk.toLowerCase(),
];
final visiblePubkeys = otherPubkeys.isNotEmpty
? otherPubkeys
: channel.participantPubkeys.map((pk) => pk.toLowerCase()).toList();
if (visiblePubkeys.length > 1) {
return Container(
width: _kDmAvatarSize,
height: _kDmAvatarSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
child: Text(
'${visiblePubkeys.length}',
style: context.textTheme.labelSmall?.copyWith(
fontSize: 9,
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
height: 1,
),
),
);
}
final otherPubkey = visiblePubkeys.isNotEmpty ? visiblePubkeys.first : null;
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
// Trigger fetches if not cached yet.
if (otherPubkey != null) {
if (profile == null) {
ref.read(userCacheProvider.notifier).preload([otherPubkey]);
}
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
}
final avatarUrl = profile?.avatarUrl;
final initial =
profile?.initial ??
(channel.participants.isNotEmpty
? channel.participants.first[0].toUpperCase()
: '?');
final presence = otherPubkey != null
? (presenceMap[otherPubkey] ?? 'offline')
: 'offline';
return SizedBox(
width: _kDmAvatarSize,
height: _kDmAvatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
AvatarImage(
imageUrl: avatarUrl,
radius: _kDmAvatarSize / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
fontSize: 9,
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _presenceColor(context, presence),
shape: BoxShape.circle,
border: Border.all(
color: context.theme.scaffoldBackgroundColor,
width: 1.5,
),
),
),
),
],
),
);
}
Color _presenceColor(BuildContext context, String presence) {
return switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
};
}
}
@@ -0,0 +1,552 @@
part of '../channels_page.dart';
class _CommunitySwitcherSheet extends HookConsumerWidget {
const _CommunitySwitcherSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final communitiesAsync = ref.watch(communityListProvider);
final activeAsync = ref.watch(activeCommunityProvider);
final isEditing = useState(false);
return SafeArea(
child: Column(
key: const Key('community-switcher-sheet'),
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xxs,
),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 32),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.switchCommunity,
key: const Key('community-switcher-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ConstrainedBox(
key: const Key('community-switcher-edit'),
constraints: const BoxConstraints(
minWidth: 48,
minHeight: 32,
),
child: TextButton(
onPressed: () => isEditing.value = !isEditing.value,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(isEditing.value ? context.l10n.done : context.l10n.edit),
),
),
),
],
),
),
),
Flexible(
child: communitiesAsync.when(
loading: () => SizedBox(
height: 120,
child: Center(
child: BuzzLoadingIndicator(
size: 40,
semanticLabel: context.l10n.loadingCommunities,
),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Text(context.l10n.communityLoadFailed(e.toString())),
),
data: (communities) {
final activeId = activeAsync.value?.id;
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
),
child: Material(
key: const Key('community-switcher-options'),
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (
var index = 0;
index < communities.length;
index++
) ...[
if (index > 0) const _CommunitySwitcherDivider(),
_CommunitySwitcherTile(
community: communities[index],
isActive: communities[index].id == activeId,
isEditing: isEditing.value,
onTap: isEditing.value
? null
: () async {
final community = communities[index];
if (community.id != activeId) {
await ref
.read(
communityListProvider.notifier,
)
.switchCommunity(community.id);
}
if (context.mounted) {
Navigator.of(context).pop();
}
},
onRemove: () => _confirmRemoveCommunity(
context,
ref,
communities[index],
closeSheetAfterRemoval:
communities[index].id == activeId,
),
),
],
if (communities.isNotEmpty)
const _CommunitySwitcherDivider(),
_AddCommunityTile(
onTap: () {
final nav = Navigator.of(
context,
rootNavigator: true,
);
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
nav.push(
MaterialPageRoute<void>(
builder: (_) =>
const PairingPage(addingCommunity: true),
),
);
},
),
],
),
),
),
);
},
),
),
],
),
);
}
}
const double _communitySwitcherAvatarSize = 36;
class _CommunitySwitcherDivider extends StatelessWidget {
const _CommunitySwitcherDivider();
@override
Widget build(BuildContext context) {
return Divider(
height: 1,
thickness: 1,
indent: Grid.xs + _communitySwitcherAvatarSize + Grid.xs,
endIndent: 0,
color: context.colors.onSurface.withValues(alpha: 0.12),
);
}
}
class _CommunitySwitcherTile extends StatelessWidget {
final Community community;
final bool isActive;
final bool isEditing;
final VoidCallback? onTap;
final VoidCallback onRemove;
const _CommunitySwitcherTile({
required this.community,
required this.isActive,
required this.isEditing,
required this.onTap,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final host = Uri.tryParse(community.relayUrl)?.host ?? community.relayUrl;
return InkWell(
key: Key('community-switcher-row-${community.id}'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.twelve,
Grid.xxs,
Grid.twelve,
),
child: Row(
children: [
_CommunityAvatar(
key: Key('community-switcher-avatar-${community.id}'),
name: community.name,
relayUrl: community.relayUrl,
size: _communitySwitcherAvatarSize,
),
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
community.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: isActive
? FontWeight.w600
: FontWeight.normal,
),
),
const SizedBox(height: Grid.quarter),
Text(
host,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
_CommunityActionSwap(
key: Key('community-switcher-action-${community.id}'),
communityId: community.id,
communityName: community.name,
isActive: isActive,
isEditing: isEditing,
onRemove: onRemove,
),
],
),
),
);
}
}
const _communityActionSwapDuration = Duration(milliseconds: 250);
const double _communityActionSwapBlur = 2;
const double _communityActionSwapStartScale = 0.25;
class _CommunityActionSwap extends StatelessWidget {
const _CommunityActionSwap({
super.key,
required this.communityId,
required this.communityName,
required this.isActive,
required this.isEditing,
required this.onRemove,
});
final String communityId;
final String communityName;
final bool isActive;
final bool isEditing;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final reduceMotion =
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
return SizedBox.square(
dimension: 40,
child: AnimatedSwitcher(
duration: reduceMotion ? Duration.zero : _communityActionSwapDuration,
reverseDuration: reduceMotion
? Duration.zero
: _communityActionSwapDuration,
transitionBuilder: (child, animation) {
final eased = CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
);
return AnimatedBuilder(
animation: eased,
child: child,
builder: (context, child) {
final progress = eased.value;
final blur = _communityActionSwapBlur * (1 - progress);
final scale =
_communityActionSwapStartScale +
((1 - _communityActionSwapStartScale) * progress);
return Opacity(
opacity: progress,
child: ImageFiltered(
enabled: blur > 0.01,
imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Transform.scale(scale: scale, child: child),
),
);
},
);
},
child: isEditing
? IconButton(
key: ValueKey('community-switcher-remove-$communityId'),
tooltip: context.l10n.removeNamedCommunity(communityName),
visualDensity: VisualDensity.compact,
onPressed: onRemove,
icon: Icon(
LucideIcons.trash2,
size: 18,
color: context.colors.error,
),
)
: Center(
key: ValueKey('community-switcher-selection-$communityId'),
child: _CommunitySelectionIndicator(
key: ValueKey('community-switcher-circle-$communityId'),
communityName: communityName,
isActive: isActive,
),
),
),
);
}
}
class _CommunitySelectionIndicator extends StatelessWidget {
const _CommunitySelectionIndicator({
super.key,
required this.communityName,
required this.isActive,
});
final String communityName;
final bool isActive;
@override
Widget build(BuildContext context) {
return Semantics(
label: isActive
? context.l10n.currentCommunity(communityName)
: context.l10n.switchToCommunity(communityName),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isActive ? context.appColors.success : Colors.transparent,
border: isActive
? null
: Border.all(color: context.colors.outlineVariant, width: 2),
shape: BoxShape.circle,
),
child: isActive
? const Icon(LucideIcons.check, color: Colors.white, size: 16)
: null,
),
);
}
}
class _AddCommunityTile extends StatelessWidget {
const _AddCommunityTile({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: const Key('community-switcher-add'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
child: Row(
children: [
Container(
width: _communitySwitcherAvatarSize,
height: _communitySwitcherAvatarSize,
decoration: BoxDecoration(
color: context.colors.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.plus,
size: 18,
color: context.colors.onPrimaryContainer,
),
),
const SizedBox(width: Grid.xs),
Text(
context.l10n.addCommunity,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
}
Future<void> _confirmRemoveCommunity(
BuildContext context,
WidgetRef ref,
Community community, {
required bool closeSheetAfterRemoval,
}) async {
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog.adaptive(
title: Text(context.l10n.removeCommunityQuestion),
content: Text(
context.l10n.removeCommunityConfirm(community.name),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: context.colors.error),
child: Text(context.l10n.remove),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(communityListProvider.notifier)
.removeCommunity(community.id);
if (closeSheetAfterRemoval && context.mounted) {
Navigator.of(context).pop();
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(context.l10n.failedRemoveCommunity(e.toString()))),
);
}
}
class _CommunityIndicator extends ConsumerWidget {
final VoidCallback onTap;
const _CommunityIndicator({required this.onTap});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeAsync = ref.watch(activeCommunityProvider);
final activeCommunity = activeAsync.value;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: _CommunityAvatar(
name: activeCommunity?.name,
relayUrl: activeCommunity?.relayUrl,
),
);
}
}
class _CommunityHeaderTitle extends ConsumerWidget {
final TextStyle? style;
final VoidCallback onTap;
const _CommunityHeaderTitle({required this.onTap, this.style});
@override
Widget build(BuildContext context, WidgetRef ref) {
final name = ref.watch(activeCommunityProvider).value?.name;
final title = name?.trim();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox.expand(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: Grid.xxs),
child: Text(
title == null || title.isEmpty ? context.l10n.community : title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
),
),
),
),
);
}
}
class _CommunityAvatar extends ConsumerWidget {
final String? name;
final String? relayUrl;
final double size;
const _CommunityAvatar({
super.key,
required this.name,
this.relayUrl,
this.size = _kTopSectionAvatarSize,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trimmedName = name?.trim();
final initial = trimmedName != null && trimmedName.isNotEmpty
? trimmedName.substring(0, 1).toUpperCase()
: '?';
final relay = relayUrl;
final iconUrl = relay == null
? null
: ref.watch(communityIconProvider(relay)).value;
return AvatarImage(
imageUrl: iconUrl,
radius: size / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,342 @@
part of '../channels_page.dart';
const _kMorphOpenDuration = Duration(milliseconds: 280);
const _kMorphCloseDuration = Duration(milliseconds: 240);
const _kMorphFadeDuration = Duration(milliseconds: 200);
const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1);
const double _kMorphOpenBounce = 0.14;
const double _kMorphCloseBounce = 0.06;
const double _kMorphClosedSize = 56;
const double _kMorphOpenHeight = 160;
const double _kMorphOpenRadius = 20;
const double _kMorphSlide = 40;
const double _kMorphScale = 0.97;
const double _kMorphBlur = 2;
const double _kQuickActionCardOverlayOpacity = 0.1;
const double _kQuickActionCardRadius = _kMorphOpenRadius - Grid.xxs;
class _MorphingQuickActionsButton extends HookWidget {
final bool open;
final double openEdgeOffset;
final VoidCallback onToggle;
final ValueChanged<_QuickAction> onSelected;
const _MorphingQuickActionsButton({
required this.open,
required this.openEdgeOffset,
required this.onToggle,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations;
final mediaQuery = MediaQuery.of(context);
final openWidth = (mediaQuery.size.width - (Grid.gutter * 2))
.clamp(_kMorphClosedSize, double.infinity)
.toDouble();
final surfaceController = useAnimationController(
initialValue: open ? 1 : 0,
upperBound: 1.02,
);
final fadeController = useAnimationController(
duration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
reverseDuration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
initialValue: open ? 1 : 0,
);
final fadeAnimation = useMemoized(
() => CurvedAnimation(
parent: fadeController,
curve: _kMorphCloseCurve,
reverseCurve: _kMorphCloseCurve,
),
[fadeController],
);
final animation = useMemoized(
() => Listenable.merge([surfaceController, fadeAnimation]),
[surfaceController, fadeAnimation],
);
useEffect(() => fadeAnimation.dispose, [fadeAnimation]);
useEffect(() {
fadeController.duration = reducedMotion
? Duration.zero
: _kMorphFadeDuration;
fadeController.reverseDuration = reducedMotion
? Duration.zero
: _kMorphFadeDuration;
if (reducedMotion) {
surfaceController.value = open ? 1 : 0;
fadeController.value = open ? 1 : 0;
} else {
final target = open ? 1.0 : 0.0;
if ((surfaceController.value - target).abs() > 0.001) {
unawaited(
surfaceController.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: open ? _kMorphOpenDuration : _kMorphCloseDuration,
bounce: open ? _kMorphOpenBounce : _kMorphCloseBounce,
),
surfaceController.value,
target,
surfaceController.velocity,
snapToEnd: true,
),
),
);
}
if (open) {
unawaited(fadeController.forward());
} else {
unawaited(fadeController.reverse());
}
}
return null;
}, [fadeController, open, reducedMotion, surfaceController]);
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
final surfaceValue = surfaceController.value;
final surfaceProgress = surfaceValue.clamp(0.0, 1.0);
final fadeValue = fadeAnimation.value.clamp(0.0, 1.0);
final width = lerpDouble(_kMorphClosedSize, openWidth, surfaceValue)!;
final height = lerpDouble(
_kMorphClosedSize,
_kMorphOpenHeight,
surfaceValue,
)!;
final radius = lerpDouble(
_kMorphClosedSize / 2,
_kMorphOpenRadius,
surfaceValue,
)!;
final borderRadius = BorderRadius.circular(radius);
return Transform.translate(
offset: Offset(openEdgeOffset * surfaceProgress, 0),
child: SizedBox(
key: const Key('quick-actions-surface'),
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.24),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: Material(
type: MaterialType.transparency,
child: Stack(
children: [
Positioned.fill(
child: OverflowBox(
alignment: Alignment.bottomRight,
minWidth: openWidth,
maxWidth: openWidth,
minHeight: _kMorphOpenHeight,
maxHeight: _kMorphOpenHeight,
child: IgnorePointer(
ignoring: !open || fadeValue < 0.9,
child: ExcludeSemantics(
excluding: !open,
child: Opacity(
opacity: fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * (1 - fadeValue),
sigmaY: _kMorphBlur * (1 - fadeValue),
),
child: Transform.translate(
offset: Offset(
_kMorphSlide * (1 - surfaceProgress),
0,
),
child: Transform.scale(
alignment: Alignment.bottomRight,
scale:
_kMorphScale +
((1 - _kMorphScale) *
surfaceProgress),
child: _QuickActionsMenu(
onSelected: onSelected,
),
),
),
),
),
),
),
),
),
Positioned(
right: 0,
bottom: 0,
width: _kMorphClosedSize,
height: _kMorphClosedSize,
child: IgnorePointer(
ignoring: open,
child: ExcludeSemantics(
excluding: open,
child: Opacity(
opacity: 1 - fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * fadeValue,
sigmaY: _kMorphBlur * fadeValue,
),
child: Transform.translate(
offset: Offset(
-_kMorphSlide * surfaceProgress,
0,
),
child: Transform.rotate(
angle: (pi / 4) * surfaceProgress,
child: Transform.scale(
scale:
1 -
((1 - _kMorphScale) *
surfaceProgress),
child: Tooltip(
message:
context.l10n.createOrStartConversation,
child: Semantics(
button: true,
label:
context.l10n.createOrStartConversation,
expanded: open,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onToggle,
child: Center(
child: Icon(
LucideIcons.plus,
color: context.colors.onPrimary,
),
),
),
),
),
),
),
),
),
),
),
),
),
],
),
),
),
),
),
);
},
);
}
}
class _QuickActionsMenu extends StatelessWidget {
final ValueChanged<_QuickAction> onSelected;
const _QuickActionsMenu({required this.onSelected});
@override
Widget build(BuildContext context) {
return Padding(
key: const Key('quick-actions-menu'),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_QuickActionItem(
icon: LucideIcons.hash,
title: context.l10n.createChannel,
key: const Key('quick-action-create-channel-card'),
onTap: () => onSelected(_QuickAction.createChannel),
),
const SizedBox(height: Grid.xxs),
_QuickActionItem(
icon: LucideIcons.messagesSquare,
title: context.l10n.newDirectMessage,
key: const Key('quick-action-new-dm-card'),
onTap: () => onSelected(_QuickAction.newDm),
),
],
),
);
}
}
class _QuickActionItem extends StatelessWidget {
final IconData icon;
final String title;
final VoidCallback onTap;
const _QuickActionItem({
super.key,
required this.icon,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final foreground = context.colors.onPrimary;
final background = Color.alphaBlend(
foreground.withValues(alpha: _kQuickActionCardOverlayOpacity),
context.colors.primary,
);
return Expanded(
child: Material(
color: background,
borderRadius: BorderRadius.circular(_kQuickActionCardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Row(
children: [
Icon(icon, size: 24, color: foreground),
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,184 @@
part of '../channels_page.dart';
const _kQuickActionsTabMotionDuration = Duration(milliseconds: 220);
const _kQuickActionsTabMotionCurve = Cubic(0.77, 0, 0.175, 1);
const _kQuickActionsHiddenOverlap = Grid.half;
const _kQuickActionsHiddenScale = 0.8;
/// Places the channel quick-actions button beside mobile navigation.
///
/// The button remains available only on Home and moves behind the navigation
/// bar when another destination is selected.
class ChannelQuickActionsLauncher extends HookConsumerWidget {
/// Whether the launcher should be visible beside the navigation bar.
final bool visible;
/// Height of the navigation bar used to vertically center the closed button.
final double navigationBarHeight;
/// Space between the navigation bar and the bottom safe area.
final double navigationBarBottomGap;
/// Full width of the navigation bar, including its inner edge padding.
final double navigationBarWidth;
/// Bottom system inset used by the navigation bar's [SafeArea].
final double systemBottomInset;
/// Distance between the launcher and the right edge of the screen.
final double rightInset;
/// Creates a launcher aligned with the supplied navigation geometry.
const ChannelQuickActionsLauncher({
super.key,
required this.visible,
required this.navigationBarHeight,
required this.navigationBarBottomGap,
required this.navigationBarWidth,
required this.systemBottomInset,
required this.rightInset,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentPubkey = ref.watch(currentPubkeyProvider);
final quickActionsOpen = useState(false);
final reducedMotion = MediaQuery.of(context).disableAnimations;
final navigationBottomInset = systemBottomInset > navigationBarBottomGap
? systemBottomInset
: navigationBarBottomGap;
final closedBottomInset =
navigationBottomInset + ((navigationBarHeight - _kMorphClosedSize) / 2);
final openLift =
navigationBarHeight +
Grid.xxs -
((navigationBarHeight - _kMorphClosedSize) / 2);
final effectiveOpen = visible && quickActionsOpen.value;
final screenWidth = MediaQuery.sizeOf(context).width;
final navigationBarRight = (screenWidth + navigationBarWidth) / 2;
final launcherRight = screenWidth - rightInset;
final hiddenHorizontalOffset =
navigationBarRight - launcherRight - _kQuickActionsHiddenOverlap;
useEffect(() {
if (!visible && quickActionsOpen.value) {
quickActionsOpen.value = false;
}
return null;
}, [visible]);
Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
}
Future<void> selectQuickAction(_QuickAction action) async {
quickActionsOpen.value = false;
if (!reducedMotion) {
await Future<void>.delayed(_kMorphCloseDuration);
}
if (!context.mounted) return;
switch (action) {
case _QuickAction.createChannel:
final created = await showBuzzModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
);
if (created != null && context.mounted) {
await openChannel(created);
}
case _QuickAction.newDm:
final opened = await showBuzzModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
_NewDirectMessageSheet(currentPubkey: currentPubkey),
);
if (opened != null && context.mounted) {
await openChannel(opened);
}
}
}
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
if (quickActionsOpen.value)
Positioned.fill(
child: Semantics(
button: true,
label: context.l10n.closeQuickActions,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => quickActionsOpen.value = false,
),
),
),
AnimatedPositioned(
duration: reducedMotion
? Duration.zero
: _kQuickActionsTabMotionDuration,
curve: _kQuickActionsTabMotionCurve,
right: rightInset,
bottom: closedBottomInset + (effectiveOpen ? openLift : 0),
child: IgnorePointer(
ignoring: !visible,
child: ExcludeSemantics(
excluding: !visible,
child: TweenAnimationBuilder<double>(
key: const Key('channel-quick-actions-motion'),
tween: Tween(end: visible ? 0 : 1),
duration: reducedMotion
? Duration.zero
: _kQuickActionsTabMotionDuration,
curve: _kQuickActionsTabMotionCurve,
builder: (context, hiddenProgress, child) => Opacity(
key: const Key('channel-quick-actions-opacity'),
opacity: 1 - hiddenProgress,
child: Transform.translate(
key: const Key('channel-quick-actions-transform'),
offset: Offset(hiddenHorizontalOffset * hiddenProgress, 0),
child: Transform.scale(
key: const Key('channel-quick-actions-scale'),
scale:
1 -
((1 - _kQuickActionsHiddenScale) * hiddenProgress),
child: child,
),
),
),
child: _MorphingQuickActionsButton(
open: effectiveOpen,
openEdgeOffset: rightInset - Grid.gutter,
onToggle: () {
unawaited(HapticFeedback.lightImpact());
quickActionsOpen.value = !quickActionsOpen.value;
},
onSelected: (action) => unawaited(selectQuickAction(action)),
),
),
),
),
),
],
);
}
}
BoxConstraints _quickActionSheetConstraints(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return BoxConstraints(
maxHeight: mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.sm,
);
}
@@ -0,0 +1,722 @@
part of '../channels_page.dart';
const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0);
class _CustomChannelSection extends StatelessWidget {
final ChannelSection section;
final List<Channel> channels;
final Set<String> unreadChannelIds;
final Set<String> mutedChannelIds;
final String? currentPubkey;
final bool expanded;
final bool isFirst;
final bool isLast;
final bool showTopDivider;
final VoidCallback onToggle;
final VoidCallback onRename;
final VoidCallback onDelete;
final VoidCallback onMoveUp;
final VoidCallback onMoveDown;
final ChannelSortMode sortMode;
final ValueChanged<ChannelSortMode> onSortModeChange;
final Future<void> Function(Channel channel) onSelectChannel;
final void Function(Channel channel) onMarkChannelRead;
const _CustomChannelSection({
required this.section,
required this.channels,
required this.unreadChannelIds,
required this.mutedChannelIds,
required this.currentPubkey,
required this.expanded,
required this.isFirst,
required this.isLast,
required this.showTopDivider,
required this.onToggle,
required this.onRename,
required this.onDelete,
required this.onMoveUp,
required this.onMoveDown,
required this.sortMode,
required this.onSortModeChange,
required this.onSelectChannel,
required this.onMarkChannelRead,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showTopDivider) const _SectionDivider(),
_CustomSectionHeader(
section: section,
expanded: expanded,
isFirst: isFirst,
isLast: isLast,
onToggle: onToggle,
onRename: onRename,
onDelete: onDelete,
onMoveUp: onMoveUp,
onMoveDown: onMoveDown,
sortMode: sortMode,
onSortModeChange: onSortModeChange,
),
_AnimatedSectionBody(
expanded: expanded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final channel in channels)
_ChannelTile(
channel: channel,
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
onMarkRead: () => onMarkChannelRead(channel),
sectionId: section.id,
),
const SizedBox(height: _kExpandedSectionTrailingPadding),
],
),
),
],
);
}
}
class _CustomSectionHeader extends ConsumerWidget {
final ChannelSection section;
final bool expanded;
final bool isFirst;
final bool isLast;
final VoidCallback onToggle;
final VoidCallback onRename;
final VoidCallback onDelete;
final VoidCallback onMoveUp;
final VoidCallback onMoveDown;
final ChannelSortMode sortMode;
final ValueChanged<ChannelSortMode> onSortModeChange;
const _CustomSectionHeader({
required this.section,
required this.expanded,
required this.isFirst,
required this.isLast,
required this.onToggle,
required this.onRename,
required this.onDelete,
required this.onMoveUp,
required this.onMoveDown,
required this.sortMode,
required this.onSortModeChange,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sectionColor = navigationSectionForeground(context);
final icon = section.icon;
final customEmoji = icon == null
? null
: _resolveCustomEmoji(icon, ref.watch(customEmojiListProvider));
return GestureDetector(
onTap: onToggle,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
_kSectionHeaderVerticalPadding,
Grid.gutter,
_kSectionHeaderVerticalPadding,
),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: icon == null || icon.isEmpty
? Icon(
LucideIcons.folder,
size: _kChannelIconSize,
color: sectionColor,
)
: customEmoji != null
? CustomEmojiImage(
shortcode: customEmoji.shortcode,
url: customEmoji.url,
size: _kChannelIconSize,
)
: Text(
icon,
maxLines: 1,
overflow: TextOverflow.visible,
style: TextStyle(
fontSize: _kChannelIconSize,
height: 1,
color: sectionColor,
),
),
),
),
const SizedBox(width: _kChannelLabelGap),
Expanded(
child: Text(
section.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: contentListTitleTextStyle.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),
),
),
Builder(
builder: (buttonContext) => IconButton(
key: ValueKey('section-menu-${section.id}'),
tooltip: context.l10n.optionsFor(section.name),
visualDensity: VisualDensity.compact,
icon: Icon(
LucideIcons.ellipsisVertical,
size: _kChannelIconSize,
color: sectionColor,
),
onPressed: () async {
final value = await showAnchoredPopover<String>(
context: buttonContext,
width: 216,
alignment: AnchoredPopoverAlignment.end,
surfaceKey: ValueKey('section-popover-${section.id}'),
items: [
PopupMenuItem(
value: 'rename',
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(
icon: LucideIcons.pencil,
label: context.l10n.renameSection,
),
),
PopupMenuItem(
value: 'move_up',
enabled: !isFirst,
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(
icon: LucideIcons.arrowUp,
label: context.l10n.moveUp,
),
),
PopupMenuItem(
value: 'move_down',
enabled: !isLast,
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(
icon: LucideIcons.arrowDown,
label: context.l10n.moveDown,
),
),
..._sortMenuItems(sortMode, l10n: context.l10n),
PopupMenuItem(
value: 'delete',
padding: _sectionMenuItemPadding,
child: _SectionMenuItemContent(
icon: LucideIcons.trash2,
label: context.l10n.deleteSection,
color: context.colors.error,
),
),
],
);
switch (value) {
case 'rename':
onRename();
case 'move_up':
onMoveUp();
case 'move_down':
onMoveDown();
case _kSortRecentMenuValue:
onSortModeChange(ChannelSortMode.recent);
case _kSortAlphaMenuValue:
onSortModeChange(ChannelSortMode.alpha);
case 'delete':
onDelete();
}
},
),
),
const SizedBox(width: Grid.quarter),
_SectionChevron(expanded: expanded, color: sectionColor),
],
),
),
);
}
}
class _SectionMenuItemContent extends StatelessWidget {
final IconData icon;
final String label;
final Color? color;
const _SectionMenuItemContent({
required this.icon,
required this.label,
this.color,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: color == null ? null : TextStyle(color: color),
),
),
],
);
}
}
CustomEmoji? _resolveCustomEmoji(String icon, List<CustomEmoji> palette) {
if (!icon.startsWith(':') || !icon.endsWith(':')) return null;
final shortcode = normalizeShortcode(icon);
if (shortcode == null) return null;
for (final emoji in palette) {
if (emoji.shortcode == shortcode) return emoji;
}
return null;
}
class _SectionNameDialog extends HookWidget {
final String title;
final String confirmLabel;
final String initialValue;
const _SectionNameDialog({
required this.title,
required this.confirmLabel,
this.initialValue = '',
});
@override
Widget build(BuildContext context) {
final controller = useTextEditingController(text: initialValue);
void confirm() {
final name = controller.text.trim();
if (name.isNotEmpty) Navigator.of(context).pop(name);
}
return AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(labelText: context.l10n.sectionName),
onSubmitted: (_) => confirm(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.cancel),
),
TextButton(onPressed: confirm, child: Text(confirmLabel)),
],
);
}
}
const _kSortRecentMenuValue = 'sort_recent';
const _kSortAlphaMenuValue = 'sort_alpha';
PopupMenuItem<String> _sortMenuItem({
required String value,
required String label,
required bool selected,
}) => PopupMenuItem(
value: value,
child: Row(
children: [
Expanded(child: Text(label)),
if (selected)
const Icon(LucideIcons.check, key: ValueKey('sort-selected-check'))
else
const SizedBox(width: 24),
],
),
);
List<PopupMenuEntry<String>> _sortMenuItems(
ChannelSortMode current, {
required AppLocalizations l10n,
bool showDivider = true,
}) => [
if (showDivider) const PopupMenuDivider(),
_sortMenuItem(
value: _kSortRecentMenuValue,
label: l10n.sortRecent,
selected: current == ChannelSortMode.recent,
),
_sortMenuItem(
value: _kSortAlphaMenuValue,
label: l10n.sortAlpha,
selected: current == ChannelSortMode.alpha,
),
];
class _ChannelSection extends StatelessWidget {
final String title;
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
final List<Channel> channels;
final bool showTopDivider;
final Set<String> unreadChannelIds;
final Set<String> mutedChannelIds;
final String? currentPubkey;
final String emptyLabel;
final ChannelSortMode? sortMode;
final ValueChanged<ChannelSortMode>? onSortModeChange;
final Future<void> Function(Channel channel) onSelectChannel;
const _ChannelSection({
required this.title,
required this.icon,
required this.expanded,
required this.onToggle,
required this.channels,
required this.showTopDivider,
required this.unreadChannelIds,
required this.mutedChannelIds,
required this.currentPubkey,
required this.emptyLabel,
this.sortMode,
this.onSortModeChange,
required this.onSelectChannel,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showTopDivider) const _SectionDivider(),
_SectionHeader(
label: title,
icon: icon,
expanded: expanded,
onToggle: onToggle,
sortMode: sortMode,
onSortModeChange: onSortModeChange,
),
_AnimatedSectionBody(
expanded: expanded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (channels.isEmpty)
Padding(
padding: const EdgeInsets.only(
left: _kChannelLabelInset,
right: _kChannelSectionInset,
top: Grid.half,
bottom: Grid.half,
),
child: Text(
emptyLabel,
style: contentListBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
)
else
for (final channel in channels)
_ChannelTile(
channel: channel,
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
onMarkRead: null,
sectionId: null,
),
const SizedBox(height: _kExpandedSectionTrailingPadding),
],
),
),
],
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.sizeOf(context).height * 0.55,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.messagesSquare,
size: Grid.xl,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xs),
Text(
context.l10n.noConversations,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
);
}
}
class _SectionDivider extends StatelessWidget {
const _SectionDivider();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Divider(
height: 1,
thickness: 1,
indent: _kChannelSectionInset,
endIndent: _kChannelSectionInset,
color: context.colors.primary.withValues(alpha: 0.15),
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String label;
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
final ChannelSortMode? sortMode;
final ValueChanged<ChannelSortMode>? onSortModeChange;
const _SectionHeader({
required this.label,
required this.icon,
required this.expanded,
required this.onToggle,
this.sortMode,
this.onSortModeChange,
});
@override
Widget build(BuildContext context) {
final sectionColor = navigationSectionForeground(context);
return GestureDetector(
onTap: onToggle,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
_kSectionHeaderVerticalPadding,
Grid.gutter,
_kSectionHeaderVerticalPadding,
),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(icon, size: _kChannelIconSize, color: sectionColor),
),
),
const SizedBox(width: _kChannelLabelGap),
Text(
label,
style: contentListTitleTextStyle.copyWith(
color: sectionColor,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (sortMode case final mode?) ...[
Builder(
builder: (buttonContext) => IconButton(
key: ValueKey('sort-menu-$label'),
tooltip: context.l10n.optionsFor(label),
visualDensity: VisualDensity.compact,
icon: Icon(
LucideIcons.ellipsisVertical,
size: _kChannelIconSize,
color: sectionColor,
),
onPressed: () async {
final value = await showAnchoredPopover<String>(
context: buttonContext,
width: 216,
alignment: AnchoredPopoverAlignment.end,
color: context.colors.surface,
elevation: 4,
shadowColor: context.colors.shadow.withValues(
alpha: 0.18,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: context.colors.outline),
),
surfaceKey: ValueKey('sort-popover-$label'),
items: _sortMenuItems(
mode,
l10n: context.l10n,
showDivider: false,
),
);
if (value == _kSortRecentMenuValue) {
onSortModeChange?.call(ChannelSortMode.recent);
} else if (value == _kSortAlphaMenuValue) {
onSortModeChange?.call(ChannelSortMode.alpha);
}
},
),
),
const SizedBox(width: Grid.quarter),
],
_SectionChevron(expanded: expanded, color: sectionColor),
],
),
),
);
}
}
class _SectionChevron extends StatelessWidget {
final bool expanded;
final Color color;
const _SectionChevron({required this.expanded, required this.color});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations;
return AnimatedRotation(
turns: expanded ? 0 : -0.25,
duration: reducedMotion ? Duration.zero : _kSectionExpandDuration,
curve: _kSectionExpandCurve,
child: Icon(
LucideIcons.chevronDown,
size: _kChannelIconSize,
color: color,
),
);
}
}
class _AnimatedSectionBody extends HookWidget {
final bool expanded;
final Widget child;
const _AnimatedSectionBody({required this.expanded, required this.child});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations;
final controller = useAnimationController(
duration: reducedMotion ? Duration.zero : _kSectionExpandDuration,
reverseDuration: reducedMotion
? Duration.zero
: _kSectionCollapseDuration,
initialValue: expanded ? 1 : 0,
);
final curvedAnimation = useMemoized(
() => CurvedAnimation(
parent: controller,
curve: _kSectionExpandCurve,
reverseCurve: _kSectionCollapseCurve,
),
[controller],
);
final shouldRender = useState(expanded);
useEffect(() => curvedAnimation.dispose, [curvedAnimation]);
useEffect(() {
void handleStatus(AnimationStatus status) {
if (status == AnimationStatus.dismissed && !expanded) {
shouldRender.value = false;
}
}
controller.addStatusListener(handleStatus);
return () => controller.removeStatusListener(handleStatus);
}, [controller, expanded]);
useEffect(() {
controller.duration = reducedMotion
? Duration.zero
: _kSectionExpandDuration;
controller.reverseDuration = reducedMotion
? Duration.zero
: _kSectionCollapseDuration;
if (expanded) {
shouldRender.value = true;
if (reducedMotion) {
controller.value = 1;
} else {
unawaited(controller.forward());
}
} else if (reducedMotion) {
controller.value = 0;
shouldRender.value = false;
} else {
unawaited(controller.reverse());
}
return null;
}, [controller, expanded, reducedMotion]);
return ClipRect(
child: AnimatedBuilder(
animation: curvedAnimation,
child: shouldRender.value ? child : const SizedBox.shrink(),
builder: (context, child) {
final value = curvedAnimation.value.clamp(0.0, 1.0);
final scaleY =
_kSectionCollapsedScaleY +
((1 - _kSectionCollapsedScaleY) * value);
return Align(
alignment: Alignment.topCenter,
heightFactor: value,
child: Opacity(
opacity: value,
child: Transform.scale(
alignment: Alignment.topCenter,
scaleY: scaleY,
child: ExcludeSemantics(
excluding: !expanded,
child: IgnorePointer(ignoring: !expanded, child: child),
),
),
),
);
},
),
);
}
}
@@ -0,0 +1,909 @@
part of '../channels_page.dart';
const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60;
class _CreateChannelMenuOption<T> {
final Key? key;
final String label;
final T value;
const _CreateChannelMenuOption({
this.key,
required this.label,
required this.value,
});
}
const _createChannelTtlOptions = [
_CreateChannelMenuOption(label: '30 minutes', value: 30 * 60),
_CreateChannelMenuOption(label: '1 hour', value: 60 * 60),
_CreateChannelMenuOption(label: '6 hours', value: 6 * 60 * 60),
_CreateChannelMenuOption(label: '12 hours', value: 12 * 60 * 60),
_CreateChannelMenuOption(label: '1 day', value: 24 * 60 * 60),
_CreateChannelMenuOption(label: '3 days', value: 3 * 24 * 60 * 60),
_CreateChannelMenuOption(
label: '7 days',
value: _defaultCreateChannelTtlSeconds,
),
_CreateChannelMenuOption(label: '14 days', value: 14 * 24 * 60 * 60),
_CreateChannelMenuOption(label: '30 days', value: 30 * 24 * 60 * 60),
];
String _localizedTtlLabel(BuildContext context, int seconds) {
final l10n = context.l10n;
if (seconds % (24 * 60 * 60) == 0) {
return l10n.durationDays(seconds ~/ (24 * 60 * 60));
}
if (seconds % (60 * 60) == 0) {
return l10n.durationHours(seconds ~/ (60 * 60));
}
return l10n.durationMinutes(seconds ~/ 60);
}
class _CreateChannelSheet extends HookConsumerWidget {
final String channelType;
const _CreateChannelSheet({required this.channelType});
@override
Widget build(BuildContext context, WidgetRef ref) {
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final visibility = useState('open');
final temporary = useState(false);
final ttlSeconds = useState(_defaultCreateChannelTtlSeconds);
final isSubmitting = useState(false);
final errorMessage = useState<String?>(null);
useListenable(nameController);
final kindLabel = channelType == 'forum'
? context.l10n.forum
: context.l10n.channel;
final ttlOptions = [
for (final option in _createChannelTtlOptions)
_CreateChannelMenuOption(
key: option.key,
label: _localizedTtlLabel(context, option.value),
value: option.value,
),
];
final canSubmit =
nameController.text.trim().isNotEmpty && !isSubmitting.value;
Future<void> submit() async {
final name = nameController.text.trim();
if (name.isEmpty || isSubmitting.value) {
return;
}
isSubmitting.value = true;
errorMessage.value = null;
try {
final created = await ref
.read(channelActionsProvider)
.createChannel(
name: name,
channelType: channelType,
visibility: visibility.value,
description: descriptionController.text.trim(),
ttlSeconds: temporary.value ? ttlSeconds.value : null,
);
if (context.mounted) {
Navigator.of(context).pop(created);
}
} catch (error) {
errorMessage.value = error.toString();
} finally {
isSubmitting.value = false;
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.createNewKind(kindLabel),
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
const SizedBox(height: Grid.sm),
_CreateChannelFieldLabel(label: context.l10n.sectionName),
const SizedBox(height: Grid.xxs),
_CreateChannelFieldShell(
child: TextField(
key: const Key('create-channel-name'),
controller: nameController,
enabled: !isSubmitting.value,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
hintText: channelType == 'forum'
? context.l10n.forumNameHint
: context.l10n.channelNameHint,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
),
),
const SizedBox(height: Grid.xs),
_CreateChannelFieldLabel(
label: context.l10n.description,
optional: true,
),
const SizedBox(height: Grid.xxs),
_CreateChannelFieldShell(
child: TextField(
key: const Key('create-channel-description'),
controller: descriptionController,
enabled: !isSubmitting.value,
decoration: InputDecoration(
hintText: context.l10n.channelPurposeHint(kindLabel),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
),
minLines: 2,
maxLines: 3,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
),
),
const SizedBox(height: Grid.sm),
_CreateChannelRadioGroup<bool>(
enabled: !isSubmitting.value,
label: context.l10n.channelType,
onSelected: (value) => temporary.value = value,
options: [
_CreateChannelMenuOption(
key: Key('create-channel-type-ongoing'),
label: context.l10n.ongoing,
value: false,
),
_CreateChannelMenuOption(
key: Key('create-channel-type-temporary'),
label: context.l10n.temporary,
value: true,
),
],
value: temporary.value,
),
if (temporary.value) ...[
const SizedBox(height: Grid.xs),
_CreateChannelSettingMenu<int>(
controlKey: const Key('create-channel-ttl'),
enabled: !isSubmitting.value,
label: context.l10n.expiresAfter,
onSelected: (value) => ttlSeconds.value = value,
options: ttlOptions,
value: ttlSeconds.value,
valueLabel: ttlOptions
.firstWhere((option) => option.value == ttlSeconds.value)
.label,
),
],
const SizedBox(height: Grid.sm),
_CreateChannelRadioGroup<String>(
enabled: !isSubmitting.value,
label: context.l10n.visibility,
onSelected: (value) => visibility.value = value,
options: [
_CreateChannelMenuOption(
key: Key('create-channel-visibility-public'),
label: context.l10n.publicVisibility,
value: 'open',
),
_CreateChannelMenuOption(
key: Key('create-channel-visibility-private'),
label: context.l10n.privateVisibility,
value: 'private',
),
],
value: visibility.value,
),
if (errorMessage.value case final error?) ...[
const SizedBox(height: Grid.xs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
],
),
),
),
);
}
}
class _CreateChannelFieldLabel extends StatelessWidget {
final String label;
final bool optional;
const _CreateChannelFieldLabel({required this.label, this.optional = false});
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: [
TextSpan(text: label),
if (optional)
TextSpan(
text: ' ${context.l10n.optional}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
fontWeight: FontWeight.w400,
),
),
],
),
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
);
}
}
class _CreateChannelFieldShell extends StatelessWidget {
final Widget child;
const _CreateChannelFieldShell({required this.child});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primaryContainer.withValues(alpha: 0.55),
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: child,
);
}
}
class _CreateChannelRadioGroup<T> extends StatelessWidget {
final bool enabled;
final String label;
final ValueChanged<T> onSelected;
final List<_CreateChannelMenuOption<T>> options;
final T value;
const _CreateChannelRadioGroup({
required this.enabled,
required this.label,
required this.onSelected,
required this.options,
required this.value,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CreateChannelFieldLabel(label: label),
const SizedBox(height: Grid.xxs),
DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: RadioGroup<T>(
groupValue: value,
onChanged: (nextValue) {
if (enabled && nextValue != null) {
onSelected(nextValue);
}
},
child: Column(
children: [
for (final (index, option) in options.indexed) ...[
RadioListTile<T>(
key: option.key,
value: option.value,
enabled: enabled,
selected: option.value == value,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.half,
),
visualDensity: VisualDensity.standard,
activeColor: context.colors.primary,
title: Text(
option.label,
style: context.textTheme.bodyMedium?.copyWith(
color: enabled
? context.colors.onSurface
: context.colors.onSurface.withValues(alpha: 0.38),
fontWeight: option.value == value
? FontWeight.w600
: FontWeight.w400,
),
),
),
if (index < options.length - 1)
Divider(
height: 1,
indent: Grid.xs,
endIndent: Grid.xs,
color: context.colors.outlineVariant,
),
],
],
),
),
),
],
);
}
}
class _CreateChannelSettingMenu<T> extends StatelessWidget {
final Key controlKey;
final bool enabled;
final String label;
final ValueChanged<T> onSelected;
final List<_CreateChannelMenuOption<T>> options;
final T value;
final IconData? valueIcon;
final String valueLabel;
const _CreateChannelSettingMenu({
required this.controlKey,
required this.enabled,
required this.label,
required this.onSelected,
required this.options,
required this.value,
this.valueIcon,
required this.valueLabel,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: PopupMenuButton<T>(
enabled: enabled,
initialValue: value,
onSelected: onSelected,
tooltip: '$label: $valueLabel',
itemBuilder: (context) => [
for (final option in options)
CheckedPopupMenuItem<T>(
checked: option.value == value,
value: option.value,
child: Text(option.label),
),
],
child: Padding(
key: controlKey,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
if (valueIcon case final icon?) ...[
Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
const SizedBox(width: Grid.half),
],
Text(
valueLabel,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: Grid.half),
Icon(
LucideIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
),
],
),
),
),
);
}
}
class _NewDirectMessageSheet extends HookConsumerWidget {
final String? currentPubkey;
const _NewDirectMessageSheet({required this.currentPubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final queryController = useTextEditingController();
final queryFocusNode = useFocusNode();
final query = useState('');
final debouncedQuery = useState('');
final selectedUsers = useState<List<DirectoryUser>>([]);
final isSubmitting = useState(false);
final submitError = useState<String?>(null);
final previewDirectoryActive = ref.watch(dmDirectoryPreviewEnabledProvider);
useEffect(() {
final timer = Timer(const Duration(milliseconds: 250), () {
debouncedQuery.value = query.value.trim().toLowerCase();
});
return timer.cancel;
}, [query.value]);
final normalizedQuery = previewDirectoryActive
? query.value.trim().toLowerCase()
: debouncedQuery.value;
final isSearchTransitionPending =
!previewDirectoryActive &&
query.value.trim().toLowerCase() != normalizedQuery;
final directoryAsync = previewDirectoryActive
? AsyncValue.data(dmDirectoryPreviewUsers)
: normalizedQuery.isEmpty
? ref.watch(relayDirectoryUsersProvider)
: ref.watch(relayDirectorySearchProvider(normalizedQuery));
final selectedPubkeys = selectedUsers.value
.map((user) => user.pubkey.toLowerCase())
.toSet();
final availableResults =
directoryAsync.asData?.value.where((user) {
final normalizedPubkey = user.pubkey.toLowerCase();
if (selectedPubkeys.contains(normalizedPubkey) ||
normalizedPubkey == currentPubkey?.toLowerCase()) {
return false;
}
if (normalizedQuery.isEmpty) {
return true;
}
return user.label.toLowerCase().contains(normalizedQuery) ||
user.secondaryLabel.toLowerCase().contains(normalizedQuery) ||
normalizedPubkey.contains(normalizedQuery);
}).toList() ??
const <DirectoryUser>[];
final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty;
Future<void> submit() async {
if (selectedUsers.value.isEmpty || isSubmitting.value) {
return;
}
if (previewDirectoryActive) {
submitError.value = context.l10n.previewDmUnavailable;
return;
}
isSubmitting.value = true;
submitError.value = null;
try {
final channel = await ref
.read(channelActionsProvider)
.openDm(
pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(),
);
if (context.mounted) {
Navigator.of(context).pop(channel);
}
} catch (error) {
submitError.value = error.toString();
} finally {
isSubmitting.value = false;
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.newMessage,
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
const SizedBox(height: Grid.xs),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: isSubmitting.value ? null : queryFocusNode.requestFocus,
child: SizedBox(
width: double.infinity,
child: ConstrainedBox(
key: const Key('new-dm-recipient-field'),
constraints: const BoxConstraints(minHeight: Grid.xl),
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(
color: context.colors.outlineVariant,
),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xxs,
),
child: LayoutBuilder(
builder: (context, constraints) {
final hasSelectedUsers =
selectedUsers.value.isNotEmpty;
final searchFieldWidth = hasSelectedUsers
? 96.0
: (constraints.maxWidth - 32).clamp(
96.0,
constraints.maxWidth,
);
return Wrap(
key: const Key('new-dm-recipient-wrap'),
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
child: Text(
context.l10n.toLabel,
style: context.textTheme.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
),
),
for (final user in selectedUsers.value)
_SelectedDmRecipientChip(
user: user,
enabled: !isSubmitting.value,
onDeleted: () {
selectedUsers.value = [
for (final candidate
in selectedUsers.value)
if (candidate.pubkey != user.pubkey)
candidate,
];
queryFocusNode.requestFocus();
},
),
SizedBox(
width: searchFieldWidth,
child: TextField(
key: const Key('new-dm-search'),
controller: queryController,
focusNode: queryFocusNode,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
enabled: !isSubmitting.value,
onChanged: (value) => query.value = value,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
decoration: InputDecoration(
hintText: hasSelectedUsers
? null
: context.l10n.searchPerson,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(
vertical: Grid.half,
),
suffixIcon: isSubmitting.value
? Padding(
padding: EdgeInsets.all(
Grid.half,
),
child: SizedBox.square(
dimension: 16,
child: BuzzLoadingIndicator(
size: 16,
semanticLabel:
context
.l10n
.creatingConversation,
),
),
)
: null,
suffixIconConstraints:
const BoxConstraints(
minHeight: 32,
minWidth: 32,
),
),
textInputAction: TextInputAction.done,
),
),
],
);
},
),
),
),
),
),
),
const SizedBox(height: Grid.xs),
Builder(
key: const Key('new-dm-results'),
builder: (context) {
if (selectedUsers.value.length >= 8) {
return SizedBox(
height: 96,
child: Center(
child: Text(
context.l10n.dmPeopleLimit,
),
),
);
}
if (directoryAsync.isLoading &&
directoryAsync.asData == null ||
isSearchTransitionPending) {
return SizedBox(
height: 280,
child: Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: context.l10n.loadingPeople,
),
),
);
}
if (directoryAsync.hasError) {
return SizedBox(
height: 280,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.peopleLoadFailed),
const SizedBox(height: Grid.half),
TextButton(
onPressed: () {
if (normalizedQuery.isEmpty) {
ref.invalidate(relayDirectoryUsersProvider);
} else {
ref.invalidate(
relayDirectorySearchProvider(
normalizedQuery,
),
);
}
},
child: Text(context.l10n.retry),
),
],
),
),
);
}
if (availableResults.isEmpty) {
return SizedBox(
height: 280,
child: Center(
child: Text(
normalizedQuery.isEmpty
? context.l10n.noPeopleAvailable
: context.l10n.noMatchingUsers,
),
),
);
}
return ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: availableResults.length,
separatorBuilder: (_, _) =>
const Divider(height: 1, indent: 56),
itemBuilder: (context, index) {
final user = availableResults[index];
return ListTile(
key: Key('new-dm-person-${user.pubkey}'),
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
),
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
user.initial,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
title: Text(
user.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
user.secondaryLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(
LucideIcons.plus,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: isSubmitting.value
? null
: () {
selectedUsers.value = [
...selectedUsers.value,
user,
];
queryController.clear();
query.value = '';
debouncedQuery.value = '';
submitError.value = null;
queryFocusNode.requestFocus();
},
);
},
);
},
),
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
],
),
),
),
);
}
}
class _SelectedDmRecipientChip extends StatelessWidget {
final DirectoryUser user;
final bool enabled;
final VoidCallback onDeleted;
const _SelectedDmRecipientChip({
required this.user,
required this.enabled,
required this.onDeleted,
});
@override
Widget build(BuildContext context) {
final foreground = context.colors.onSurfaceVariant;
return ConstrainedBox(
key: Key('new-dm-selected-${user.pubkey}'),
constraints: const BoxConstraints(maxWidth: 224),
child: SizedBox(
height: 40,
child: Material(
color: context.colors.surfaceContainerHighest,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: Semantics(
button: true,
enabled: enabled,
excludeSemantics: true,
label: context.l10n.removePerson(user.label),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: enabled ? onDeleted : null,
child: Padding(
padding: const EdgeInsets.only(
left: Grid.half,
right: Grid.twelve,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AvatarImage(
imageUrl: user.avatarUrl,
radius: 16,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
user.initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
user.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,137 @@
part of '../channels_page.dart';
class _ChannelsSkeleton extends StatelessWidget {
final List<Channel>? channels;
final double topInset;
final SessionStatus status;
const _ChannelsSkeleton({
required this.channels,
required this.topInset,
required this.status,
});
@override
Widget build(BuildContext context) {
final visibleChannels =
channels
?.where((channel) => channel.isMember && !channel.isArchived)
.take(8)
.toList() ??
const <Channel>[];
final widths = visibleChannels
.map(
(channel) => min(
240,
max(
88,
24 +
(resolveDmChannelDisplayLabel(
channel,
currentPubkey: null,
).length *
8),
),
).toDouble(),
)
.toList();
const fallbackWidths = <double>[136, 184, 112, 160, 208, 128];
while (widths.length < 6) {
widths.add(fallbackWidths[widths.length % fallbackWidths.length]);
}
final splitAt = min(4, widths.length);
final firstSection = widths.take(splitAt).toList();
final secondSection = widths.skip(splitAt).toList();
final semanticsLabel = switch (status) {
SessionStatus.connecting => context.l10n.connecting,
SessionStatus.reconnecting => context.l10n.reconnecting,
SessionStatus.connected || SessionStatus.disconnected =>
context.l10n.loading,
};
return Semantics(
key: const Key('channels-connection-skeleton'),
liveRegion: true,
label: semanticsLabel,
child: ExcludeSemantics(
child: ListView(
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
Grid.gutter,
topInset + Grid.twelve,
Grid.gutter,
80,
),
children: [
_ChannelSkeletonSection(widths: firstSection),
const SizedBox(height: Grid.xs),
_ChannelSkeletonSection(widths: secondSection),
],
),
),
);
}
}
class _ChannelSkeletonSection extends StatelessWidget {
final List<double> widths;
const _ChannelSkeletonSection({required this.widths});
@override
Widget build(BuildContext context) {
return Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: Grid.half),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: SkeletonBar(width: 18, height: 18),
),
),
SizedBox(width: Grid.xxs),
SkeletonBar(
key: Key('channels-skeleton-section-label'),
width: 88,
height: 16,
),
],
),
),
for (var index = 0; index < widths.length; index++)
Padding(
padding: const EdgeInsets.symmetric(
vertical: _kChannelRowVerticalPadding,
),
child: Row(
children: [
SizedBox(
width: _kChannelLeadingWidth,
child: Align(
alignment: Alignment.centerLeft,
child: SkeletonBar(
width: _kChannelIconSize,
height: _kChannelIconSize,
borderRadius: BorderRadius.circular(
index.isEven ? Radii.xs : Radii.full,
),
),
),
),
const SizedBox(width: _kChannelLabelGap),
SkeletonBar(
key: Key('channels-skeleton-row-label-$index'),
width: widths[index],
height: 16,
),
],
),
),
],
);
}
}
@@ -0,0 +1,845 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme_provider.dart';
import '../../shared/utils/string_utils.dart';
import 'channel.dart';
import 'channel_management_provider.dart' show channelDetailsProvider;
import 'channel_mutes/channel_mutes_provider.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'thread_follows/thread_follows_provider.dart';
import 'unread_badge/is_high_priority_event.dart';
import 'unread_badge/observed_unread_event.dart';
import 'unread_badge/should_notify_for_event.dart';
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
const _unreadCatchUpLimit = 1000;
const _participatedRootIdsPrefix = 'buzz-thread-participation.v1';
const _authoredRootIdsPrefix = 'buzz-thread-authored.v1';
/// Loads the user's channel list from the relay over WebSocket.
///
/// Two-step query:
/// 1. Fetch kind:39002 membership events tagged `#p:<my-pubkey>` to find
/// the channel ids I'm a member of.
/// 2. Fetch the corresponding kind:39000 channel metadata events.
///
/// Live updates are layered on top via per-channel subscriptions on the
/// `#h` tag for any of the visible channel event kinds — incoming events
/// bump `lastMessageAt` for that channel.
class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
static const _backstopInterval = Duration(seconds: 60);
final Map<String, void Function()> _unsubscribersByChannel = {};
Future<void> _liveSubscriptionQueue = Future.value();
List<Channel> _desiredLiveChannels = const [];
Set<String> _desiredLiveChannelIds = const {};
int _subscriptionVersion = 0;
String? _subscriptionRelayBaseUrl;
Timer? _backstopTimer;
final Map<String, int> _latestObservedByChannel = {};
final Map<String, Map<String, ObservedUnreadEvent>>
_observedUnreadEventsByChannel = {};
Set<String> _participatedRootIds = {};
Set<String> _authoredRootIds = {};
String? _threadInterestPubkey;
bool _hasLoaded = false;
Map<String, int> get latestObservedByChannel =>
Map.unmodifiable(_latestObservedByChannel);
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel =>
Map<String, Map<String, ObservedUnreadEvent>>.unmodifiable({
for (final entry in _observedUnreadEventsByChannel.entries)
entry.key: Map<String, ObservedUnreadEvent>.unmodifiable(entry.value),
});
@override
Future<List<Channel>> build() async {
ref.watch(relayConfigProvider);
final connected = Completer<void>();
final sessionState = ref.read(relaySessionProvider);
final waitingForInitialConnection =
sessionState.status != SessionStatus.connected;
ref.listen(relaySessionProvider, (previous, next) {
if (next.status != SessionStatus.connected) return;
if (waitingForInitialConnection &&
!_hasLoaded &&
!connected.isCompleted) {
connected.complete();
} else if (previous?.status != SessionStatus.connected) {
unawaited(_backstopRefresh());
}
});
// Re-fetch when the app returns to foreground so channels created on
// another device while mobile was backgrounded appear immediately.
ref.listen(appLifecycleProvider, (prev, next) {
if (next == AppLifecycleState.resumed) {
refresh();
}
});
ref.onDispose(() {
_clearLiveSubscriptions();
_latestObservedByChannel.clear();
_observedUnreadEventsByChannel.clear();
_backstopTimer?.cancel();
_backstopTimer = null;
});
if (sessionState.status != SessionStatus.connected) {
// Keep the prior community's cache visible until the new relay connects.
if (_hasLoaded) return state.value ?? const [];
await connected.future;
}
return _fetch(subscribeLive: true);
}
Future<List<Channel>> _fetch({
bool subscribeLive = false,
bool fetchLastMessage = true,
}) async {
final channels = await _fetchChannels(
subscribeLive: subscribeLive,
fetchLastMessage: fetchLastMessage,
);
_hasLoaded = true;
return channels;
}
Future<List<Channel>> _fetchChannels({
bool subscribeLive = false,
bool fetchLastMessage = true,
}) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) throw StateError('No signing identity available');
_loadThreadInterestStores(myPk);
final session = ref.read(relaySessionProvider.notifier);
// Step 1: find the channels I'm a member of via kind:39002.
final memberships = <NostrEvent>[];
{
int? until;
const pageSize = 500;
while (true) {
final page = await session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {
'#p': [myPk],
},
limit: pageSize,
until: until,
),
);
memberships.addAll(page);
if (page.length < pageSize) break;
until = page.map((e) => e.createdAt).reduce(min) - 1;
}
}
final channelIds = memberships
.map((e) => e.getTagValue('d'))
.whereType<String>()
.toSet()
.toList();
if (channelIds.isEmpty) {
if (subscribeLive) await _subscribeLive(const []);
return const [];
}
// Step 2: pull channel metadata in one batched filter.
final metas = await session.fetchHistory(
NostrFilters.channelMetadata(channelIds),
);
// Dedupe by `d` tag (channel id) — kind:39000 is parameterized-replaceable,
// so logically there's exactly one current event per id, but stale revisions
// from before the relay's d_tag backfill can linger. Keep the highest
// `created_at` per id so the latest channel_type / name wins.
final latestMetaPerId = <String, NostrEvent>{};
for (final event in metas) {
if (event.kind != 39000) continue;
final id = event.getTagValue('d');
if (id == null) continue;
final existing = latestMetaPerId[id];
if (existing == null || event.createdAt > existing.createdAt) {
latestMetaPerId[id] = event;
}
}
final dedupedMetas = latestMetaPerId.values;
// Resolve DM participant display names. Relay stores DM channels with
// literal name="DM"; pure-Nostr architecture pushes name resolution to
// the client, so collect non-self participant pubkeys across all DM
// metas and batch-fetch their kind:0 profiles in one round-trip.
final dmParticipants = <String>{};
final myPkLower = myPk.toLowerCase();
for (final event in dedupedMetas) {
final data = ChannelData.fromEvent(event);
if (data.channelType != 'dm') continue;
for (final pk in data.participantPubkeys) {
final lower = pk.toLowerCase();
if (lower != myPkLower) dmParticipants.add(lower);
}
}
final displayNames = <String, String>{};
if (dmParticipants.isNotEmpty) {
final profileEvents = await session.fetchHistory(
NostrFilters.profilesBatch(dmParticipants.toList()),
);
for (final event in profileEvents) {
if (event.kind != 0) continue;
final profile = ProfileData.fromEvent(event);
final label = profile.displayName?.trim().isNotEmpty == true
? profile.displayName!.trim()
: profile.nip05?.trim().isNotEmpty == true
? profile.nip05!.trim()
: shortPubkey(profile.pubkey);
displayNames[profile.pubkey.toLowerCase()] = label;
}
}
final hiddenDmIds = await _fetchHiddenDmIds(session, myPk);
final channels = <Channel>[];
for (final event in dedupedMetas) {
final channel = _channelFromMeta(
event,
isMember: true,
displayNames: displayNames,
);
if (channel.isDm && hiddenDmIds.contains(channel.id)) continue;
// Ephemeral (TTL) channels are surfaced in the list with an
// `_EphemeralBadge` rendered in `channels_page.dart` — they shouldn't be
// hidden. Desktop shows them too. Previously dropped here unconditionally,
// which made TTL channels invisible on iOS even when the user was a member.
channels.add(channel);
}
// Batch-fetch member counts via kind:39002 membership events.
final memberEvents = await session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {'#d': channelIds},
limit: channelIds.length,
),
);
final memberCounts = <String, int>{};
for (final event in memberEvents) {
final chId = event.getTagValue('d');
if (chId == null) continue;
final pTags = <String>{};
for (final tag in event.tags) {
if (tag.isNotEmpty && tag[0] == 'p' && tag.length > 1) {
pTags.add(tag[1].toLowerCase());
}
}
memberCounts[chId] = pTags.length;
}
for (var i = 0; i < channels.length; i++) {
final count = memberCounts[channels[i].id];
if (count != null) {
channels[i] = channels[i].copyWith(memberCount: count);
}
}
// Step 3: fetch the most recent message per channel to populate lastMessageAt.
// kind:39000 metadata doesn't carry message timestamps, so channels load with
// lastMessageAt: null. Without this, unread detection and badge computation
// see every channel as having no messages. Skipped on backstop refreshes since
// live subscriptions keep lastMessageAt current after the initial load.
if (fetchLastMessage) {
final lastMessageResults = await Future.wait(
channels.map((channel) async {
if (!channel.isMember || channel.isArchived) return null;
try {
if (channel.isDm) {
final events = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
limit: 1,
),
);
if (events.isEmpty) return null;
return MapEntry(channel.id, events.first.createdAt);
}
final events = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
limit: 20,
),
);
for (final event in events) {
if (shouldNotifyForEvent(
event,
myPk,
mutedChannelIds: _mutedChannelIds(),
channelId: channel.id,
)) {
return MapEntry(channel.id, event.createdAt);
}
}
return null;
} catch (_) {
return null;
}
}),
);
final lastMessageMap = <String, int>{};
for (final entry
in lastMessageResults.whereType<MapEntry<String, int>>()) {
lastMessageMap[entry.key] = entry.value;
}
for (var i = 0; i < channels.length; i++) {
final ts = lastMessageMap[channels[i].id];
if (ts != null) {
channels[i] = channels[i].copyWith(
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
ts * 1000,
isUtc: true,
),
);
}
}
}
channels.sort((left, right) {
final typeOrder =
(_channelTypeOrder[left.channelType] ?? 99) -
(_channelTypeOrder[right.channelType] ?? 99);
if (typeOrder != 0) return typeOrder;
// Case-insensitive to match desktop's `localeCompare` ordering.
return left.name.toLowerCase().compareTo(right.name.toLowerCase());
});
// Invalidate `channelDetailsProvider` entries whose archived state flipped
// since the last fetch. Required because `channelDetailsProvider` is a
// separate Riverpod cache and `Channel.mergeDetails(details)` overwrites
// archivedAt from the cached details — so an active-then-archived channel
// (e.g. TTL auto-archive by the relay reaper) could keep showing compose
// and manage actions in the detail view until the cache expired naturally.
//
// Scoped narrowly to the archived flip — broader metadata staleness
// (renames, topic changes, etc.) is a separate, pre-existing concern that
// already affects this provider for other reasons.
final prevById = <String, Channel>{
for (final c in state.value ?? const <Channel>[]) c.id: c,
};
for (final channel in channels) {
final prev = prevById[channel.id];
if (prev != null && prev.isArchived != channel.isArchived) {
ref.invalidate(channelDetailsProvider(channel.id));
}
}
if (subscribeLive) {
await _subscribeLive(channels);
}
return channels;
}
Future<Set<String>> _fetchHiddenDmIds(
RelaySessionNotifier session,
String myPk,
) async {
try {
final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk));
if (events.isEmpty) return const {};
NostrEvent latest = events.first;
for (final event in events.skip(1)) {
if (event.createdAt > latest.createdAt) {
latest = event;
}
}
return {
for (final tag in latest.tags)
if (tag.length >= 2 && tag[0] == 'h') tag[1],
};
} catch (_) {
return const {};
}
}
/// Build a [Channel] from a kind:39000 metadata event.
///
/// [displayNames] maps lowercase participant pubkey → resolved label and is
/// used to populate [Channel.participants] for DMs so [Channel.displayLabel]
/// can render real names instead of the relay-canonical "DM" name.
Channel _channelFromMeta(
NostrEvent event, {
required bool isMember,
Map<String, String> displayNames = const {},
}) {
final data = ChannelData.fromEvent(event);
final participants = data.channelType == 'dm'
? [
for (final pk in data.participantPubkeys)
displayNames[pk.toLowerCase()] ?? shortPubkey(pk),
]
: const <String>[];
return Channel(
id: data.id,
name: data.name,
channelType: data.channelType,
visibility: data.visibility,
description: data.description,
topic: data.topic,
createdBy: event.pubkey,
createdAt: DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
),
memberCount: 0,
lastMessageAt: null,
// `archivedAt` doubles as both the archived-state flag and the timestamp.
// The kind:39000 metadata only carries `["archived", "true"]`, not the
// moment of archival, so we stamp the event's `createdAt` — that's when
// the relay republished the metadata, which is the closest signal we have.
archivedAt: data.isArchived
? DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
)
: null,
participants: participants,
participantPubkeys: data.participantPubkeys,
isMember: isMember,
ttlSeconds: data.ttlSeconds,
ttlDeadline: data.ttlDeadline,
);
}
/// Subscribe per-channel to live events (requires `#h` tag for relay
/// channel-scoped fan-out). Also starts a 60s WS backstop poll to detect
/// newly created channels we don't yet have subscriptions for.
Future<void> _subscribeLive(List<Channel> channels) {
final channelIds = {
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel.id,
};
final relayBaseUrl = ref.read(relayConfigProvider).baseUrl;
_desiredLiveChannels = channels;
_desiredLiveChannelIds = channelIds;
final subscriptionVersion = ++_subscriptionVersion;
final sync = _liveSubscriptionQueue.then(
(_) =>
_syncLiveSubscriptions(relayBaseUrl, subscriptionVersion, channels),
);
_liveSubscriptionQueue = sync.catchError((Object error, StackTrace stack) {
debugPrint(
'[ChannelsNotifier] live subscription sync failed: $error\n$stack',
);
});
return sync;
}
Future<void> _syncLiveSubscriptions(
String relayBaseUrl,
int subscriptionVersion,
List<Channel> channels,
) async {
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
return;
}
if (subscriptionVersion != _subscriptionVersion) {
await _syncLiveSubscriptions(
ref.read(relayConfigProvider).baseUrl,
_subscriptionVersion,
_desiredLiveChannels,
);
return;
}
if (_subscriptionRelayBaseUrl != relayBaseUrl) {
for (final unsubscribe in _unsubscribersByChannel.values) {
unsubscribe();
}
_unsubscribersByChannel.clear();
_subscriptionRelayBaseUrl = relayBaseUrl;
}
if (ref.read(relayConfigProvider).baseUrl != relayBaseUrl) {
return;
}
final session = ref.read(relaySessionProvider.notifier);
final channelIds = _desiredLiveChannelIds;
for (final entry in _unsubscribersByChannel.entries.toList()) {
if (channelIds.contains(entry.key)) continue;
_unsubscribersByChannel.remove(entry.key);
entry.value();
}
for (final channelId in channelIds) {
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
return;
}
if (_unsubscribersByChannel.containsKey(channelId)) continue;
try {
final unsubscribe = await session.subscribe(
NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [channelId],
},
limit: 0,
),
_handleLiveEvent,
);
if (ref.read(relaySessionProvider).status != SessionStatus.connected ||
!_desiredLiveChannelIds.contains(channelId) ||
ref.read(relayConfigProvider).baseUrl != relayBaseUrl ||
_subscriptionRelayBaseUrl != relayBaseUrl) {
unsubscribe();
return;
}
final replaced = _unsubscribersByChannel[channelId];
if (replaced != null) {
unsubscribe();
continue;
}
_unsubscribersByChannel[channelId] = unsubscribe;
} catch (error) {
debugPrint(
'[ChannelsNotifier] live subscription failed for $channelId: $error',
);
}
}
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
return;
}
if (subscriptionVersion != _subscriptionVersion) {
final desiredChannelIds = _desiredLiveChannelIds;
for (final entry in _unsubscribersByChannel.entries.toList()) {
if (desiredChannelIds.contains(entry.key)) continue;
_unsubscribersByChannel.remove(entry.key);
entry.value();
}
return;
}
unawaited(_catchUpUnreadEvents(channels));
_backstopTimer?.cancel();
_backstopTimer = Timer.periodic(
_backstopInterval,
(_) => _backstopRefresh(),
);
}
Future<void> _catchUpUnreadEvents(List<Channel> channels) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) return;
final session = ref.read(relaySessionProvider.notifier);
final mutedChannelIds = _mutedChannelIds();
final ReadStateState readState;
try {
readState = ref.read(readStateProvider);
} catch (error) {
debugPrint('[ChannelsNotifier] unread catch-up skipped: $error');
return;
}
final futures = <Future<void>>[];
for (final channel in channels) {
if (!channel.isMember || channel.isArchived) continue;
final readAt = readState.effectiveTimestamp(channel.id);
futures.add(
_catchUpUnreadEventsForChannel(
session,
channel,
myPk,
readAt,
mutedChannelIds,
),
);
}
const batchSize = 5;
for (var i = 0; i < futures.length; i += batchSize) {
await Future.wait(futures.sublist(i, min(i + batchSize, futures.length)));
}
state = state.whenData((channels) => List<Channel>.of(channels));
}
Future<void> _catchUpUnreadEventsForChannel(
RelaySessionNotifier session,
Channel channel,
String myPk,
int? readAt,
Set<String> mutedChannelIds,
) async {
try {
final events = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
since: readAt == null ? 0 : readAt + 1,
limit: _unreadCatchUpLimit,
),
);
for (final event in events) {
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) {
_recordSelfThreadInterest(event, myPk);
}
}
for (final event in events) {
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) continue;
if (readAt != null && event.createdAt <= readAt) continue;
if (!shouldNotifyForEvent(
event,
myPk,
participatedRootIds: _participatedRootIds,
followedRootIds: _followedRootIds(),
authoredRootIds: _authoredRootIds,
mutedChannelIds: mutedChannelIds,
channelId: channel.id,
)) {
continue;
}
_recordUnreadEvent(channel, event, myPk);
}
} catch (error) {
debugPrint(
'[ChannelsNotifier] unread catch-up failed for ${channel.id}: $error',
);
}
}
void _handleLiveEvent(NostrEvent event) {
final channelId = event.channelId;
if (channelId == null) return;
final myPk = ref.read(myPubkeyProvider);
final mutedChannelIds = _mutedChannelIds();
state = state.whenData((channels) {
final idx = channels.indexWhere((c) => c.id == channelId);
if (idx == -1) {
refresh();
return channels;
}
final updated = List<Channel>.of(channels);
final channel = updated[idx];
if (myPk != null && event.pubkey.toLowerCase() == myPk.toLowerCase()) {
_recordSelfThreadInterest(event, myPk);
}
if (myPk != null &&
shouldNotifyForEvent(
event,
myPk,
participatedRootIds: _participatedRootIds,
followedRootIds: _followedRootIds(),
authoredRootIds: _authoredRootIds,
mutedChannelIds: mutedChannelIds,
channelId: channel.id,
)) {
_recordUnreadEvent(channel, event, myPk);
final eventTime = DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
);
if (channel.lastMessageAt == null ||
eventTime.isAfter(channel.lastMessageAt!)) {
updated[idx] = channel.copyWith(lastMessageAt: eventTime);
}
}
return updated;
});
}
Set<String> _mutedChannelIds() => {
for (final entry in ref.read(channelMutesProvider).store.channels.entries)
if (entry.value.muted) entry.key,
};
Set<String> _followedRootIds() =>
ref.read(threadFollowsProvider).followedRootIds;
void _loadThreadInterestStores(String pubkey) {
final normalizedPubkey = pubkey.toLowerCase();
if (_threadInterestPubkey == normalizedPubkey) return;
_threadInterestPubkey = normalizedPubkey;
try {
final prefs = ref.read(savedPrefsProvider);
_participatedRootIds = _readRootIdSet(
prefs.getString('$_participatedRootIdsPrefix:$normalizedPubkey'),
);
_authoredRootIds = _readRootIdSet(
prefs.getString('$_authoredRootIdsPrefix:$normalizedPubkey'),
);
} catch (_) {
_participatedRootIds = {};
_authoredRootIds = {};
}
}
void _recordSelfThreadInterest(NostrEvent event, String pubkey) {
final ref = event.threadReference;
final target = ref.rootId != null ? _participatedRootIds : _authoredRootIds;
final id = ref.rootId ?? event.id;
if (!target.add(id)) return;
_writeThreadInterestStores(pubkey);
}
void _writeThreadInterestStores(String pubkey) {
final normalizedPubkey = pubkey.toLowerCase();
try {
final prefs = ref.read(savedPrefsProvider);
prefs.setString(
'$_participatedRootIdsPrefix:$normalizedPubkey',
_encodeRootIdSet(_participatedRootIds),
);
prefs.setString(
'$_authoredRootIdsPrefix:$normalizedPubkey',
_encodeRootIdSet(_authoredRootIds),
);
} catch (_) {
// Ignore storage failures; in-memory interest still works this session.
}
}
void _recordUnreadEvent(Channel channel, NostrEvent event, String myPk) {
final isThreadedReply =
event.threadReference.parentId != null && !_isBroadcastReply(event);
final isHighPriority =
channel.isDm || isHighPriorityEvent(event.tags, myPk);
recordObservedUnreadEvent(
_observedUnreadEventsByChannel,
channel.id,
makeObservedUnreadEvent(
id: event.id,
createdAt: event.createdAt,
rootId: _observedUnreadRootId(event),
highPriority: isHighPriority,
channelType: channel.channelType,
isThreadedReply: isThreadedReply,
),
_unreadCatchUpLimit,
);
final current = _latestObservedByChannel[channel.id] ?? 0;
if (event.createdAt > current) {
_latestObservedByChannel[channel.id] = event.createdAt;
}
}
void clearObservedUnreadForChannel(String channelId) {
_latestObservedByChannel.remove(channelId);
_observedUnreadEventsByChannel.remove(channelId);
state = state.whenData((channels) => List<Channel>.of(channels));
}
void clearObservedUnreadCoveredByRead(String channelId, int readAt) {
final latest = _latestObservedByChannel[channelId];
if (latest != null && latest <= readAt) {
clearObservedUnreadForChannel(channelId);
}
}
/// Backstop refresh that preserves existing state on transient failure.
Future<void> _backstopRefresh() async {
try {
final sessionState = ref.read(relaySessionProvider);
final prevChannels = state.value ?? const [];
final prevLastMessage = {
for (final c in prevChannels)
if (c.lastMessageAt != null) c.id: c.lastMessageAt,
};
final channels = await _fetch(
subscribeLive: sessionState.status == SessionStatus.connected,
fetchLastMessage: false,
);
for (var i = 0; i < channels.length; i++) {
final prev = prevLastMessage[channels[i].id];
if (channels[i].lastMessageAt == null && prev != null) {
channels[i] = channels[i].copyWith(lastMessageAt: prev);
}
}
state = AsyncData(channels);
} catch (error) {
debugPrint('[ChannelsNotifier] backstop refresh failed: $error');
}
}
Future<void> refresh() async {
final sessionState = ref.read(relaySessionProvider);
// Don't attempt to fetch when the session isn't connected — fetchHistory
// would send REQs over an unauthenticated socket that either time out
// (returning empty results) or get cancelled on disconnect, replacing the
// cached channel list with [] or an error. Wait for `build()` to re-run
// when the session transitions to connected.
if (sessionState.status != SessionStatus.connected) return;
state = await AsyncValue.guard(() => _fetch(subscribeLive: true));
}
void _clearLiveSubscriptions() {
_subscriptionVersion++;
_desiredLiveChannels = const [];
_desiredLiveChannelIds = const {};
for (final unsubscribe in _unsubscribersByChannel.values) {
unsubscribe();
}
_unsubscribersByChannel.clear();
_subscriptionRelayBaseUrl = null;
_backstopTimer?.cancel();
_backstopTimer = null;
}
}
final channelsProvider = AsyncNotifierProvider<ChannelsNotifier, List<Channel>>(
ChannelsNotifier.new,
);
String? _observedUnreadRootId(NostrEvent event) =>
_isBroadcastReply(event) ? null : event.threadReference.rootId;
bool _isBroadcastReply(NostrEvent event) => event.tags.any(
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
);
Set<String> _readRootIdSet(String? raw) {
if (raw == null || raw.isEmpty) return {};
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return {};
return {
for (final value in decoded)
if (value is String) value,
};
} catch (_) {
return {};
}
}
String _encodeRootIdSet(Set<String> values) => jsonEncode(values.toList());
@@ -0,0 +1,60 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' show FlutterView;
import 'package:camera/camera.dart' as camera;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/mobile_tab_footer_backdrop.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../activity/compose_drafts_provider.dart';
import 'camera_capture_cleanup.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
import 'channels_provider.dart';
import 'emoji_picker.dart';
import 'mentions/mention_candidates.dart';
import 'mentions/mention_candidates_provider.dart';
import 'mentions/mention_ranking.dart';
import 'photo_library.dart';
part 'compose_bar/helpers.dart';
part 'compose_bar/agent_mention_labels.dart';
part 'compose_bar/markdown_editing_controller.dart';
part 'compose_bar/draft_lifecycle.dart';
part 'compose_bar/suggestions.dart';
part 'compose_bar/formatting_toolbar.dart';
part 'compose_bar/attachments.dart';
part 'compose_bar/upload_progress_pill.dart';
part 'compose_bar/photo_gallery_picker.dart';
part 'compose_bar/ios_photo_picker.dart';
part 'compose_bar/ios_attachment_popover.dart';
part 'compose_bar/camera_preview.dart';
part 'compose_bar/send_button.dart';
part 'compose_bar/layout.dart';
part 'compose_bar/dock.dart';
part 'compose_bar/compose_bar_widget.dart';
@@ -0,0 +1,10 @@
part of '../compose_bar.dart';
Set<String> _agentMentionLabels({
required Iterable<MentionCandidate> candidates,
}) {
return <String>{
for (final candidate in candidates)
if (candidate.isAgent) candidate.label,
};
}
@@ -0,0 +1,738 @@
part of '../compose_bar.dart';
enum _AttachmentSurface { closed, menu, camera, photos }
const _attachmentMenuWidth = 216.0;
const _attachmentMenuPadding = Grid.xs;
const _attachmentMenuItemHeight = 52.0;
const _attachmentMenuItemSpacing = Grid.xxs;
const _attachmentMenuIconSize = 24.0;
const _attachmentMenuIconSlotWidth = 28.0;
const _attachmentExpandedHeight = 372.0;
@immutable
class _AttachmentMenuLayout {
final double itemHeight;
final double contentHeight;
final double height;
const _AttachmentMenuLayout({
required this.itemHeight,
required this.contentHeight,
required this.height,
});
factory _AttachmentMenuLayout.from(BuildContext context) {
final textPainter = TextPainter(
text: TextSpan(
text: context.l10n.cameraMenu,
style: context.textTheme.titleMedium,
),
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
maxLines: 1,
)..layout();
final itemHeight = math.max(
_attachmentMenuItemHeight,
textPainter.height + (Grid.xxs * 2),
);
textPainter.dispose();
final contentHeight =
(_attachmentMenuPadding * 2) +
(itemHeight * 4) +
(_attachmentMenuItemSpacing * 3);
return _AttachmentMenuLayout(
itemHeight: itemHeight,
contentHeight: contentHeight,
height: math.min(contentHeight, _attachmentExpandedHeight),
);
}
bool get isScrollable => contentHeight > height;
}
class _AttachmentSurfacePanel extends HookWidget {
final _AttachmentSurface surface;
final Widget suggestionPanel;
final VoidCallback onBack;
final VoidCallback onCamera;
final VoidCallback onPhotos;
final VoidCallback onVideo;
final VoidCallback onFiles;
final Future<void> Function(XFile image) onCapture;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
const _AttachmentSurfacePanel({
super.key,
required this.surface,
required this.suggestionPanel,
required this.onBack,
required this.onCamera,
required this.onPhotos,
required this.onVideo,
required this.onFiles,
required this.onCapture,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.onChooseAllPhotos,
});
@override
Widget build(BuildContext context) {
if (surface == _AttachmentSurface.closed) return suggestionPanel;
final menuLayout = _AttachmentMenuLayout.from(context);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final isExpanded =
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos;
final morphController = useAnimationController(
initialValue: isExpanded ? 1 : 0,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 320),
reverseDuration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 250),
);
final rawProgress = useAnimation(morphController);
final renderedExpandedSurface = useState<_AttachmentSurface?>(
isExpanded ? surface : null,
);
final latestSurface = useRef(surface);
latestSurface.value = surface;
useEffect(() {
void disposeCollapsedContent(AnimationStatus status) {
if (status == AnimationStatus.dismissed &&
latestSurface.value == _AttachmentSurface.menu) {
renderedExpandedSurface.value = null;
}
}
morphController.addStatusListener(disposeCollapsedContent);
return () =>
morphController.removeStatusListener(disposeCollapsedContent);
}, [morphController]);
useEffect(() {
if (isExpanded) {
renderedExpandedSurface.value = surface;
morphController.forward();
} else {
morphController.reverse();
}
return null;
}, [isExpanded, morphController, surface]);
final visibleExpandedSurface =
renderedExpandedSurface.value ?? (isExpanded ? surface : null);
final cameraInitializationReady =
reducedMotion ||
(surface == _AttachmentSurface.camera && rawProgress >= 1);
final expandedContent = switch (visibleExpandedSurface) {
_AttachmentSurface.camera => KeyedSubtree(
key: const ValueKey('camera-preview'),
child: KeyedSubtree(
key: ValueKey(
cameraInitializationReady
? 'camera-initialization-ready'
: 'camera-initialization-deferred',
),
child: _InlineCameraPreview(
initializeCamera: cameraInitializationReady,
onClose: onBack,
onCapture: onCapture,
),
),
),
_AttachmentSurface.photos => KeyedSubtree(
key: const ValueKey('photo-gallery'),
child: _PhotoGalleryPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
onChooseAllPhotos: onChooseAllPhotos,
),
),
_AttachmentSurface.closed ||
_AttachmentSurface.menu ||
null => const SizedBox.shrink(),
};
double interval(double value, double begin, double end) {
return ((value - begin) / (end - begin)).clamp(0.0, 1.0);
}
final menuOpacity = 1 - interval(rawProgress, 0.12, 0.38);
final expandedOpacity = interval(rawProgress, 0.28, 0.65);
final sizeProgress = morphController.status == AnimationStatus.reverse
? const Cubic(0.22, 1, 0.36, 1).transform(rawProgress)
: Curves.easeInOutCubic.transform(rawProgress);
return LayoutBuilder(
builder: (context, constraints) {
final expandedWidth = constraints.maxWidth;
const expandedHeight = _attachmentExpandedHeight;
final width =
_attachmentMenuWidth +
((expandedWidth - _attachmentMenuWidth) * sizeProgress);
final height =
menuLayout.height +
((expandedHeight - menuLayout.height) * sizeProgress);
final baseColor = appPopoverColor(context);
final expandedColor =
visibleExpandedSurface == _AttachmentSurface.camera
? Colors.black
: baseColor;
return Align(
alignment: Alignment.topLeft,
heightFactor: 1,
child: SizedBox(
width: width,
height: height,
child: Material(
key: const ValueKey('attachment-surface-popover'),
type: MaterialType.card,
color: Color.lerp(baseColor, expandedColor, sizeProgress),
surfaceTintColor: Colors.transparent,
elevation: appPopoverElevation,
shadowColor: appPopoverShadowColor(context),
shape: appPopoverShape(context),
clipBehavior: Clip.antiAlias,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
left: 0,
top: 0,
width: _attachmentMenuWidth,
height: menuLayout.height,
child: IgnorePointer(
ignoring: surface != _AttachmentSurface.menu,
child: Opacity(
opacity: menuOpacity,
child: _AttachmentMenu(
layout: menuLayout,
onCamera: onCamera,
onPhotos: onPhotos,
onVideo: onVideo,
onFiles: onFiles,
),
),
),
),
Positioned(
left: 0,
top: 0,
width: expandedWidth,
height: expandedHeight,
child: IgnorePointer(
ignoring: !isExpanded,
child: Opacity(
opacity: expandedOpacity,
child: expandedContent,
),
),
),
],
),
),
),
);
},
);
}
}
@immutable
class _ComposeDraftPayload {
final String content;
final List<List<String>> mediaTags;
const _ComposeDraftPayload({required this.content, required this.mediaTags});
factory _ComposeDraftPayload.fromDraft({
required String text,
required List<BlobDescriptor> attachments,
required List<CustomEmoji> customEmoji,
}) {
var content = text;
final mediaTags = <List<String>>[];
for (final attachment in attachments) {
mediaTags.add(attachment.toImetaTag());
content += '\n${attachment.toMarkdownImage()}';
}
mediaTags.addAll(buildCustomEmojiTags(content, customEmoji));
return _ComposeDraftPayload(content: content, mediaTags: mediaTags);
}
}
enum _PendingAttachmentKind { image, video, file }
@immutable
class _PendingAttachment {
static var _nextId = 0;
final int id;
final XFile file;
final _PendingAttachmentKind kind;
final bool deleteAfterUse;
_PendingAttachment({
required this.file,
required this.kind,
this.deleteAfterUse = false,
}) : id = _nextId++;
}
Future<void> _deleteXFile(XFile file) async {
final path = file.path;
if (path.isEmpty) return;
try {
await File(path).delete();
} on FileSystemException {
// Temporary files may already have been removed by the operating system.
}
}
Future<void> _deleteOwnedAttachments(
Iterable<_PendingAttachment> attachments,
) async {
for (final attachment in attachments) {
if (attachment.deleteAfterUse) await _deleteXFile(attachment.file);
}
}
void _useOwnedAttachmentCleanup(
ValueNotifier<List<_PendingAttachment>> attachments,
) {
useEffect(
() =>
() => unawaited(_deleteOwnedAttachments(attachments.value)),
[attachments],
);
}
void _removePendingAttachment(
ValueNotifier<List<_PendingAttachment>> attachments,
ObjectRef<int> draftRevision,
int id,
) {
draftRevision.value += 1;
final removed = attachments.value.where((attachment) => attachment.id == id);
attachments.value = _withoutAttachment(attachments.value, id);
unawaited(_deleteOwnedAttachments(removed));
}
Future<void> _retainAndQueueImages(
BuildContext context,
List<XFile> images,
void Function(List<XFile>, {bool deleteAfterUse}) queueImages,
) async {
final retained = await retainTemporaryImages(images);
if (!context.mounted) {
for (final image in retained) {
await _deleteXFile(image);
}
return;
}
queueImages(retained, deleteAfterUse: true);
}
Future<BlobDescriptor> _uploadPendingAttachment(
MediaUploadService service,
_PendingAttachment attachment, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) => switch (attachment.kind) {
_PendingAttachmentKind.image => service.uploadImage(
attachment.file,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
_PendingAttachmentKind.video => service.uploadVideo(
attachment.file,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
_PendingAttachmentKind.file => service.uploadFile(
attachment.file,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
};
class _AttachmentTrigger extends StatelessWidget {
final _AttachmentSurface surface;
final bool formattingOpen;
final ValueChanged<BuildContext> onTap;
const _AttachmentTrigger({
required this.surface,
required this.formattingOpen,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 240);
return SizedBox.square(
dimension: 36,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
shape: BoxShape.circle,
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: IconButton(
tooltip: switch (surface) {
_AttachmentSurface.closed =>
formattingOpen
? context.l10n.closeFormatting
: context.l10n.addAttachment,
_AttachmentSurface.menu => context.l10n.closeAttachments,
_AttachmentSurface.camera ||
_AttachmentSurface.photos => context.l10n.backAttachmentOptions,
},
onPressed: () => _runComposerAction(() => onTap(context)),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: AnimatedRotation(
duration: duration,
curve: Curves.easeOutBack,
turns: surface == _AttachmentSurface.menu || formattingOpen
? 0.125
: 0,
child: AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1).animate(animation),
child: child,
),
),
child: Icon(
switch (surface) {
_AttachmentSurface.camera => LucideIcons.camera,
_AttachmentSurface.photos => LucideIcons.images,
_AttachmentSurface.closed ||
_AttachmentSurface.menu => LucideIcons.plus,
},
key: ValueKey('attachment-trigger-${surface.name}'),
size: 20,
color: context.colors.onSurfaceVariant,
),
),
),
),
),
);
}
}
class _AttachmentMenu extends StatelessWidget {
final _AttachmentMenuLayout layout;
final VoidCallback onCamera;
final VoidCallback onPhotos;
final VoidCallback onVideo;
final VoidCallback onFiles;
const _AttachmentMenu({
required this.layout,
required this.onCamera,
required this.onPhotos,
required this.onVideo,
required this.onFiles,
});
@override
Widget build(BuildContext context) {
return SizedBox(
key: const ValueKey('attachment-menu'),
width: _attachmentMenuWidth,
height: layout.height,
child: ListView.separated(
key: const ValueKey('attachment-menu-scroll'),
padding: const EdgeInsets.all(_attachmentMenuPadding),
physics: layout.isScrollable
? null
: const NeverScrollableScrollPhysics(),
itemCount: 4,
separatorBuilder: (_, _) =>
const SizedBox(height: _attachmentMenuItemSpacing),
itemBuilder: (context, index) {
final (icon, id, label, onTap) = switch (index) {
0 => (
LucideIcons.camera,
'camera',
context.l10n.cameraMenu,
onCamera,
),
1 => (
LucideIcons.images,
'photos',
context.l10n.photosMenu,
onPhotos,
),
2 => (
LucideIcons.video,
'video',
context.l10n.videoMenu,
onVideo,
),
_ => (
LucideIcons.file,
'files',
context.l10n.filesMenu,
onFiles,
),
};
return _AttachmentMenuItem(
height: layout.itemHeight,
icon: icon,
id: id,
label: label,
onTap: onTap,
);
},
),
);
}
}
class _AttachmentMenuItem extends StatelessWidget {
final double height;
final IconData icon;
final String id;
final String label;
final VoidCallback onTap;
const _AttachmentMenuItem({
required this.height,
required this.icon,
required this.id,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
key: ValueKey('attachment-menu-item-$id'),
height: height,
child: Tooltip(
message: label,
child: InkWell(
onTap: () => _runComposerAction(onTap),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Row(
children: [
SizedBox(
key: ValueKey('attachment-menu-icon-$id'),
width: _attachmentMenuIconSlotWidth,
child: Center(
child: Icon(
icon,
size: _attachmentMenuIconSize,
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
label,
key: ValueKey(
'attachment-menu-label-$id',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w400,
),
),
),
],
),
),
),
),
);
}
}
List<_PendingAttachment> _withoutAttachment(
List<_PendingAttachment> attachments,
int id,
) {
return [
for (final attachment in attachments)
if (attachment.id != id) attachment,
];
}
class _AttachmentStrip extends StatelessWidget {
final List<_PendingAttachment> attachments;
final ValueChanged<int> onRemove;
const _AttachmentStrip({required this.attachments, required this.onRemove});
@override
Widget build(BuildContext context) {
final thumbWidth = 72.0;
final thumbHeight = 72.0;
return SizedBox(
height: thumbHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: attachments.length,
separatorBuilder: (_, _) => const SizedBox(width: Grid.half),
itemBuilder: (context, index) {
final attachment = attachments[index];
return Container(
key: ValueKey('compose-attachment:${attachment.id}'),
width: thumbWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: attachment.kind == _PendingAttachmentKind.video
? ColoredBox(
color: Colors.black,
child: Center(
child: Icon(
LucideIcons.video,
color: Colors.white,
size: 24,
),
),
)
: attachment.kind == _PendingAttachmentKind.image &&
attachment.file.path.isNotEmpty
? Image.file(
File(attachment.file.path),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(
LucideIcons.image,
color: context.colors.onSurfaceVariant,
),
),
)
: attachment.kind == _PendingAttachmentKind.image
? _MemoryAttachmentImage(file: attachment.file)
: ColoredBox(
color: context.colors.surface,
child: Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.file,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.quarter),
Text(
attachment.file.name.isEmpty
? context.l10n.fileAttachment
: attachment.file.name,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
),
),
Positioned(
top: Grid.quarter,
right: Grid.quarter,
child: SizedBox(
width: 24,
height: 24,
child: IconButton(
onPressed: () =>
_runComposerAction(() => onRemove(attachment.id)),
tooltip: context.l10n.removeAttachment,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
backgroundColor: context.colors.surface.withValues(
alpha: 0.92,
),
minimumSize: const Size(24, 24),
maximumSize: const Size(24, 24),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
LucideIcons.x,
size: 14,
color: context.colors.onSurface,
),
),
),
),
],
),
);
},
),
);
}
}
class _MemoryAttachmentImage extends HookConsumerWidget {
final XFile file;
const _MemoryAttachmentImage({required this.file});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bytes = useFuture(useMemoized(() => file.readAsBytes(), [file]));
final data = bytes.data;
if (data == null || data.isEmpty) {
return ColoredBox(
color: context.colors.surface,
child: Icon(LucideIcons.image, color: context.colors.onSurfaceVariant),
);
}
return Image.memory(
data,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(LucideIcons.image, color: context.colors.onSurfaceVariant),
),
);
}
}
@@ -0,0 +1,322 @@
part of '../compose_bar.dart';
class _InlineCameraPreview extends HookConsumerWidget {
final bool initializeCamera;
final Future<void> Function(XFile image) onCapture;
final VoidCallback onClose;
const _InlineCameraPreview({
required this.initializeCamera,
required this.onCapture,
required this.onClose,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useState<camera.CameraController?>(null);
final controllerRef = useRef<camera.CameraController?>(null);
final isInitializing = useState(true);
final isCapturing = useState(false);
final error = useState<String?>(null);
useEffect(() {
if (!initializeCamera) return null;
var disposed = false;
var generation = 0;
Future<void> disposeCurrent() async {
generation += 1;
final current = controllerRef.value;
controllerRef.value = null;
if (!disposed) controller.value = null;
await current?.dispose();
}
Future<void> initialize() async {
final currentGeneration = ++generation;
if (!disposed) {
isInitializing.value = true;
error.value = null;
}
camera.CameraController? next;
try {
final available = await camera.availableCameras();
if (disposed || currentGeneration != generation) return;
if (available.isEmpty) {
throw camera.CameraException(
'no-cameras',
context.l10n.cameraNoDevices,
);
}
final description = available.firstWhere(
(candidate) =>
candidate.lensDirection == camera.CameraLensDirection.back,
orElse: () => available.first,
);
next = camera.CameraController(
description,
camera.ResolutionPreset.high,
enableAudio: false,
);
await next.initialize();
if (disposed || currentGeneration != generation) {
await next.dispose();
return;
}
controllerRef.value = next;
controller.value = next;
isInitializing.value = false;
} catch (cameraError) {
await next?.dispose();
if (!disposed && currentGeneration == generation) {
error.value = _cameraErrorMessage(context, cameraError);
isInitializing.value = false;
}
}
}
final lifecycleListener = AppLifecycleListener(
onInactive: () => unawaited(disposeCurrent()),
onResume: () => unawaited(initialize()),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!disposed) unawaited(initialize());
});
return () {
disposed = true;
lifecycleListener.dispose();
generation += 1;
final current = controllerRef.value;
controllerRef.value = null;
unawaited(current?.dispose() ?? Future<void>.value());
};
}, [initializeCamera]);
Future<void> capture() async {
final activeController = controller.value;
if (activeController == null ||
isCapturing.value ||
activeController.value.isTakingPicture) {
return;
}
isCapturing.value = true;
error.value = null;
try {
final image = await activeController.takePicture();
if (context.mounted) {
await processCapturedImage(image, onCapture);
} else {
await processCapturedImage(image, (_) async {});
}
} catch (captureError) {
if (context.mounted) {
error.value = _cameraErrorMessage(context, captureError);
}
} finally {
if (context.mounted) isCapturing.value = false;
}
}
final activeController = controller.value;
final usesAndroidCameraLayout =
defaultTargetPlatform == TargetPlatform.android;
return ColoredBox(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
if (activeController case final initialized?)
_CameraFeed(controller: initialized)
else
_CameraPlaceholder(
isInitializing: isInitializing.value,
message: error.value,
),
if (activeController != null)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(Grid.twelve),
child: _CameraCaptureButton(
isPressed: isCapturing.value,
onTap: capture,
),
),
),
Positioned(
top: usesAndroidCameraLayout ? null : Grid.xxs,
left: usesAndroidCameraLayout ? Grid.twelve : null,
right: usesAndroidCameraLayout ? null : Grid.xxs,
bottom: usesAndroidCameraLayout
? Grid.twelve + ((_cameraCaptureSize - _cameraBackSize) / 2)
: null,
child: _CameraCloseButton(
onTap: onClose,
emphasized: usesAndroidCameraLayout,
),
),
],
),
);
}
}
class _CameraFeed extends StatelessWidget {
final camera.CameraController controller;
const _CameraFeed({required this.controller});
@override
Widget build(BuildContext context) {
final previewSize = controller.value.previewSize;
if (previewSize == null) return const ColoredBox(color: Colors.black);
return ClipRect(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: previewSize.height,
height: previewSize.width,
child: camera.CameraPreview(controller),
),
),
);
}
}
class _CameraPlaceholder extends StatelessWidget {
final bool isInitializing;
final String? message;
const _CameraPlaceholder({
required this.isInitializing,
required this.message,
});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.black,
child: Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: isInitializing
? BuzzLoadingIndicator(
size: 44,
color: Colors.white,
semanticLabel: context.l10n.startingCamera,
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.cameraOff,
color: Colors.white,
size: 28,
),
const SizedBox(height: Grid.xxs),
Text(
message ?? context.l10n.cameraUnavailable,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: Colors.white,
),
),
],
),
),
),
);
}
}
class _CameraCaptureButton extends StatelessWidget {
final bool isPressed;
final VoidCallback onTap;
const _CameraCaptureButton({required this.isPressed, required this.onTap});
@override
Widget build(BuildContext context) {
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 100);
return Semantics(
button: true,
label: context.l10n.takePhoto,
child: GestureDetector(
onTap: isPressed ? null : () => _runComposerAction(onTap),
child: AnimatedScale(
scale: isPressed ? 0.92 : 1,
duration: duration,
curve: Curves.easeOutCubic,
child: Container(
width: _cameraCaptureSize,
height: _cameraCaptureSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.24),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
),
padding: const EdgeInsets.all(Grid.half),
child: const DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
);
}
}
class _CameraCloseButton extends StatelessWidget {
final VoidCallback onTap;
final bool emphasized;
const _CameraCloseButton({required this.onTap, required this.emphasized});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: emphasized ? _cameraBackSize : 36,
child: IconButton(
onPressed: () => _runComposerAction(onTap),
tooltip: context.l10n.backAttachmentOptions,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(
alpha: emphasized ? 0.68 : 0.56,
),
foregroundColor: Colors.white,
),
icon: Icon(LucideIcons.arrowLeft, size: emphasized ? 24 : 18),
),
);
}
}
const _cameraCaptureSize = 64.0;
const _cameraBackSize = 44.0;
String _cameraErrorMessage(BuildContext context, Object error) {
if (error is camera.CameraException) {
return switch (error.code) {
'CameraAccessDenied' ||
'CameraAccessDeniedWithoutPrompt' ||
'CameraAccessRestricted' => context.l10n.cameraAccessDisabled,
'no-cameras' => context.l10n.cameraUnavailableDevice,
_ => context.l10n.cameraStartRetry,
};
}
return context.l10n.cameraStartRetry;
}
@@ -0,0 +1,967 @@
part of '../compose_bar.dart';
/// Rich compose bar with @mention autocomplete and a markdown formatting
/// toolbar. Used in both channel and thread views — the caller provides an
/// [onSend] callback that handles actual message submission.
typedef ComposeBarOnSend =
Future<void> Function(
String content,
List<String> mentionPubkeys, {
List<List<String>> mediaTags,
});
class ComposeBar extends HookConsumerWidget {
final String channelId;
final String channelName;
final String? hintText;
final ComposeBarOnSend onSend;
/// Optional thread IDs for thread-scoped typing indicators.
final String? threadHeadId;
final String? rootId;
const ComposeBar({
super.key,
required this.channelId,
this.channelName = '',
this.hintText,
this.threadHeadId,
this.rootId,
required this.onSend,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useMemoized(_MarkdownEditingController.new);
useListenable(controller);
useEffect(() => controller.dispose, [controller]);
// Restore and persist unsent text as a local draft so the Activity
// inbox Drafts filter reflects real composer state.
//
// The effect is additionally keyed on the active relay + pubkey identity:
// provider-level namespacing alone cannot protect a composer that stays
// mounted through an in-place community/account switch — the controller
// would retain the old identity's text and the next edit would persist it
// into the new identity's store. On identity change we replace the
// controller content with the new identity's own saved draft (or clear).
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
final draftRevision = useRef(0);
final draftIdentity =
'${ref.watch(relayConfigProvider).baseUrl}'
':${ref.watch(myPubkeyProvider) ?? 'anon'}';
final focusNode = useFocusNode();
useEffect(
() =>
() => _dismissComposerKeyboard(focusNode),
[focusNode],
);
final isComposerExpanded = useState(false);
final isEmojiPickerOpen = useState(false);
final attachmentSurface = useState(_AttachmentSurface.closed);
final iosAttachmentPopover = useMemoized(
_IOSAttachmentPopoverController.new,
);
useEffect(
() =>
() => unawaited(iosAttachmentPopover.dispose()),
[iosAttachmentPopover],
);
final isSending = useState(false);
final showFormatting = useState(false);
final attachments = useState<List<_PendingAttachment>>([]);
_useOwnedAttachmentCleanup(attachments);
final uploadError = useState<String?>(null);
final uploadingCount = useState(0);
final uploadProgress = useState(0.0);
final uploadGeneration = useRef(0);
final activeUploadCancellation = useRef<UploadCancellationToken?>(null);
_useComposeDraftLifecycle(
ref: ref,
controller: controller,
draftKey: draftKey,
channelId: channelId,
threadHeadId: threadHeadId,
draftIdentity: draftIdentity,
draftRevision: draftRevision,
attachments: attachments,
uploadGeneration: uploadGeneration,
activeUploadCancellation: activeUploadCancellation,
uploadingCount: uploadingCount,
isSending: isSending,
attachmentSurface: attachmentSurface,
uploadError: uploadError,
iosAttachmentPopover: iosAttachmentPopover,
);
final clipboardHasImage = useState(false);
final hasAttachments = attachments.value.isNotEmpty;
final customEmoji = ref.watch(customEmojiListProvider);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final composerExpansionController = useAnimationController(
initialValue: 0,
upperBound: 1.05,
);
final composerExpansionValue = useAnimation(composerExpansionController);
final composerExpansionProgress = composerExpansionValue
.clamp(0.0, 1.0)
.toDouble();
void collapseComposer() {
if (!isComposerExpanded.value) return;
showFormatting.value = false;
isComposerExpanded.value = false;
}
useEffect(() {
void collapseWhenUnfocused() {
if (!focusNode.hasFocus && !isEmojiPickerOpen.value) {
collapseComposer();
}
}
focusNode.addListener(collapseWhenUnfocused);
return () => focusNode.removeListener(collapseWhenUnfocused);
}, [focusNode]);
final appView = View.of(context);
useEffect(() {
final observer = _ComposerKeyboardMetricsObserver(
view: appView,
onKeyboardHidden: () {
collapseComposer();
focusNode.unfocus();
},
);
WidgetsBinding.instance.addObserver(observer);
return () => WidgetsBinding.instance.removeObserver(observer);
}, [appView, focusNode]);
final resolvedHint = hintText ??
(channelName.isNotEmpty
? context.l10n.messageChannelHint(channelName)
: context.l10n.messageHint);
useEffect(() {
final target = isComposerExpanded.value ? 1.0 : 0.0;
if (reducedMotion) {
composerExpansionController.value = target;
} else if ((composerExpansionController.value - target).abs() > 0.001) {
composerExpansionController.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 220),
bounce: 0.08,
),
composerExpansionController.value,
target,
0,
snapToEnd: true,
),
);
}
return null;
}, [isComposerExpanded.value, reducedMotion]);
useEffect(() {
if (defaultTargetPlatform != TargetPlatform.iOS) return null;
var disposed = false;
Future<void> refreshClipboardAvailability() async {
final hasImage = await ref
.read(mediaUploadServiceProvider)
.clipboardHasImage();
if (!disposed && context.mounted) {
clipboardHasImage.value = hasImage;
}
}
void refreshWhenFocused() {
if (focusNode.hasFocus) refreshClipboardAvailability();
}
final lifecycleListener = AppLifecycleListener(
onResume: refreshClipboardAvailability,
);
focusNode.addListener(refreshWhenFocused);
refreshClipboardAvailability();
return () {
disposed = true;
focusNode.removeListener(refreshWhenFocused);
lifecycleListener.dispose();
};
}, [focusNode]);
// Mention state --------------------------------------------------------
final mentionQuery = useState<String?>(null);
final mentionStartIdx = useState(-1);
// Map of displayName → selected mention candidate built as the user selects
// mentions. Used to pass resolved pubkeys directly to onSend and to attach
// selected non-member agents before the message is published.
final mentionMap = useRef(<String, MentionCandidate>{});
// Channel autocomplete state ----------------------------------------------
final channelQuery = useState<String?>(null);
final channelStartIdx = useState(-1);
final channelsAsync = ref.watch(channelsProvider);
final membersAsync = ref.watch(channelMembersProvider(channelId));
final currentPubkey = ref.watch(currentPubkeyProvider);
final userCache = ref.watch(userCacheProvider);
final isDmChannel =
channelsAsync.asData?.value.any((c) => c.id == channelId && c.isDm) ??
false;
// Preload profiles for channel members, mentionable agents, and their
// owners so @mention suggestions show names ("managed by …" included).
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
final agentMentionLabels = _agentMentionLabels(
candidates: mentionMap.value.values,
);
final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join(
'\u0000',
);
useEffect(() {
controller.setAgentMentionNames(agentMentionLabels);
return null;
}, [controller, agentMentionLabelsKey]);
useEffect(
() {
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
final pubkeys = [
...memberList.map((m) => m.pubkey),
...?relayAgents?.map((a) => a.pubkey),
...?agentOwners?.values,
];
if (pubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(pubkeys);
}
return null;
},
[
membersAsync.asData?.value.length,
relayAgents?.length,
agentOwners?.length,
],
);
// Typing indicator broadcast — throttled to one event per 3 seconds.
final lastTypingSentMs = useRef(0);
final isModifyingText = useRef(false);
// Detect @mention query and broadcast typing on text / selection change.
useEffect(() {
void listener() {
if (isModifyingText.value) return;
final text = controller.text;
final sel = controller.selection;
// Broadcast typing indicator (throttled).
if (text.isNotEmpty) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - lastTypingSentMs.value > _typingThrottleMs) {
lastTypingSentMs.value = now;
_sendTypingIndicator(
ref,
channelId: channelId,
threadHeadId: threadHeadId,
rootId: rootId,
);
}
}
if (!sel.isValid || !sel.isCollapsed) {
mentionQuery.value = null;
channelQuery.value = null;
return;
}
final cursor = sel.baseOffset;
if (cursor < 1) {
mentionQuery.value = null;
channelQuery.value = null;
return;
}
// Walk backward from cursor looking for trigger characters.
// stopAtSpace: false — @mentions support multi-word display names.
final atPos = findTrigger(text, cursor, '@', stopAtSpace: false);
if (atPos != null) {
mentionQuery.value = text.substring(atPos + 1, cursor).toLowerCase();
mentionStartIdx.value = atPos;
channelQuery.value = null;
} else {
mentionQuery.value = null;
}
// Channel autocomplete detection — only when no @mention is active.
if (mentionQuery.value == null) {
final hashPos = findTrigger(text, cursor, '#');
if (hashPos != null) {
channelQuery.value = text
.substring(hashPos + 1, cursor)
.toLowerCase();
channelStartIdx.value = hashPos;
} else {
channelQuery.value = null;
}
} else {
channelQuery.value = null;
}
}
controller.addListener(listener);
return () => controller.removeListener(listener);
}, [controller]);
// Ranked mention candidates (desktop-parity ordering + eligibility).
final suggestions = mentionQuery.value == null
? const <MentionCandidate>[]
: ref
.watch(
mentionCandidatesProvider((
channelId: channelId,
query: mentionQuery.value!,
)),
)
.take(_mentionSuggestionLimit)
.toList();
// Resolve owner names for the visible "managed by …" subtitles.
useEffect(() {
final ownerPubkeys = [for (final s in suggestions) ?s.ownerPubkey];
if (ownerPubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(ownerPubkeys);
}
return null;
}, [suggestions.length, mentionQuery.value]);
// Filter channels against the query.
final channels = channelsAsync.asData?.value ?? <Channel>[];
final channelSuggestions = filterChannels(channels, channelQuery.value);
// Insert a selected mention into the text field.
void insertMention(MentionCandidate candidate) {
final name = candidate.label;
// Track the resolved candidate so we can pass its pubkey and prepare
// selected non-member agents at send time.
mentionMap.value[name] = candidate;
final start = mentionStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '@$name ',
);
mentionQuery.value = null;
}
// Insert a selected channel into the text field.
void insertChannel(Channel channel) {
final start = channelStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '#${channel.name} ',
);
channelQuery.value = null;
}
// Insert `@` at the cursor to manually trigger mention mode.
void triggerMention() => _insertTriggerAtCursor(controller, focusNode, '@');
// Insert `#` at the cursor to manually trigger channel mode.
void triggerChannel() => _insertTriggerAtCursor(controller, focusNode, '#');
// Insert a selected emoji at the cursor without replacing the draft.
void insertEmoji(String emoji) {
final text = controller.text;
final selection = controller.selection;
final cursor = selection.isValid
? selection.baseOffset.clamp(0, text.length)
: text.length;
controller.value = TextEditingValue(
text: text.replaceRange(cursor, cursor, emoji),
selection: TextSelection.collapsed(offset: cursor + emoji.length),
);
focusNode.requestFocus();
}
void clearComposer() {
draftRevision.value += 1;
controller.clear();
attachments.value = [];
mentionMap.value.clear();
mentionQuery.value = null;
channelQuery.value = null;
attachmentSurface.value = _AttachmentSurface.closed;
showFormatting.value = false;
uploadError.value = null;
focusNode.requestFocus();
}
void removeAttachment(int id) {
_removePendingAttachment(attachments, draftRevision, id);
}
// Send the message.
Future<void> send() async {
final text = controller.text.trim();
if ((text.isEmpty && !hasAttachments) ||
isSending.value ||
uploadingCount.value > 0) {
return;
}
// Resolved before any await: see
// `_reportSendCancelledByCommunitySwitch`.
final messenger = ScaffoldMessenger.maybeOf(context);
final communityChangedMessage =
context.l10n.messageNotSentCommunityChanged;
// Extract pubkeys for mentions present in the final text.
final selectedMentions = <MentionCandidate>[
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
final outgoing = _OutgoingMentions(selectedMentions);
final scan = await _scanNonMemberMentions(
ref,
channelId: channelId,
selectedMentions: selectedMentions,
currentPubkey: currentPubkey,
);
// Mentioning humans outside the channel prompts "Invite" / "Do
// nothing" (send without inviting) — mirrors desktop's
// NonMemberMentionDialog. Agents keep the existing silent auto-add.
if (scan.humans.isNotEmpty) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
names: [for (final candidate in scan.humans) candidate.label],
canInvite: scan.canAddMembers,
);
if (choice == null) return; // Dismissed — keep the draft, send nothing.
outgoing.resolveHumanChoice(choice, scan.humans);
}
final queuedAttachments = List<_PendingAttachment>.of(attachments.value);
final channelActions = ref.read(channelActionsProvider);
// An add that was refused doesn't block the message: it is reported and
// the un-added mentions are demoted to reference tags so the send lands.
Future<void> addMentionedNonMembers() => outgoing.addNonMembers(
channelActions,
scan: scan,
messenger: messenger,
privateChannelError: context.l10n.privateChannelAddDenied,
);
isSending.value = true;
try {
if (queuedAttachments.isEmpty) {
try {
await addMentionedNonMembers();
final payload = _ComposeDraftPayload.fromDraft(
text: text,
attachments: const [],
customEmoji: customEmoji,
);
await onSend(
payload.content,
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
if (context.mounted) clearComposer();
} on StateError {
_reportSendCancelledByCommunitySwitch(
messenger,
communityChangedMessage,
);
} catch (error) {
// send() runs unawaited, so a relay rejection or publish timeout
// would otherwise vanish with the composer looking idle. The draft
// is kept (clearComposer never ran) so the user can retry.
messenger?.showSnackBar(
SnackBar(content: Text(_composeSendErrorMessage(error))),
);
}
return;
}
final draftText = controller.value;
final draftAttachments = List<_PendingAttachment>.of(attachments.value);
final draftMentions = Map<String, MentionCandidate>.of(
mentionMap.value,
);
clearComposer();
final clearedDraftRevision = draftRevision.value;
uploadingCount.value += 1;
uploadProgress.value = 0;
isSending.value = false;
final queueGeneration = uploadGeneration.value;
final cancellation = UploadCancellationToken();
final uploadService = ref.read(mediaUploadServiceProvider);
activeUploadCancellation.value = cancellation;
final delivery = onSend;
unawaited(() async {
var retainedForRetry = false;
try {
final uploaded = <BlobDescriptor>[];
for (var index = 0; index < queuedAttachments.length; index++) {
final attachment = queuedAttachments[index];
final descriptor = await _uploadPendingAttachment(
uploadService,
attachment,
onProgress: (progress) {
if (context.mounted) {
uploadProgress.value =
(index + progress) / queuedAttachments.length;
}
},
cancellationToken: cancellation,
);
if (queueGeneration != uploadGeneration.value) return;
uploaded.add(descriptor);
if (context.mounted) {
uploadProgress.value = (index + 1) / queuedAttachments.length;
}
}
final payload = _ComposeDraftPayload.fromDraft(
text: text,
attachments: uploaded,
customEmoji: customEmoji,
);
if (queueGeneration != uploadGeneration.value) return;
await addMentionedNonMembers();
if (queueGeneration != uploadGeneration.value) return;
await delivery(
payload.content,
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
} catch (error) {
if (cancellation.isCancelled) return;
if (context.mounted) uploadError.value = _formatUploadError(error);
if (context.mounted &&
queueGeneration == uploadGeneration.value &&
draftRevision.value == clearedDraftRevision) {
controller.value = draftText;
attachments.value = draftAttachments;
retainedForRetry = true;
mentionMap.value
..clear()
..addAll(draftMentions);
focusNode.requestFocus();
}
} finally {
if (!retainedForRetry) {
await _deleteOwnedAttachments(queuedAttachments);
}
if (activeUploadCancellation.value == cancellation) {
activeUploadCancellation.value = null;
}
if (context.mounted && queueGeneration == uploadGeneration.value) {
uploadingCount.value = math.max(0, uploadingCount.value - 1);
}
}
}());
} finally {
if (context.mounted && isSending.value) isSending.value = false;
}
}
void queueAttachment(
XFile file,
_PendingAttachmentKind kind, {
bool deleteAfterUse = false,
}) {
draftRevision.value += 1;
uploadError.value = null;
attachments.value = [
...attachments.value,
_PendingAttachment(
file: file,
kind: kind,
deleteAfterUse: deleteAfterUse,
),
];
}
Future<void> pickThenQueue({
required Future<XFile?> Function() pick,
required _PendingAttachmentKind kind,
}) async {
uploadError.value = null;
try {
final picked = await pick();
if (picked == null || !context.mounted) return;
queueAttachment(picked, kind);
} catch (error) {
if (context.mounted) {
uploadError.value = _formatUploadError(error);
}
}
}
void queueImages(List<XFile> images, {bool deleteAfterUse = false}) {
if (images.isEmpty) return;
draftRevision.value += 1;
uploadError.value = null;
attachments.value = [
...attachments.value,
for (final image in images)
_PendingAttachment(
file: image,
kind: _PendingAttachmentKind.image,
deleteAfterUse: deleteAfterUse,
),
];
}
Future<void> retainAndQueueImages(List<XFile> images) =>
_retainAndQueueImages(context, images, queueImages);
Widget buildContextMenu(
BuildContext context,
EditableTextState editableTextState,
) {
void pasteImage() {
ContextMenuController.removeAny();
unawaited(() async {
try {
final image = await ref
.read(mediaUploadServiceProvider)
.readClipboardImage(filename: context.l10n.pastedImage);
if (image != null && context.mounted) {
queueAttachment(image, _PendingAttachmentKind.image);
} else if (context.mounted) {
uploadError.value = context.l10n.unableReadPastedImage;
}
} catch (error) {
if (context.mounted) uploadError.value = _formatUploadError(error);
}
}());
}
if (defaultTargetPlatform == TargetPlatform.iOS &&
SystemContextMenu.isSupportedByField(editableTextState)) {
return SystemContextMenu.editableText(
editableTextState: editableTextState,
items: [
if (clipboardHasImage.value)
IOSSystemContextMenuItemCustom(
title: context.l10n.pasteImage,
onPressed: pasteImage,
),
...SystemContextMenu.getDefaultItems(editableTextState),
],
);
}
final buttonItems = [...editableTextState.contextMenuButtonItems];
if (defaultTargetPlatform == TargetPlatform.iOS &&
clipboardHasImage.value) {
buttonItems.insert(
0,
ContextMenuButtonItem(
label: context.l10n.pasteImage,
onPressed: pasteImage,
),
);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
}
void uploadPastedImage(KeyboardInsertedContent content) {
final bytes = content.data;
if (bytes == null || bytes.isEmpty) {
uploadError.value = context.l10n.unableReadPastedImage;
return;
}
queueAttachment(
XFile.fromData(bytes, name: context.l10n.pastedImage),
_PendingAttachmentKind.image,
);
}
// Wrap (or insert) markdown formatting around the current selection.
void applyFormat(String prefix, [String? suffix]) {
suffix ??= prefix;
final text = controller.text;
final sel = controller.selection;
if (!sel.isValid) return;
isModifyingText.value = true;
try {
if (sel.isCollapsed) {
final offset = sel.baseOffset;
final updated =
'${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: offset + prefix.length,
);
} else {
final selected = text.substring(sel.start, sel.end);
final updated =
'${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: sel.start + prefix.length + selected.length + suffix.length,
);
}
} finally {
isModifyingText.value = false;
}
focusNode.requestFocus();
}
// ----- Widget tree ----------------------------------------------------
void chooseAttachment(
Future<void> Function() choose, {
String? errorMessage,
}) {
attachmentSurface.value = _AttachmentSurface.closed;
unawaited(() async {
try {
await choose();
} catch (error) {
if (context.mounted) {
uploadError.value = errorMessage ?? _formatUploadError(error);
}
}
}());
}
void toggleAttachments() {
attachmentSurface.value = switch (attachmentSurface.value) {
_AttachmentSurface.closed => _AttachmentSurface.menu,
_AttachmentSurface.menu => _AttachmentSurface.closed,
_AttachmentSurface.camera ||
_AttachmentSurface.photos => _AttachmentSurface.menu,
};
}
void handleAttachmentTap(BuildContext triggerContext) {
if (defaultTargetPlatform != TargetPlatform.iOS ||
attachmentSurface.value != _AttachmentSurface.closed) {
toggleAttachments();
return;
}
unawaited(
iosAttachmentPopover
.present(
sourceContext: triggerContext,
onCapture: (image) => retainAndQueueImages([image]),
onChoosePhotos: retainAndQueueImages,
onAllPhotos: () => chooseAttachment(() async {
final photos = await ref
.read(mediaUploadServiceProvider)
.pickGalleryImages();
queueImages(photos);
}, errorMessage: context.l10n.unableOpenPhotoLibrary),
onVideo: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickGalleryVideo,
kind: _PendingAttachmentKind.video,
);
}),
onFiles: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickAttachmentFile,
kind: _PendingAttachmentKind.file,
);
}),
)
.then((didPresent) {
if (!didPresent && context.mounted) {
focusNode.unfocus();
toggleAttachments();
}
}),
);
}
void openCamera() {
focusNode.unfocus();
attachmentSurface.value = _AttachmentSurface.camera;
}
final motionDuration = reducedMotion
? Duration.zero
: Duration(
milliseconds:
attachmentSurface.value == _AttachmentSurface.camera ||
attachmentSurface.value == _AttachmentSurface.photos
? 320
: 250,
);
final suggestionOverlayController = useMemoized(
OverlayPortalController.new,
);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) suggestionOverlayController.show();
});
return null;
}, [suggestionOverlayController]);
void expandComposer() {
if (isComposerExpanded.value) return;
attachmentSurface.value = _AttachmentSurface.closed;
isComposerExpanded.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) focusNode.requestFocus();
});
}
final suggestionPanel = channelSuggestions.isNotEmpty
? KeyedSubtree(
key: const ValueKey('channel-suggestions'),
child: _ChannelSuggestions(
suggestions: channelSuggestions,
onSelect: insertChannel,
),
)
: suggestions.isNotEmpty
? KeyedSubtree(
key: const ValueKey('mention-suggestions'),
child: _MentionSuggestions(
suggestions: suggestions,
userCache: userCache,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
onSelect: insertMention,
),
)
: const SizedBox.shrink(key: ValueKey('no-suggestions'));
Widget buildOverlayPanel(_AttachmentSurface surface) {
return _AttachmentSurfacePanel(
key: ValueKey(
surface == _AttachmentSurface.closed
? 'composer-suggestions'
: 'attachment-surface',
),
surface: surface,
suggestionPanel: suggestionPanel,
onBack: () => attachmentSurface.value = _AttachmentSurface.menu,
onCamera: openCamera,
onPhotos: () {
focusNode.unfocus();
attachmentSurface.value = _AttachmentSurface.photos;
},
onVideo: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickGalleryVideo,
kind: _PendingAttachmentKind.video,
);
}),
onFiles: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickAttachmentFile,
kind: _PendingAttachmentKind.file,
);
}),
onCapture: (image) async {
attachmentSurface.value = _AttachmentSurface.closed;
await retainAndQueueImages([image]);
},
onPickAllPhotos: ref.read(mediaUploadServiceProvider).pickGalleryImages,
onChoosePhotos: (photos) async {
attachmentSurface.value = _AttachmentSurface.closed;
await retainAndQueueImages(photos);
},
onChooseAllPhotos: (photos) async {
attachmentSurface.value = _AttachmentSurface.closed;
queueImages(photos);
},
);
}
// Suggestions and attachments live in the overlay so showing them cannot
// reflow the composer. Both stay anchored just above the capsule.
final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress;
final hasPendingUploads = uploadingCount.value > 0;
return _ComposerDockFrame(
widthFactor: composerWidthFactor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_UploadProgressMotion(
visible: hasPendingUploads,
progress: uploadProgress.value,
reducedMotion: reducedMotion,
onCancel: () {
activeUploadCancellation.value?.cancel();
uploadGeneration.value += 1;
uploadingCount.value = 0;
uploadProgress.value = 0;
},
),
_ComposerOverlayPortal(
controller: suggestionOverlayController,
attachmentSurface: attachmentSurface,
reducedMotion: reducedMotion,
buildOverlayPanel: buildOverlayPanel,
onDismissAttachmentSurface: () {
attachmentSurface.value = _AttachmentSurface.closed;
},
child: _ComposeBarLayout(
attachments: attachments.value,
onRemoveAttachment: removeAttachment,
uploadError: uploadError.value,
isExpanded: isComposerExpanded.value,
controller: controller,
focusNode: focusNode,
contextMenuBuilder: buildContextMenu,
onContentInserted: uploadPastedImage,
onSend: () => unawaited(send()),
resolvedHint: resolvedHint,
attachmentSurface: attachmentSurface.value,
onAttachmentTap: handleAttachmentTap,
onExpand: expandComposer,
expansionValue: composerExpansionValue,
expansionProgress: composerExpansionProgress,
formattingOpen: showFormatting.value,
onCloseFormatting: () => showFormatting.value = false,
motionDuration: motionDuration,
onFormat: applyFormat,
onMention: () {
attachmentSurface.value = _AttachmentSurface.closed;
triggerMention();
},
onChannel: () {
attachmentSurface.value = _AttachmentSurface.closed;
triggerChannel();
},
onEmoji: () {
attachmentSurface.value = _AttachmentSurface.closed;
isEmojiPickerOpen.value = true;
_showComposerEmojiPicker(context, insertEmoji, () {
if (!context.mounted) return;
isEmojiPickerOpen.value = false;
focusNode.requestFocus();
});
},
onOpenFormatting: () {
attachmentSurface.value = _AttachmentSurface.closed;
showFormatting.value = true;
},
hasPendingUploads: hasPendingUploads,
canSend: controller.text.trim().isNotEmpty || hasAttachments,
isSending: isSending.value,
),
),
],
),
);
}
}
@@ -0,0 +1,144 @@
part of '../compose_bar.dart';
class _ComposerDockFrame extends StatelessWidget {
final double widthFactor;
final Widget child;
const _ComposerDockFrame({required this.widthFactor, required this.child});
@override
Widget build(BuildContext context) {
final backdropHeight = mobileTabFooterBackdropHeight(context);
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
key: const ValueKey('composer-footer-gradient'),
left: 0,
right: 0,
bottom: 0,
height: backdropHeight,
child: IgnorePointer(
child: MobileTabFooterBackdrop(height: backdropHeight),
),
),
Padding(
padding: EdgeInsets.only(
left: Grid.twelve,
right: Grid.twelve,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
),
child: Align(
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
key: const ValueKey('composer-width-transition'),
widthFactor: widthFactor,
child: child,
),
),
),
],
);
}
}
class _ComposerOverlayPortal extends StatelessWidget {
final OverlayPortalController controller;
final ValueListenable<_AttachmentSurface> attachmentSurface;
final bool reducedMotion;
final Widget Function(_AttachmentSurface surface) buildOverlayPanel;
final VoidCallback onDismissAttachmentSurface;
final Widget child;
const _ComposerOverlayPortal({
required this.controller,
required this.attachmentSurface,
required this.reducedMotion,
required this.buildOverlayPanel,
required this.onDismissAttachmentSurface,
required this.child,
});
@override
Widget build(BuildContext context) {
return OverlayPortal.overlayChildLayoutBuilder(
controller: controller,
overlayChildBuilder: (context, layoutInfo) {
final composerOrigin = MatrixUtils.transformPoint(
layoutInfo.childPaintTransform,
Offset.zero,
);
return ValueListenableBuilder<_AttachmentSurface>(
valueListenable: attachmentSurface,
builder: (context, surface, _) {
final surfaceDuration = reducedMotion
? Duration.zero
: Duration(
milliseconds:
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos
? 320
: 250,
);
final expandedSurfaceCoversComposer =
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos;
final surfaceLeft = expandedSurfaceCoversComposer
? Grid.twelve
: composerOrigin.dx;
final surfaceWidth = expandedSurfaceCoversComposer
? layoutInfo.overlaySize.width - (Grid.twelve * 2)
: layoutInfo.childSize.width;
final overlayAnchorY =
composerOrigin.dy +
(expandedSurfaceCoversComposer
? layoutInfo.childSize.height + Grid.twelve
: 0);
return Stack(
children: [
if (surface != _AttachmentSurface.closed)
Positioned(
left: 0,
top: 0,
right: 0,
height: composerOrigin.dy,
child: ExcludeSemantics(
child: GestureDetector(
key: const ValueKey('attachment-dismiss-barrier'),
behavior: HitTestBehavior.opaque,
onTap: onDismissAttachmentSurface,
),
),
),
AnimatedPositioned(
duration: surfaceDuration,
curve:
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos
? const Cubic(0.34, 1.25, 0.64, 1)
: const Cubic(0.22, 1, 0.36, 1),
left: surfaceLeft,
bottom: layoutInfo.overlaySize.height - overlayAnchorY,
width: surfaceWidth,
child: ClipRect(
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: surface == _AttachmentSurface.closed
? _SuggestionPanelMotion(
duration: surfaceDuration,
alignment: Alignment.bottomLeft,
child: buildOverlayPanel(surface),
)
: buildOverlayPanel(surface),
),
),
),
],
);
},
);
},
child: child,
);
}
}
@@ -0,0 +1,60 @@
part of '../compose_bar.dart';
void _useComposeDraftLifecycle({
required WidgetRef ref,
required _MarkdownEditingController controller,
required String draftKey,
required String channelId,
required String? threadHeadId,
required String draftIdentity,
required ObjectRef<int> draftRevision,
required ValueNotifier<List<_PendingAttachment>> attachments,
required ObjectRef<int> uploadGeneration,
required ObjectRef<UploadCancellationToken?> activeUploadCancellation,
required ValueNotifier<int> uploadingCount,
required ValueNotifier<bool> isSending,
required ValueNotifier<_AttachmentSurface> attachmentSurface,
required ValueNotifier<String?> uploadError,
required _IOSAttachmentPopoverController iosAttachmentPopover,
}) {
final lastDraftIdentity = useRef<String?>(null);
useEffect(() {
final identityChanged =
lastDraftIdentity.value != null &&
lastDraftIdentity.value != draftIdentity;
lastDraftIdentity.value = draftIdentity;
final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey);
if (identityChanged) {
draftRevision.value += 1;
uploadGeneration.value += 1;
activeUploadCancellation.value?.cancel();
activeUploadCancellation.value = null;
uploadingCount.value = 0;
isSending.value = false;
attachmentSurface.value = _AttachmentSurface.closed;
uploadError.value = null;
unawaited(iosAttachmentPopover.dispose());
final staleAttachments = attachments.value;
attachments.value = const [];
unawaited(_deleteOwnedAttachments(staleAttachments));
controller.text = saved ?? '';
} else if (saved != null && controller.text.isEmpty) {
controller.text = saved;
}
void persistDraft() {
draftRevision.value += 1;
ref
.read(composeDraftsProvider.notifier)
.save(
key: draftKey,
channelId: channelId,
threadHeadId: threadHeadId,
text: controller.text,
);
}
controller.addListener(persistDraft);
return () => controller.removeListener(persistDraft);
}, [controller, draftKey, draftIdentity]);
}
@@ -0,0 +1,90 @@
part of '../compose_bar.dart';
class _FormattingToolbar extends StatelessWidget {
final void Function(String prefix, [String? suffix]) onFormat;
const _FormattingToolbar({required this.onFormat});
@override
Widget build(BuildContext context) {
return Row(
key: const ValueKey('formatting-actions'),
mainAxisSize: MainAxisSize.min,
children: [
_FormatButton(
icon: LucideIcons.bold,
tooltip: context.l10n.bold,
onTap: () => onFormat('**'),
),
_FormatButton(
icon: LucideIcons.italic,
tooltip: context.l10n.italic,
onTap: () => onFormat('_'),
),
_FormatButton(
icon: LucideIcons.strikethrough,
tooltip: context.l10n.strikethrough,
onTap: () => onFormat('~~'),
),
_FormatButton(
icon: LucideIcons.code,
tooltip: context.l10n.code,
onTap: () => onFormat('`'),
),
_FormatButton(
icon: LucideIcons.squareCode,
tooltip: context.l10n.codeBlock,
onTap: () => onFormat('```\n', '\n```'),
),
],
);
}
}
class _FormatButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
const _FormatButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(Radii.sm),
onTap: () => _runComposerAction(onTap),
child: Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: Icon(icon, size: 18, color: context.colors.primary),
),
),
);
}
}
class _ComposeAction extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _ComposeAction({required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: () => _runComposerAction(onTap),
icon: Icon(icon, size: 20, color: context.colors.onSurfaceVariant),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
);
}
}
@@ -0,0 +1,455 @@
part of '../compose_bar.dart';
const _typingThrottleMs = 3000;
class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver {
final FlutterView view;
final VoidCallback onKeyboardHidden;
bool _wasVisible;
_ComposerKeyboardMetricsObserver({
required this.view,
required this.onKeyboardHidden,
}) : _wasVisible = view.viewInsets.bottom > 0;
@override
void didChangeMetrics() {
final isVisible = view.viewInsets.bottom > 0;
if (_wasVisible && !isVisible) onKeyboardHidden();
_wasVisible = isVisible;
}
}
void _runComposerAction(VoidCallback action) {
unawaited(HapticFeedback.selectionClick());
action();
}
void _showComposerEmojiPicker(
BuildContext context,
ValueChanged<String> onSelect,
VoidCallback onDismiss,
) {
showEmojiPicker(
context: context,
onSelect: (emoji) => _runComposerAction(() => onSelect(emoji)),
onDismiss: onDismiss,
);
}
void _dismissComposerKeyboard(FocusNode focusNode) {
focusNode.unfocus();
unawaited(SystemChannels.textInput.invokeMethod<void>('TextInput.hide'));
}
const _pastedImageMimeTypes = <String>[
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
];
/// Cap on ranked mention suggestions shown — matches desktop's
/// `MENTION_SUGGESTION_LIMIT`.
const _mentionSuggestionLimit = 50;
/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
/// word boundary. Returns the index of the trigger character, or `null` if none
/// is found.
///
/// When [stopAtSpace] is `true` the walk stops at both spaces and newlines —
/// appropriate for `#channel` names which are kebab-case slugs without spaces.
/// When `false`, only newlines stop the walk, allowing multi-word queries like
/// `@Alice Smith` to match members with multi-word display names.
@visibleForTesting
int? findTrigger(
String text,
int cursor,
String trigger, {
bool stopAtSpace = true,
}) {
for (var i = cursor - 1; i >= 0; i--) {
final ch = text[i];
if (ch == '\n') break;
if (stopAtSpace && ch == ' ') break;
if (ch == trigger) {
if (i == 0 || text[i - 1] == ' ' || text[i - 1] == '\n') {
return i;
}
break;
}
}
return null;
}
/// Replace the range `[start, cursor)` with [replacement] and move the cursor
/// to the end of the replacement. Used by both mention and channel insertion.
@visibleForTesting
void spliceAndMoveCursor(
TextEditingController controller,
FocusNode focusNode, {
required int start,
required String replacement,
}) {
final text = controller.text;
final cursor =
(controller.selection.isValid
? controller.selection.baseOffset
: text.length)
.clamp(start, text.length);
final before = text.substring(0, start);
final after = text.substring(cursor);
controller.text = '$before$replacement$after';
controller.selection = TextSelection.collapsed(
offset: start + replacement.length,
);
focusNode.requestFocus();
}
/// Insert [trigger] (e.g. `@` or `#`) at the cursor position, prefixed with
/// a space if needed for word separation. Used by `triggerMention` and
/// `triggerChannel`.
void _insertTriggerAtCursor(
TextEditingController controller,
FocusNode focusNode,
String trigger,
) {
final text = controller.text;
final cursor = controller.selection.isValid
? controller.selection.baseOffset
: text.length;
final needsSpace =
cursor > 0 && text[cursor - 1] != ' ' && text[cursor - 1] != '\n';
final insert = needsSpace ? ' $trigger' : trigger;
final before = text.substring(0, cursor);
final after = text.substring(cursor);
controller.text = '$before$insert$after';
controller.selection = TextSelection.collapsed(
offset: cursor + insert.length,
);
focusNode.requestFocus();
}
bool hasMention(String text, String name) {
final pattern = RegExp(
'(?:^|\\s|[*_]{1,3}|\\|\\|)@${RegExp.escape(name)}(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)',
caseSensitive: false,
);
return pattern.hasMatch(text);
}
/// Outcome of the non-member mention prompt. `null` (dialog dismissed)
/// cancels the send and keeps the draft.
enum _NonMemberMentionChoice { invite, sendWithoutInviting }
/// User-facing text for a failed add or send.
String _composeSendErrorMessage(Object error) {
if (error is AddMembersException) return error.message;
return error.toString().replaceFirst('Exception: ', '');
}
/// Ask whether to invite mentioned humans who aren't channel members, or
/// send without inviting them. Mirrors desktop's `NonMemberMentionDialog`.
/// [canInvite] false (a private channel the sender doesn't own/administer)
/// drops the Invite action — the relay rejects that add, so offering it would
/// only produce an error.
Future<_NonMemberMentionChoice?> _promptNonMemberMention(
BuildContext context, {
required List<String> names,
required bool canInvite,
}) {
return showBuzzDialog<_NonMemberMentionChoice>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.mentionOutsideTitle),
content: Text(
canInvite
? context.l10n.mentionOutsidePeopleContent(names.join(', '))
: '${context.l10n.mentionOutsidePeopleContent(names.join(', '))} '
'${context.l10n.privateChannelNoInvite}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(
dialogContext,
).pop(_NonMemberMentionChoice.sendWithoutInviting),
child: Text(
canInvite ? context.l10n.doNothing : context.l10n.sendAnyway,
),
),
if (canInvite)
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite),
child: Text(context.l10n.invite),
),
],
),
);
}
/// Send a typing indicator over the WebSocket (fire-and-forget).
///
/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not
/// via HTTP. Ephemeral events like typing indicators are broadcast-only and
/// the relay doesn't persist them, so the HTTP `/api/events` endpoint may
/// silently discard them.
void _sendTypingIndicator(
WidgetRef ref, {
required String channelId,
String? threadHeadId,
String? rootId,
}) {
try {
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
if (nsec == null || nsec.isEmpty) return;
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return;
final tags = <List<String>>[
['h', channelId],
if (threadHeadId != null && rootId != null && rootId != threadHeadId) ...[
['e', rootId, '', 'root'],
['e', threadHeadId, '', 'reply'],
] else if (threadHeadId != null)
['e', threadHeadId, '', 'reply'],
];
final event = nostr.Event.from(
kind: EventKind.typingIndicator,
content: '',
tags: tags,
secretKey: privkeyHex,
verify: false,
);
// Send directly over WebSocket — fire-and-forget, matching desktop.
final session = ref.read(relaySessionProvider.notifier);
session.sendRaw(['EVENT', event.toMap()]);
} catch (_) {
// Fire-and-forget — typing indicator failure is non-fatal.
}
}
/// Reports a send that was cancelled because the active community changed.
///
/// The send path is fire-and-forget, so a `StateError` escaping it would be
/// silent. The composer's own error line cannot carry this message either: the
/// identity change that causes the failure also resets that state on the next
/// frame, so [messenger] must be resolved before the send's first `await`.
void _reportSendCancelledByCommunitySwitch(
ScaffoldMessengerState? messenger,
String message,
) {
messenger?.showSnackBar(
SnackBar(content: Text(message)),
);
}
/// What an add attempt left undone: who is still a non-member, and why.
@immutable
class _NonMemberAddOutcome {
final List<String> notAdded;
final List<String> errors;
const _NonMemberAddOutcome({required this.notAdded, required this.errors});
static const empty = _NonMemberAddOutcome(notAdded: [], errors: []);
}
/// Adds mentioned non-members to the channel before a send.
///
/// Agents are added silently with the `bot` role; humans are only passed here
/// after they have been explicitly invited from the mention prompt.
///
/// A rejection is reported, never thrown: the send is fire-and-forget, so an
/// escaping error would drop the message with nothing shown. [StateError] still
/// propagates — a community switch must cancel the whole send.
Future<_NonMemberAddOutcome> _addMentionedNonMembers(
ChannelActions channelActions, {
required String channelId,
required List<String> agentPubkeys,
required List<String> humanPubkeys,
required bool canAddMembers,
required String privateChannelError,
}) async {
final pending = [
if (agentPubkeys.isNotEmpty) (agentPubkeys, 'bot'),
if (humanPubkeys.isNotEmpty) (humanPubkeys, 'member'),
];
if (pending.isEmpty) return _NonMemberAddOutcome.empty;
// A plain member of a private channel cannot add anyone: skip the doomed
// kind:9000 rather than trading it for a relay rejection.
if (!canAddMembers) {
return _NonMemberAddOutcome(
notAdded: [for (final (pubkeys, _) in pending) ...pubkeys],
errors: [privateChannelError],
);
}
final notAdded = <String>[];
final errors = <String>[];
for (final (pubkeys, role) in pending) {
try {
await channelActions.addMembers(
channelId: channelId,
pubkeys: pubkeys,
role: role,
);
} on StateError {
rethrow;
} catch (error) {
notAdded.addAll(
error is AddMembersException ? error.failures.keys : pubkeys,
);
errors.add(_composeSendErrorMessage(error));
}
}
return _NonMemberAddOutcome(notAdded: notAdded, errors: errors);
}
/// Mentioned identities that aren't in the channel yet, plus whether the sender
/// is allowed to add them at all.
@immutable
class _NonMemberMentionScan {
final String channelId;
final List<String> agentPubkeys;
final List<MentionCandidate> humans;
final bool canAddMembers;
const _NonMemberMentionScan({
required this.channelId,
required this.agentPubkeys,
required this.humans,
required this.canAddMembers,
});
}
/// Resolves which mentioned identities are non-members, and whether this
/// identity may add them (see [Channel.canAddMembers]). DMs are skipped: their
/// participant set is fixed at creation.
Future<_NonMemberMentionScan> _scanNonMemberMentions(
WidgetRef ref, {
required String channelId,
required List<MentionCandidate> selectedMentions,
required String? currentPubkey,
}) async {
final none = _NonMemberMentionScan(
channelId: channelId,
agentPubkeys: const [],
humans: const [],
canAddMembers: true,
);
if (selectedMentions.isEmpty) return none;
final channel = (await ref.read(
channelsProvider.future,
)).firstWhere((candidate) => candidate.id == channelId);
if (channel.isDm) return none;
final members = await ref.read(channelMembersProvider(channelId).future);
final memberPubkeys = {
for (final member in members) member.pubkey.toLowerCase(),
};
String? selfRole;
if (currentPubkey != null) {
final self = currentPubkey.toLowerCase();
for (final member in members) {
if (member.pubkey.toLowerCase() == self) {
selfRole = member.role;
break;
}
}
}
final agentPubkeys = <String>[];
final humans = <MentionCandidate>[];
final seen = <String>{};
for (final candidate in selectedMentions) {
final pubkey = candidate.pubkey.toLowerCase();
if (memberPubkeys.contains(pubkey) || !seen.add(pubkey)) continue;
if (candidate.isAgent) {
agentPubkeys.add(pubkey);
} else {
humans.add(candidate);
}
}
return _NonMemberMentionScan(
channelId: channelId,
agentPubkeys: agentPubkeys,
humans: humans,
canAddMembers: channel.canAddMembers(selfRole),
);
}
/// The p-tags and `mention` reference tags an outgoing message should carry.
///
/// Anyone who ends up *not* added is demoted from a p-tag to a reference tag so
/// their name still renders without notifying a non-member — mirrors desktop's
/// `mergeOutgoingTagsWithReferenceMentions`.
class _OutgoingMentions {
List<String> pubkeys;
final List<List<String>> referenceTags = [];
List<String> _invitedHumanPubkeys = const [];
_OutgoingMentions(List<MentionCandidate> selectedMentions)
: pubkeys = LinkedHashSet<String>.from(
selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();
void demote(Iterable<String> demoted) {
final excluded = {for (final pubkey in demoted) pubkey.toLowerCase()};
if (excluded.isEmpty) return;
pubkeys = [
for (final pubkey in pubkeys)
if (!excluded.contains(pubkey)) pubkey,
];
referenceTags.addAll([
for (final pubkey in excluded) ['mention', pubkey],
]);
}
/// Applies the mention prompt's outcome: invite them, or send without.
void resolveHumanChoice(
_NonMemberMentionChoice choice,
List<MentionCandidate> humans,
) {
final humanPubkeys = [
for (final candidate in humans) candidate.pubkey.toLowerCase(),
];
switch (choice) {
case _NonMemberMentionChoice.invite:
_invitedHumanPubkeys = humanPubkeys;
case _NonMemberMentionChoice.sendWithoutInviting:
demote(humanPubkeys);
}
}
/// Adds the scanned non-members, demoting and reporting whatever didn't land.
Future<void> addNonMembers(
ChannelActions channelActions, {
required _NonMemberMentionScan scan,
required ScaffoldMessengerState? messenger,
required String privateChannelError,
}) async {
final outcome = await _addMentionedNonMembers(
channelActions,
channelId: scan.channelId,
agentPubkeys: scan.agentPubkeys,
humanPubkeys: _invitedHumanPubkeys,
canAddMembers: scan.canAddMembers,
privateChannelError: privateChannelError,
);
demote(outcome.notAdded);
if (outcome.errors.isNotEmpty) {
messenger?.showSnackBar(
SnackBar(content: Text(outcome.errors.join(' '))),
);
}
}
}
@@ -0,0 +1,199 @@
part of '../compose_bar.dart';
const _nativeAttachmentPopoverChannel = MethodChannel(
'buzz/native_attachment_popover',
);
final _iosAttachmentPopoverCoordinator = _IOSAttachmentPopoverCoordinator(
_nativeAttachmentPopoverChannel,
);
class _IOSAttachmentPopoverCallbacks {
final Future<void> Function(XFile image) onCapture;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final VoidCallback onAllPhotos;
final VoidCallback onVideo;
final VoidCallback onFiles;
const _IOSAttachmentPopoverCallbacks({
required this.onCapture,
required this.onChoosePhotos,
required this.onAllPhotos,
required this.onVideo,
required this.onFiles,
});
}
class _IOSAttachmentPopoverCoordinator {
static final Object _cancelledSupportCheck = Object();
final MethodChannel _channel;
Object? _activeOwner;
_IOSAttachmentPopoverCallbacks? _callbacks;
Completer<void>? _pendingSupportCancellation;
bool _didPresent = false;
bool _handlerInstalled = false;
_IOSAttachmentPopoverCoordinator(this._channel);
Future<bool> present({
required Object owner,
required BuildContext sourceContext,
required Future<void> Function(XFile image) onCapture,
required Future<void> Function(List<XFile> photos) onChoosePhotos,
required VoidCallback onAllPhotos,
required VoidCallback onVideo,
required VoidCallback onFiles,
}) async {
if (defaultTargetPlatform != TargetPlatform.iOS) return false;
if (_activeOwner case final activeOwner?) {
if (identical(activeOwner, owner)) return true;
if (!_didPresent) _clearOwner(activeOwner);
return _didPresent;
}
final supportCancellation = Completer<void>();
_activeOwner = owner;
_pendingSupportCancellation = supportCancellation;
_callbacks = _IOSAttachmentPopoverCallbacks(
onCapture: onCapture,
onChoosePhotos: onChoosePhotos,
onAllPhotos: onAllPhotos,
onVideo: onVideo,
onFiles: onFiles,
);
_ensureHandler();
try {
final supportResult = await Future.any<Object?>([
_channel.invokeMethod<bool>('isSupported'),
supportCancellation.future.then<Object?>((_) => _cancelledSupportCheck),
]);
if (identical(supportResult, _cancelledSupportCheck)) return true;
if (identical(_pendingSupportCancellation, supportCancellation)) {
_pendingSupportCancellation = null;
}
if (!identical(_activeOwner, owner)) return false;
final supported = supportResult == true;
if (!supported || !sourceContext.mounted) {
_clearOwner(owner);
return false;
}
final renderObject = sourceContext.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) {
_clearOwner(owner);
return false;
}
final origin = renderObject.localToGlobal(Offset.zero);
_didPresent = true;
final didPresent =
await _channel.invokeMethod<bool>('present', {
'x': origin.dx,
'y': origin.dy,
'width': renderObject.size.width,
'height': renderObject.size.height,
}) ??
false;
if (!identical(_activeOwner, owner)) return false;
if (!didPresent) _clearOwner(owner);
return didPresent;
} on PlatformException {
_clearOwner(owner);
return false;
}
}
Future<void> disposeOwner(Object owner) async {
if (!identical(_activeOwner, owner)) return;
_callbacks = null;
if (!_didPresent) {
_clearOwner(owner);
return;
}
try {
await _channel.invokeMethod<void>('dismiss');
} on PlatformException {
// The native bridge is unavailable, so there is nothing left to dismiss.
} on MissingPluginException {
// The native bridge is unavailable, so there is nothing left to dismiss.
} finally {
_clearOwner(owner);
}
}
void _ensureHandler() {
if (_handlerInstalled) return;
_handlerInstalled = true;
_channel.setMethodCallHandler(_handleMethodCall);
}
Future<void> _handleMethodCall(MethodCall call) async {
final callbacks = _callbacks;
switch (call.method) {
case 'cameraCaptured':
if (call.arguments case final String path) {
final activeCallbacks = callbacks;
if (activeCallbacks != null) {
await processCapturedImage(XFile(path), activeCallbacks.onCapture);
}
}
case 'photosSelected':
final paths = (call.arguments as List<Object?>? ?? const [])
.whereType<String>()
.toList();
if (callbacks != null && paths.isNotEmpty) {
await processTemporaryImages([
for (final path in paths) XFile(path),
], callbacks.onChoosePhotos);
}
case 'pickAllPhotos':
callbacks?.onAllPhotos();
case 'pickVideo':
callbacks?.onVideo();
case 'pickFiles':
callbacks?.onFiles();
case 'dismissed':
_activeOwner = null;
_callbacks = null;
_didPresent = false;
}
}
void _clearOwner(Object owner) {
if (!identical(_activeOwner, owner)) return;
final supportCancellation = _pendingSupportCancellation;
_pendingSupportCancellation = null;
if (supportCancellation != null && !supportCancellation.isCompleted) {
supportCancellation.complete();
}
_activeOwner = null;
_callbacks = null;
_didPresent = false;
}
}
class _IOSAttachmentPopoverController {
Future<bool> present({
required BuildContext sourceContext,
required Future<void> Function(XFile image) onCapture,
required Future<void> Function(List<XFile> photos) onChoosePhotos,
required VoidCallback onAllPhotos,
required VoidCallback onVideo,
required VoidCallback onFiles,
}) => _iosAttachmentPopoverCoordinator.present(
owner: this,
sourceContext: sourceContext,
onCapture: onCapture,
onChoosePhotos: onChoosePhotos,
onAllPhotos: onAllPhotos,
onVideo: onVideo,
onFiles: onFiles,
);
Future<void> dispose() => _iosAttachmentPopoverCoordinator.disposeOwner(this);
}
@@ -0,0 +1,239 @@
part of '../compose_bar.dart';
const _inlinePhotoPickerViewType = 'buzz/inline_photo_picker';
const _inlinePhotoPickerSupportChannel = MethodChannel(
'buzz/inline_photo_picker',
);
class _IOSInlinePhotoPicker extends HookWidget {
final VoidCallback onBack;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
final Widget fallback;
const _IOSInlinePhotoPicker({
required this.onBack,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.onChooseAllPhotos,
required this.fallback,
});
@override
Widget build(BuildContext context) {
final supportFuture = useMemoized(
() async =>
await _inlinePhotoPickerSupportChannel.invokeMethod<bool>(
'isSupported',
) ??
false,
);
final support = useFuture(supportFuture);
final pickerChannel = useState<MethodChannel?>(null);
final selectedCount = useState(0);
final selectedPaths = useState<List<String>>(const []);
final isPreparingSelection = useState(false);
final isProcessing = useState(false);
useEffect(() {
final channel = pickerChannel.value;
if (channel == null) return null;
channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'selectionCountChanged':
selectedCount.value = call.arguments as int? ?? 0;
selectedPaths.value = const [];
isPreparingSelection.value = selectedCount.value > 0;
case 'selectionDidChange':
final paths = (call.arguments as List<Object?>? ?? const [])
.whereType<String>()
.toList();
selectedPaths.value = paths;
isPreparingSelection.value = false;
case 'didFail':
isPreparingSelection.value = false;
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(
call.arguments as String? ??
context.l10n.preparingPhotos,
),
),
);
}
});
return () => channel.setMethodCallHandler(null);
}, [pickerChannel.value]);
final canSelect =
selectedCount.value > 0 &&
selectedPaths.value.length == selectedCount.value &&
!isPreparingSelection.value &&
!isProcessing.value;
Future<void> submitSelection() async {
if (!canSelect) return;
isProcessing.value = true;
try {
final paths = List<String>.from(selectedPaths.value);
final claimed =
await pickerChannel.value?.invokeMethod<bool>(
'claimSelection',
paths,
) ??
false;
if (!claimed) return;
final photos = [for (final path in paths) XFile(path)];
await processTemporaryImages(photos, onChoosePhotos);
} finally {
if (context.mounted) isProcessing.value = false;
}
}
Future<void> openAllPhotos() async {
if (isPreparingSelection.value || isProcessing.value) return;
isProcessing.value = true;
try {
final photos = await onPickAllPhotos();
if (photos.isNotEmpty) {
await onChooseAllPhotos(photos);
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(context.l10n.unablePhotoLibrary)),
);
}
} finally {
if (context.mounted) isProcessing.value = false;
}
}
if (support.connectionState != ConnectionState.done) {
return const _NativePhotoPickerLoading();
}
if (support.hasError || support.data != true) return fallback;
return SizedBox(
key: const ValueKey('ios-inline-photo-picker'),
height: _attachmentExpandedHeight,
width: double.infinity,
child: Stack(
fit: StackFit.expand,
children: [
UiKitView(
viewType: _inlinePhotoPickerViewType,
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (viewId) {
pickerChannel.value = MethodChannel(
'buzz/inline_photo_picker/$viewId',
);
},
),
PositionedDirectional(
start: Grid.twelve,
bottom: Grid.twelve,
child: SafeArea(
top: false,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.62),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.28),
),
),
child: IconButton(
key: const ValueKey('ios-inline-photo-picker-back'),
onPressed: isProcessing.value
? null
: () => _runComposerAction(onBack),
tooltip: context.l10n.backAttachmentOptions,
icon: const Icon(
LucideIcons.chevronLeft,
color: Colors.white,
),
),
),
),
),
PositionedDirectional(
end: Grid.twelve,
bottom: Grid.twelve,
child: SafeArea(
top: false,
child: FilledButton(
key: const ValueKey('ios-inline-photo-picker-select'),
onPressed: canSelect
? () =>
_runComposerAction(() => unawaited(submitSelection()))
: selectedCount.value == 0 &&
!isPreparingSelection.value &&
!isProcessing.value
? () => _runComposerAction(() => unawaited(openAllPhotos()))
: null,
style: FilledButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.76),
disabledBackgroundColor: Colors.black.withValues(alpha: 0.32),
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white70,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
shape: const StadiumBorder(),
side: BorderSide(color: Colors.white.withValues(alpha: 0.28)),
),
child: isPreparingSelection.value
? SizedBox.square(
dimension: 20,
child: BuzzLoadingIndicator(
size: 20,
color: Colors.white,
semanticLabel: context.l10n.preparingPhotos,
),
)
: Text(
selectedCount.value == 0
? context.l10n.allPhotos
: context.l10n.addPhoto(selectedCount.value),
),
),
),
),
if (isProcessing.value)
ColoredBox(
color: Color.fromRGBO(0, 0, 0, 0.28),
child: Center(
child: BuzzLoadingIndicator(
size: 44,
color: Colors.white,
semanticLabel: context.l10n.preparingPhotos,
),
),
),
],
),
);
}
}
class _NativePhotoPickerLoading extends StatelessWidget {
const _NativePhotoPickerLoading();
@override
Widget build(BuildContext context) {
return SizedBox(
key: const ValueKey('ios-inline-photo-picker-loading'),
height: _attachmentExpandedHeight,
width: double.infinity,
child: Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: context.l10n.openingPhotos,
),
),
);
}
}
@@ -0,0 +1,323 @@
part of '../compose_bar.dart';
class _ComposeBarLayout extends StatelessWidget {
final List<_PendingAttachment> attachments;
final ValueChanged<int> onRemoveAttachment;
final String? uploadError;
final bool isExpanded;
final TextEditingController controller;
final FocusNode focusNode;
final EditableTextContextMenuBuilder contextMenuBuilder;
final ValueChanged<KeyboardInsertedContent> onContentInserted;
final VoidCallback onSend;
final String resolvedHint;
final _AttachmentSurface attachmentSurface;
final ValueChanged<BuildContext> onAttachmentTap;
final VoidCallback onExpand;
final double expansionValue;
final double expansionProgress;
final bool formattingOpen;
final VoidCallback onCloseFormatting;
final Duration motionDuration;
final void Function(String prefix, [String? suffix]) onFormat;
final VoidCallback onMention;
final VoidCallback onChannel;
final VoidCallback onEmoji;
final VoidCallback onOpenFormatting;
final bool canSend;
final bool hasPendingUploads;
final bool isSending;
const _ComposeBarLayout({
required this.attachments,
required this.onRemoveAttachment,
required this.uploadError,
required this.isExpanded,
required this.controller,
required this.focusNode,
required this.contextMenuBuilder,
required this.onContentInserted,
required this.onSend,
required this.resolvedHint,
required this.attachmentSurface,
required this.onAttachmentTap,
required this.onExpand,
required this.expansionValue,
required this.expansionProgress,
required this.formattingOpen,
required this.onCloseFormatting,
required this.motionDuration,
required this.onFormat,
required this.onMention,
required this.onChannel,
required this.onEmoji,
required this.onOpenFormatting,
required this.canSend,
required this.hasPendingUploads,
required this.isSending,
});
@override
Widget build(BuildContext context) {
return _DragDownToDismissKeyboard(child: _buildBar(context));
}
Widget _buildBar(BuildContext context) {
final trimmedDraft = controller.text.trim();
final collapsedText = trimmedDraft.isEmpty
? resolvedHint
: trimmedDraft.replaceAll(RegExp(r'\s+'), ' ');
final composerRadius =
Radii.dialog + Grid.quarter * (1 - expansionProgress);
return Container(
key: const ValueKey('composer-surface'),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(composerRadius),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (attachments.isNotEmpty) ...[
_AttachmentStrip(
attachments: attachments,
onRemove: onRemoveAttachment,
),
const SizedBox(height: Grid.xxs),
],
if (uploadError case final error?) ...[
Align(
alignment: Alignment.centerLeft,
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
const SizedBox(height: Grid.xxs),
],
// Keep the default state out of the focus system entirely so
// restored native focus cannot expand a newly opened channel.
if (isExpanded)
TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.send,
contextMenuBuilder: contextMenuBuilder,
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: _pastedImageMimeTypes,
onContentInserted: onContentInserted,
),
onSubmitted: (_) => onSend(),
minLines: 1,
maxLines: 5,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: resolvedHint,
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.half,
),
isDense: true,
),
)
else
Row(
children: [
_AttachmentTrigger(
surface: attachmentSurface,
formattingOpen: false,
onTap: onAttachmentTap,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Semantics(
button: true,
label: resolvedHint,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _runComposerAction(onExpand),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
collapsedText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: trimmedDraft.isEmpty
? context.colors.onSurfaceVariant
: context.colors.onSurface,
),
),
),
),
),
),
),
const SizedBox(width: Grid.xxs),
_SendButton(
isDisabled: !canSend || hasPendingUploads,
isSending: isSending,
onTap: onSend,
),
],
),
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: expansionValue,
child: IgnorePointer(
ignoring: !isExpanded,
child: Opacity(
opacity: expansionProgress,
child: Transform.translate(
offset: Offset(0, Grid.xxs * (1 - expansionProgress)),
child: Column(
children: [
const SizedBox(height: Grid.xxs),
Row(
children: [
_AttachmentTrigger(
surface: attachmentSurface,
formattingOpen: formattingOpen,
onTap: (triggerContext) {
if (formattingOpen) {
onCloseFormatting();
} else {
onAttachmentTap(triggerContext);
}
},
),
const SizedBox(width: Grid.half),
Expanded(
child: AnimatedSwitcher(
duration: motionDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder:
(currentChild, previousChildren) => Stack(
alignment: Alignment.centerLeft,
children: [
...previousChildren,
?currentChild,
],
),
child: formattingOpen
? _FormattingToolbar(onFormat: onFormat)
: Row(
key: const ValueKey('standard-actions'),
children: [
_ComposeAction(
icon: LucideIcons.atSign,
onTap: onMention,
),
_ComposeAction(
icon: LucideIcons.hash,
onTap: onChannel,
),
_ComposeAction(
icon: LucideIcons.smilePlus,
onTap: onEmoji,
),
_ComposeAction(
icon: LucideIcons.aLargeSmall,
onTap: onOpenFormatting,
),
const Spacer(),
_SendButton(
isDisabled:
!canSend || hasPendingUploads,
isSending: isSending,
onTap: onSend,
),
],
),
),
),
],
),
],
),
),
),
),
),
),
],
),
);
}
}
/// Drag the compose bar downward to put the keyboard away.
///
/// Continues the gesture the message list starts: once your finger reaches the
/// composer, keep pulling down and the keyboard goes with it. Uses a raw
/// [Listener] rather than a `GestureDetector` on purpose — a gesture recognizer
/// here would enter the arena against the `TextField` and could steal taps,
/// caret placement, and selection drags. A [Listener] only observes.
class _DragDownToDismissKeyboard extends HookWidget {
final Widget child;
const _DragDownToDismissKeyboard({required this.child});
@override
Widget build(BuildContext context) {
// A ref, not state: pointer travel must not rebuild the composer, which
// would churn the TextField mid-gesture.
final downwardTravel = useRef(0.0);
final startedInEditable = useRef(false);
return Listener(
onPointerDown: (event) {
downwardTravel.value = 0;
final hitTest = HitTestResult();
RendererBinding.instance.hitTestInView(
hitTest,
event.position,
event.viewId,
);
startedInEditable.value = hitTest.path.any(
(entry) => entry.target is RenderEditable,
);
},
onPointerCancel: (_) {
downwardTravel.value = 0;
startedInEditable.value = false;
},
onPointerUp: (_) {
downwardTravel.value = 0;
startedInEditable.value = false;
},
onPointerMove: (event) {
if (startedInEditable.value) return;
final dy = event.delta.dy;
if (dy <= 0) {
downwardTravel.value = 0;
return;
}
downwardTravel.value += dy;
if (downwardTravel.value < keyboardDismissDragThreshold) return;
downwardTravel.value = 0;
dismissKeyboard(context);
},
child: child,
);
}
}
@@ -0,0 +1,398 @@
part of '../compose_bar.dart';
enum _MarkdownStyle { codeBlock, inlineCode, bold, italic, strikethrough }
class _MarkdownRule {
final RegExp pattern;
final _MarkdownStyle style;
final bool parsesNestedFormatting;
_MarkdownRule(
String pattern,
this.style, {
this.parsesNestedFormatting = true,
}) : pattern = RegExp(pattern);
}
class _MarkdownEditingController extends TextEditingController {
final Set<String> _agentMentionNames = <String>{};
static final _rules = [
_MarkdownRule(
r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```',
_MarkdownStyle.codeBlock,
parsesNestedFormatting: false,
),
_MarkdownRule(
r'`([^`\n]*?)`',
_MarkdownStyle.inlineCode,
parsesNestedFormatting: false,
),
_MarkdownRule(r'\*\*([^\n]*?)\*\*', _MarkdownStyle.bold),
_MarkdownRule(r'~~([^\n]*?)~~', _MarkdownStyle.strikethrough),
_MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic),
];
/// Updates the known agent labels which should render as agent mention
/// chips. The editor still stores the literal `@Name` text, matching the
/// markdown sent to the relay.
void setAgentMentionNames(Iterable<String> names) {
final next = {
for (final name in names)
if (name.trim().isNotEmpty) name.trim().toLowerCase(),
};
if (setEquals(_agentMentionNames, next)) return;
_agentMentionNames
..clear()
..addAll(next);
notifyListeners();
}
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
final baseStyle = style ?? const TextStyle();
final composingRange =
withComposing && value.composing.isValid && !value.composing.isCollapsed
? value.composing
: TextRange.empty;
return TextSpan(
style: baseStyle,
children: _buildMarkdownSpans(
context,
text,
baseStyle,
sourceOffset: 0,
composingRange: composingRange,
),
);
}
List<InlineSpan> _buildMarkdownSpans(
BuildContext context,
String source,
TextStyle inheritedStyle, {
required int sourceOffset,
required TextRange composingRange,
}) {
final spans = <InlineSpan>[];
var offset = 0;
while (offset < source.length) {
final tail = source.substring(offset);
_MarkdownRule? nextRule;
RegExpMatch? nextMatch;
for (final rule in _rules) {
final match = rule.pattern.firstMatch(tail);
if (match == null) continue;
if (nextMatch == null || match.start < nextMatch.start) {
nextRule = rule;
nextMatch = match;
}
}
if (nextRule == null || nextMatch == null) {
spans.addAll(
_buildTextSpans(
context,
source.substring(offset),
inheritedStyle,
sourceOffset + offset,
composingRange,
),
);
break;
}
if (nextMatch.start > 0) {
spans.addAll(
_buildTextSpans(
context,
tail.substring(0, nextMatch.start),
inheritedStyle,
sourceOffset + offset,
composingRange,
),
);
}
final fullMatch = nextMatch.group(0)!;
final content = nextMatch.group(1)!;
final (contentStart, contentEnd) = _contentBounds(
fullMatch,
nextRule.style,
);
final contentStyle = _styleFor(context, inheritedStyle, nextRule.style);
final matchOffset = sourceOffset + offset + nextMatch.start;
spans.add(
TextSpan(
text: fullMatch.substring(0, contentStart),
style: _hiddenSyntaxStyle(inheritedStyle),
),
);
if (nextRule.parsesNestedFormatting) {
spans.addAll(
_buildMarkdownSpans(
context,
content,
contentStyle,
sourceOffset: matchOffset + contentStart,
composingRange: composingRange,
),
);
} else {
spans.addAll(
_buildTextSpans(
context,
content,
contentStyle,
matchOffset + contentStart,
composingRange,
renderAgentMentions: false,
),
);
}
spans.add(
TextSpan(
text: fullMatch.substring(contentEnd),
style: _hiddenSyntaxStyle(inheritedStyle),
),
);
offset += nextMatch.end;
}
return spans;
}
List<InlineSpan> _buildTextSpans(
BuildContext context,
String source,
TextStyle style,
int sourceOffset,
TextRange composingRange, {
bool renderAgentMentions = true,
}) {
List<InlineSpan> buildTextSegment(String text, TextStyle segmentStyle) =>
renderAgentMentions
? _buildAgentMentionSpans(context, text, segmentStyle)
: [TextSpan(text: text, style: segmentStyle)];
if (source.isEmpty) return const [];
final localStart = (composingRange.start - sourceOffset)
.clamp(0, source.length)
.toInt();
final localEnd = (composingRange.end - sourceOffset)
.clamp(0, source.length)
.toInt();
if (!composingRange.isValid ||
composingRange.isCollapsed ||
localStart >= localEnd) {
return buildTextSegment(source, style);
}
final composingDecorations = [
if (style.decoration != null && style.decoration != TextDecoration.none)
style.decoration!,
TextDecoration.underline,
];
final composingStyle = style.copyWith(
decoration: TextDecoration.combine(composingDecorations),
);
return [
if (localStart > 0)
...buildTextSegment(source.substring(0, localStart), style),
TextSpan(
text: source.substring(localStart, localEnd),
style: composingStyle,
),
if (localEnd < source.length)
...buildTextSegment(source.substring(localEnd), style),
];
}
List<InlineSpan> _buildAgentMentionSpans(
BuildContext context,
String source,
TextStyle style,
) {
if (_agentMentionNames.isEmpty) {
return [TextSpan(text: source, style: style)];
}
final escapedNames = _agentMentionNames.toList()
..sort((a, b) => b.length.compareTo(a.length));
final expression = RegExp(
r'(^|\s)@(' +
escapedNames.map(RegExp.escape).join('|') +
r')(?=\s|[,.!?:;)\]}*_]|$)',
caseSensitive: false,
multiLine: true,
);
final spans = <InlineSpan>[];
var offset = 0;
for (final match in expression.allMatches(source)) {
final prefix = match.group(1)!;
if (match.start > offset) {
spans.add(
TextSpan(text: source.substring(offset, match.start), style: style),
);
}
if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style));
final label = match.group(2)!;
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: _ComposerAgentMentionChip(label: label, textStyle: style),
),
);
// The visual chip replaces the `@` placeholder. Keep the label as
// invisible source text so the text span still has one character per
// source character, preserving native cursor and deletion behavior.
spans.add(
TextSpan(
text: label,
semanticsLabel: '',
style: _hiddenMentionTextStyle(style),
),
);
offset = match.end;
}
if (offset < source.length) {
spans.add(TextSpan(text: source.substring(offset), style: style));
}
return spans.isEmpty ? [TextSpan(text: source, style: style)] : spans;
}
TextStyle _hiddenMentionTextStyle(TextStyle inheritedStyle) =>
inheritedStyle.copyWith(
color: Colors.transparent,
fontSize: 0.01,
height: 0.01,
letterSpacing: 0,
decoration: TextDecoration.none,
);
(int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) {
final delimiterLength = switch (markdownStyle) {
_MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2,
_MarkdownStyle.italic || _MarkdownStyle.inlineCode => 1,
_MarkdownStyle.codeBlock => 3,
};
if (markdownStyle != _MarkdownStyle.codeBlock) {
return (delimiterLength, fullMatch.length - delimiterLength);
}
final openingLength = fullMatch.startsWith('```\r\n')
? 5
: fullMatch.startsWith('```\n')
? 4
: delimiterLength;
final closingLength = fullMatch.endsWith('\r\n```')
? 5
: fullMatch.endsWith('\n```')
? 4
: delimiterLength;
return (openingLength, fullMatch.length - closingLength);
}
TextStyle _styleFor(
BuildContext context,
TextStyle inheritedStyle,
_MarkdownStyle markdownStyle,
) {
return switch (markdownStyle) {
_MarkdownStyle.bold => inheritedStyle.merge(
const TextStyle(fontWeight: FontWeight.w700),
),
_MarkdownStyle.italic => inheritedStyle.merge(
const TextStyle(fontStyle: FontStyle.italic),
),
_MarkdownStyle.strikethrough => inheritedStyle.merge(
const TextStyle(decoration: TextDecoration.lineThrough),
),
_MarkdownStyle.inlineCode ||
_MarkdownStyle.codeBlock => inheritedStyle.merge(
TextStyle(
fontFamily: 'GeistMono',
color: context.colors.onSurface,
backgroundColor: context.colors.surface,
),
),
};
}
TextStyle _hiddenSyntaxStyle(TextStyle inheritedStyle) {
return inheritedStyle.copyWith(
color: Colors.transparent,
fontSize: 0.01,
height: 0.01,
letterSpacing: 0,
decoration: TextDecoration.none,
);
}
}
class _ComposerAgentMentionChip extends StatelessWidget {
final String label;
final TextStyle textStyle;
const _ComposerAgentMentionChip({
required this.label,
required this.textStyle,
});
@override
Widget build(BuildContext context) {
final style = textStyle.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
height: 1,
);
final fontSize = style.fontSize ?? 16;
return Semantics(
label: context.l10n.agentMentionLabel(label),
excludeSemantics: true,
child: Container(
key: const ValueKey('composer-agent-mention-chip'),
padding: const EdgeInsets.fromLTRB(
Grid.half,
Grid.quarter + 1,
Grid.half,
Grid.quarter,
),
decoration: BoxDecoration(
// The composer surface is already tinted, so the body chip's
// low-opacity fill disappears here. Keep the same chip geometry
// while giving this editable token enough contrast to read as one.
color: context.colors.primary.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: context.colors.primary.withValues(alpha: 0.12),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
LucideIcons.bot,
size: fontSize * 0.95,
color: context.colors.primary,
),
const SizedBox(width: Grid.quarter),
Text(label, style: style),
],
),
),
);
}
}
@@ -0,0 +1,398 @@
part of '../compose_bar.dart';
class _PhotoGalleryPicker extends StatelessWidget {
final VoidCallback onBack;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
const _PhotoGalleryPicker({
required this.onBack,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.onChooseAllPhotos,
});
@override
Widget build(BuildContext context) {
final fallback = _RecentPhotoGalleryPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
onChooseAllPhotos: onChooseAllPhotos,
);
if (defaultTargetPlatform != TargetPlatform.iOS) return fallback;
return _IOSInlinePhotoPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
onChooseAllPhotos: onChooseAllPhotos,
fallback: fallback,
);
}
}
class _RecentPhotoGalleryPicker extends HookConsumerWidget {
final VoidCallback onBack;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
const _RecentPhotoGalleryPicker({
required this.onBack,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.onChooseAllPhotos,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recentPhotos = useMemoized(
ref.read(photoLibraryProvider).loadRecentPhotos,
);
final recentSnapshot = useFuture(recentPhotos);
final selection = useState<List<RecentPhoto>>([]);
final isResolving = useState(false);
final actionError = useState<String?>(null);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
void togglePhoto(RecentPhoto photo) {
if (isResolving.value) return;
final current = selection.value;
final existingIndex = current.indexWhere((item) => item.id == photo.id);
selection.value = existingIndex < 0
? [...current, photo]
: [
...current.take(existingIndex),
...current.skip(existingIndex + 1),
];
}
Future<void> choosePhotos() async {
if (isResolving.value) return;
isResolving.value = true;
actionError.value = null;
try {
final photos = selection.value.isEmpty
? await onPickAllPhotos()
: await ref
.read(photoLibraryProvider)
.resolveSelectedPhotos(selection.value);
if (photos.isNotEmpty && context.mounted) {
await (selection.value.isEmpty ? onChooseAllPhotos : onChoosePhotos)(
photos,
);
}
} catch (_) {
if (context.mounted) {
actionError.value = selection.value.isEmpty
? context.l10n.unablePhotoLibrary
: context.l10n.preparingPhotos;
}
} finally {
if (context.mounted) isResolving.value = false;
}
}
final selectedCount = selection.value.length;
final actionLabel = selectedCount == 0
? context.l10n.allPhotos
: context.l10n.addPhoto(selectedCount);
Widget buildGalleryBody() {
if (recentSnapshot.connectionState != ConnectionState.done) {
return Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: context.l10n.loadingRecentPhotos,
),
);
}
if (recentSnapshot.hasError) {
return _PhotoGalleryMessage(
icon: LucideIcons.images,
title: context.l10n.recentPhotosUnavailableTitle,
message: context.l10n.recentPhotosUnavailableMessage,
);
}
final photos = recentSnapshot.data ?? const <RecentPhoto>[];
if (photos.isEmpty) {
return _PhotoGalleryMessage(
icon: LucideIcons.images,
title: context.l10n.noRecentPhotosTitle,
message: context.l10n.noRecentPhotosMessage,
);
}
return GridView.builder(
key: const ValueKey('recent-photo-grid'),
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: Grid.quarter,
mainAxisSpacing: Grid.quarter,
),
itemCount: photos.length,
itemBuilder: (context, index) {
final photo = photos[index];
final selectionIndex = selection.value.indexWhere(
(item) => item.id == photo.id,
);
return _RecentPhotoTile(
photo: photo,
selectionIndex: selectionIndex,
reducedMotion: reducedMotion,
onTap: () => _runComposerAction(() => togglePhoto(photo)),
);
},
);
}
return Padding(
key: const ValueKey('photo-gallery-picker'),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 40,
child: Row(
children: [
IconButton(
key: const ValueKey('photo-gallery-back'),
onPressed: isResolving.value
? null
: () => _runComposerAction(onBack),
tooltip: context.l10n.backAttachmentOptions,
visualDensity: VisualDensity.compact,
icon: const Icon(LucideIcons.arrowLeft, size: 20),
),
const SizedBox(width: Grid.quarter),
Expanded(
child: Text(
context.l10n.recentPhotos,
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
if (selectedCount > 0)
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: Text(
context.l10n.selectedCount(selectedCount),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: Grid.half),
Expanded(
child: Column(
children: [
Expanded(child: buildGalleryBody()),
if (actionError.value case final error?) ...[
const SizedBox(height: Grid.half),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 72),
child: SingleChildScrollView(
child: Text(
error,
key: const ValueKey('photo-gallery-error'),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
textAlign: TextAlign.center,
),
),
),
],
],
),
),
const SizedBox(height: Grid.xxs),
SizedBox(
width: double.infinity,
child: selectedCount == 0
? OutlinedButton.icon(
key: const ValueKey('photo-gallery-action'),
onPressed: isResolving.value
? null
: () => _runComposerAction(
() => unawaited(choosePhotos()),
),
icon: isResolving.value
? BuzzLoadingIndicator(
size: 22,
color: context.colors.primary,
semanticLabel: context.l10n.openingAllPhotos,
)
: const Icon(LucideIcons.images, size: 18),
label: Text(actionLabel),
)
: FilledButton.icon(
key: const ValueKey('photo-gallery-action'),
onPressed: isResolving.value
? null
: () => _runComposerAction(
() => unawaited(choosePhotos()),
),
icon: isResolving.value
? BuzzLoadingIndicator(
size: 22,
color: Colors.white,
semanticLabel: context.l10n.preparingPhotos,
)
: const Icon(LucideIcons.plus, size: 18),
label: Text(actionLabel),
),
),
],
),
);
}
}
class _RecentPhotoTile extends StatelessWidget {
final RecentPhoto photo;
final int selectionIndex;
final bool reducedMotion;
final VoidCallback onTap;
const _RecentPhotoTile({
required this.photo,
required this.selectionIndex,
required this.reducedMotion,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final isSelected = selectionIndex >= 0;
return Semantics(
button: true,
selected: isSelected,
label: isSelected
? context.l10n.photoSelected(selectionIndex + 1)
: context.l10n.selectPhoto,
child: GestureDetector(
key: ValueKey('recent-photo-${photo.id}'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm),
child: Image.memory(
photo.thumbnailBytes,
fit: BoxFit.cover,
gaplessPlayback: true,
),
),
AnimatedContainer(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: isSelected
? context.colors.primary
: Colors.transparent,
width: isSelected ? 3 : 0,
),
color: isSelected
? Colors.black.withValues(alpha: 0.08)
: Colors.transparent,
),
),
PositionedDirectional(
top: Grid.half,
end: Grid.half,
child: AnimatedScale(
scale: isSelected ? 1 : 0.8,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedOpacity(
opacity: isSelected ? 1 : 0,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 100),
child: Container(
key: ValueKey('photo-selection-index-${photo.id}'),
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.primary,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
child: Text(
'${selectionIndex + 1}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimary,
fontWeight: FontWeight.w700,
),
),
),
),
),
),
],
),
),
);
}
}
class _PhotoGalleryMessage extends StatelessWidget {
final IconData icon;
final String title;
final String message;
const _PhotoGalleryMessage({
required this.icon,
required this.title,
required this.message,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 32, color: context.colors.onSurfaceVariant),
const SizedBox(height: Grid.xxs),
Text(
title,
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: Grid.quarter),
Text(
message,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
@@ -0,0 +1,49 @@
part of '../compose_bar.dart';
class _SendButton extends StatelessWidget {
final bool isSending;
final bool isDisabled;
final VoidCallback onTap;
const _SendButton({
required this.isSending,
required this.onTap,
this.isDisabled = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: (isSending || isDisabled)
? null
: () => _runComposerAction(onTap),
style: IconButton.styleFrom(
backgroundColor: context.colors.primary,
disabledBackgroundColor: context.colors.primary.withValues(
alpha: 0.5,
),
shape: const CircleBorder(),
),
padding: EdgeInsets.zero,
icon: isSending
? BuzzLoadingIndicator(
size: 18,
color: context.colors.onPrimary,
semanticLabel: context.l10n.sendingMessage,
)
: Icon(
LucideIcons.arrowUp,
size: 18,
color: context.colors.onPrimary,
),
),
);
}
}
String _formatUploadError(Object error) {
return error.toString().replaceFirst('Exception: ', '');
}
@@ -0,0 +1,308 @@
part of '../compose_bar.dart';
class _SuggestionPanelMotion extends HookWidget {
final Duration duration;
final Alignment alignment;
final Widget child;
const _SuggestionPanelMotion({
required this.duration,
required this.alignment,
required this.child,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final springController = useAnimationController(
initialValue: 1,
upperBound: 1.08,
);
final springValue = useAnimation(springController);
final previousChildKey = useRef<Key?>(child.key);
useEffect(() {
if (previousChildKey.value == child.key) return null;
previousChildKey.value = child.key;
if (reducedMotion) {
springController.value = 1;
} else {
springController
..stop()
..value = 0.9
..animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 320),
bounce: 0.18,
),
0.9,
1,
0,
snapToEnd: true,
),
);
}
return null;
}, [child.key, reducedMotion]);
return Transform.scale(
scale: springValue,
alignment: alignment,
child: AnimatedSize(
duration: duration,
curve: Curves.easeInOutCubic,
alignment: alignment,
child: AnimatedSwitcher(
duration: duration,
reverseDuration: duration,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: alignment,
clipBehavior: Clip.none,
children: [...previousChildren, ?currentChild],
),
transitionBuilder: (child, animation) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeInOutCubic,
);
return AnimatedBuilder(
animation: curvedAnimation,
child: child,
builder: (context, child) => IgnorePointer(
ignoring: animation.status == AnimationStatus.reverse,
child: Opacity(
opacity: animation.value.clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, Grid.xs * (1 - animation.value)),
child: Transform.scale(
scale: 0.92 + (0.08 * curvedAnimation.value),
alignment: alignment,
child: child,
),
),
),
),
);
},
child: child,
),
),
);
}
}
class _MentionSuggestions extends StatelessWidget {
final List<MentionCandidate> suggestions;
final Map<String, UserProfile> userCache;
final String? currentPubkey;
final bool isDmChannel;
final void Function(MentionCandidate) onSelect;
const _MentionSuggestions({
required this.suggestions,
required this.userCache,
required this.currentPubkey,
required this.isDmChannel,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('mention-suggestions-popover'),
type: MaterialType.card,
color: appPopoverColor(context),
surfaceTintColor: Colors.transparent,
elevation: appPopoverElevation,
shadowColor: appPopoverShadowColor(context),
shape: appPopoverShape(context),
clipBehavior: Clip.hardEdge,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final candidate = suggestions[index];
final name = candidate.label;
final avatarUrl =
candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl;
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: AvatarImage(
imageUrl: avatarUrl,
radius: 18,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
name[0].toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
title: Text(name, style: context.textTheme.titleSmall),
subtitle: _MentionSuggestionInfo.build(
context,
candidate: candidate,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
userCache: userCache,
),
onTap: () => _runComposerAction(() => onSelect(candidate)),
);
},
),
),
);
}
}
/// The secondary info line under a mention suggestion — mirrors desktop's
/// `MentionAutocomplete` subtitle: bot icon + "agent" (or an "admin" badge
/// for human admins), then "managed by …" / "not in channel".
abstract final class _MentionSuggestionInfo {
static Widget? build(
BuildContext context, {
required MentionCandidate candidate,
required String? currentPubkey,
required bool isDmChannel,
required Map<String, UserProfile> userCache,
}) {
final ownerLabel = candidate.isAgent
? formatOwnerLabel(
candidate.ownerPubkey,
currentPubkey,
userCache,
currentUserLabel: context.l10n.you,
)
: null;
final notInChannel = !isDmChannel && !candidate.isMember;
final isAdmin = !candidate.isAgent && candidate.role == 'admin';
final String? detail;
if (ownerLabel != null && notInChannel) {
detail = context.l10n.managedByNotInChannel(ownerLabel);
} else if (ownerLabel != null) {
detail = context.l10n.managedBy(ownerLabel);
} else if (notInChannel) {
detail = context.l10n.notInChannel;
} else {
detail = null;
}
if (!candidate.isAgent && !isAdmin && detail == null) return null;
final style = context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
);
return Row(
children: [
if (candidate.isAgent) ...[
Icon(
LucideIcons.bot,
size: 12,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Text(context.l10n.agent, style: style),
] else if (isAdmin)
Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: 1,
),
decoration: BoxDecoration(
color: context.colors.secondaryContainer,
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Text(
context.l10n.adminRole,
style: style?.copyWith(
color: context.colors.onSecondaryContainer,
),
),
),
if (detail != null) ...[
if (candidate.isAgent || isAdmin) const SizedBox(width: Grid.xxs),
Flexible(
child: Text(detail, style: style, overflow: TextOverflow.ellipsis),
),
],
],
);
}
}
@visibleForTesting
List<Channel> filterChannels(List<Channel> channels, String? query) {
if (query == null) return const [];
final q = query.toLowerCase();
return channels
.where((c) => c.channelType != 'dm')
.where((c) {
if (q.isEmpty) return true;
return c.name.toLowerCase().contains(q);
})
.take(8)
.toList();
}
class _ChannelSuggestions extends StatelessWidget {
final List<Channel> suggestions;
final void Function(Channel) onSelect;
const _ChannelSuggestions({
required this.suggestions,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('channel-suggestions-popover'),
type: MaterialType.card,
color: appPopoverColor(context),
surfaceTintColor: Colors.transparent,
elevation: appPopoverElevation,
shadowColor: appPopoverShadowColor(context),
shape: appPopoverShape(context),
clipBehavior: Clip.hardEdge,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final channel = suggestions[index];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
horizontalTitleGap: 0,
leading: SizedBox.square(
dimension: 36,
child: Icon(
LucideIcons.hash,
size: 20,
color: context.colors.onSurfaceVariant,
),
),
title: Text(channel.name, style: context.textTheme.bodyLarge),
onTap: () => _runComposerAction(() => onSelect(channel)),
);
},
),
),
);
}
}
@@ -0,0 +1,197 @@
part of '../compose_bar.dart';
class _UploadProgressMotion extends StatelessWidget {
final bool visible;
final double progress;
final bool reducedMotion;
final VoidCallback onCancel;
const _UploadProgressMotion({
required this.visible,
required this.progress,
required this.reducedMotion,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
key: const ValueKey('compose-upload-progress-motion'),
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 220),
reverseDuration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
transitionBuilder: (child, animation) {
final motion = CurvedAnimation(
parent: animation,
curve: Curves.easeInCubic,
reverseCurve: Curves.easeOutCubic,
);
return ClipRect(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1.15),
end: Offset.zero,
).animate(motion),
child: child,
),
);
},
child: visible
? Padding(
key: const ValueKey('compose-upload-progress-visible'),
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: _UploadProgressPill(
progress: progress,
reducedMotion: reducedMotion,
onCancel: onCancel,
),
)
: const SizedBox.shrink(
key: ValueKey('compose-upload-progress-hidden'),
),
);
}
}
/// A lightweight post-send upload status that stays above the composer, so the
/// composer is immediately available for the next message.
class _UploadProgressPill extends HookConsumerWidget {
final double progress;
final bool reducedMotion;
final VoidCallback onCancel;
const _UploadProgressPill({
required this.progress,
required this.reducedMotion,
required this.onCancel,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cancelHovered = useState(false);
final clampedProgress = progress.clamp(0.0, 1.0).toDouble();
final percentage = (clampedProgress * 100).round();
return Semantics(
liveRegion: true,
label: context.l10n.uploadingPercent(percentage),
child: SizedBox(
key: const ValueKey('compose-upload-progress'),
width: 300,
height: 36,
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.full),
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.full),
),
child: Stack(
fit: StackFit.expand,
children: [
Align(
alignment: Alignment.centerLeft,
child: AnimatedFractionallySizedBox(
key: const ValueKey('compose-upload-progress-fill'),
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
widthFactor: clampedProgress,
heightFactor: 1,
child: ColoredBox(
color: context.colors.primary.withValues(
alpha: context.theme.brightness == Brightness.dark
? 0.15
: 0.12,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
left: Grid.twelve,
right: Grid.half,
),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.uploading,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(
width: 36,
child: FittedBox(
alignment: Alignment.centerRight,
fit: BoxFit.scaleDown,
child: Text(
'$percentage%',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: Grid.xxs),
Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
child: MouseRegion(
onEnter: (_) => cancelHovered.value = true,
onExit: (_) => cancelHovered.value = false,
child: Material(
color: cancelHovered.value
? context.colors.onSurface.withValues(
alpha: 0.08,
)
: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.full),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: const ValueKey('compose-upload-cancel'),
onTap: onCancel,
borderRadius: BorderRadius.circular(Radii.full),
child: Padding(
key: const ValueKey(
'compose-upload-cancel-padding',
),
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.quarter,
),
child: Text(
context.l10n.cancelUpload,
style: context.textTheme.labelMedium
?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
],
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Reports the laid-out height of a floating composer dock.
///
/// Message timelines use that height as scroll padding while still painting
/// beneath the dock, which lets the dock's fade reveal real timeline content.
class ComposerDockSizeReporter extends HookWidget {
final ValueChanged<double> onHeightChanged;
final Widget child;
const ComposerDockSizeReporter({
super.key,
required this.onHeightChanged,
required this.child,
});
@override
Widget build(BuildContext context) {
final sizeKey = useMemoized(GlobalKey.new);
final lastHeight = useRef<double?>(null);
void reportHeight() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final renderObject = sizeKey.currentContext?.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) return;
final height = renderObject.size.height;
final previous = lastHeight.value;
if (previous != null && (previous - height).abs() < 0.5) return;
lastHeight.value = height;
onHeightChanged(height);
});
}
useEffect(() {
reportHeight();
return null;
}, const []);
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (_) {
reportHeight();
return true;
},
child: SizeChangedLayoutNotifier(
child: KeyedSubtree(key: sizeKey, child: child),
),
);
}
}
@@ -0,0 +1,189 @@
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import '../../shared/l10n/l10n.dart';
// Re-export shortPubkey so existing callers continue to compile.
export '../../shared/utils/string_utils.dart' show shortPubkey;
final _fullDateFormat = DateFormat('EEEE, MMMM d, y');
final _shortMonthFormat = DateFormat('MMM');
final _messageTimeFormat = DateFormat('h:mm a', 'en_US');
/// Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026".
///
/// [now] is exposed for testing; production callers should omit it.
String formatDayHeading(
int unixSeconds, {
@visibleForTesting DateTime? now,
AppLocalizations? l10n,
}) {
final date = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
now ??= DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final messageDay = DateTime(date.year, date.month, date.day);
if (today.year == messageDay.year &&
today.month == messageDay.month &&
today.day == messageDay.day) {
return l10n?.timeToday ?? 'Today';
}
final yesterday = DateTime(now.year, now.month, now.day - 1);
if (yesterday.year == messageDay.year &&
yesterday.month == messageDay.month &&
yesterday.day == messageDay.day) {
return l10n?.timeYesterday ?? 'Yesterday';
}
if (l10n != null) {
return l10n.fullDate(
_weekdayLabel(l10n, date.weekday),
_monthLabel(l10n, date.month),
date.day,
date.year,
);
}
return _fullDateFormat.format(date);
}
/// Whether two unix-second timestamps fall on the same calendar day (local time).
bool isSameDay(int a, int b) {
final dtA = DateTime.fromMillisecondsSinceEpoch(
a * 1000,
isUtc: true,
).toLocal();
final dtB = DateTime.fromMillisecondsSinceEpoch(
b * 1000,
isUtc: true,
).toLocal();
return dtA.year == dtB.year && dtA.month == dtB.month && dtA.day == dtB.day;
}
/// Returns a compact relative time string like "just now", "5m ago", "3h ago",
/// "2d ago", or a short date for older timestamps.
String relativeTime(int unixSeconds, {AppLocalizations? l10n}) {
final now = DateTime.now();
final time = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
final diff = now.difference(time);
if (diff.inMinutes < 1) return l10n?.timeJustNow ?? 'just now';
if (diff.inMinutes < 60) {
return l10n == null
? '${diff.inMinutes}m ago'
: l10n.relativeMinutesAgo(diff.inMinutes);
}
if (diff.inHours < 24) {
return l10n == null
? '${diff.inHours}h ago'
: l10n.relativeHoursAgo(diff.inHours);
}
if (diff.inDays < 7) {
return l10n == null
? '${diff.inDays}d ago'
: l10n.relativeDaysAgo(diff.inDays);
}
if (l10n != null) {
return l10n.shortDate(
_monthLabel(l10n, time.month),
time.day,
);
}
return '${time.month}/${time.day}/${time.year}';
}
/// Returns desktop-parity thread activity copy such as "just now",
/// "3 hours ago", or "on May 19th".
String formatThreadSummaryLastReplyTime(
int unixSeconds, {
@visibleForTesting int? nowSeconds,
AppLocalizations? l10n,
}) {
nowSeconds ??= DateTime.now().millisecondsSinceEpoch ~/ 1000;
var diff = nowSeconds - unixSeconds;
if (diff < 0) diff = 0;
if (diff < 60) return l10n?.timeJustNow ?? 'just now';
if (diff < 3600) {
final value = diff ~/ 60;
return l10n == null ? _formatAgo(value, 'minute') : l10n.relativeMinutesAgo(value);
}
if (diff < 86400) {
final value = diff ~/ 3600;
return l10n == null ? _formatAgo(value, 'hour') : l10n.relativeHoursAgo(value);
}
if (diff < 604800) {
final value = diff ~/ 86400;
return l10n == null ? _formatAgo(value, 'day') : l10n.relativeDaysAgo(value);
}
final date = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
if (l10n != null) {
return l10n.threadOnDate(
_monthLabel(l10n, date.month),
date.day,
l10n.localeName == 'zh_CN' ? '' : _ordinalSuffix(date.day),
);
}
return 'on ${_shortMonthFormat.format(date)} '
'${date.day}${_ordinalSuffix(date.day)}';
}
String _formatAgo(int value, String unit) =>
'$value $unit${value == 1 ? '' : 's'} ago';
String _ordinalSuffix(int day) {
final lastTwoDigits = day % 100;
if (lastTwoDigits >= 11 && lastTwoDigits <= 13) return 'th';
return switch (day % 10) {
1 => 'st',
2 => 'nd',
3 => 'rd',
_ => 'th',
};
}
/// Desktop-parity message clock time, e.g. "2:34 PM".
String formatMessageTime(int unixSeconds, {AppLocalizations? l10n}) {
final date = DateTime.fromMillisecondsSinceEpoch(
unixSeconds * 1000,
isUtc: true,
).toLocal();
if (l10n == null) return _messageTimeFormat.format(date);
final hour = date.hour % 12 == 0 ? 12 : date.hour % 12;
final minute = date.minute.toString().padLeft(2, '0');
final period = date.hour < 12 ? l10n.timeAm : l10n.timePm;
return l10n.messageTime('$hour', minute, period);
}
String _monthLabel(AppLocalizations l10n, int month) => switch (month) {
1 => l10n.monthJan,
2 => l10n.monthFeb,
3 => l10n.monthMar,
4 => l10n.monthApr,
5 => l10n.monthMay,
6 => l10n.monthJun,
7 => l10n.monthJul,
8 => l10n.monthAug,
9 => l10n.monthSep,
10 => l10n.monthOct,
11 => l10n.monthNov,
_ => l10n.monthDec,
};
String _weekdayLabel(AppLocalizations l10n, int weekday) => switch (weekday) {
DateTime.monday => l10n.weekdayMonday,
DateTime.tuesday => l10n.weekdayTuesday,
DateTime.wednesday => l10n.weekdayWednesday,
DateTime.thursday => l10n.weekdayThursday,
DateTime.friday => l10n.weekdayFriday,
DateTime.saturday => l10n.weekdaySaturday,
_ => l10n.weekdaySunday,
};
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
/// Desktop-parity day separator with a centered label over a horizontal rule.
class DayDivider extends StatelessWidget {
final String label;
const DayDivider({super.key, required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: SizedBox(
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Positioned(
left: 0,
right: 0,
child: Divider(
height: 1,
thickness: 1,
color: context.colors.outlineVariant.withValues(alpha: 0.35),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs + Grid.quarter,
vertical: Grid.half,
),
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(
color: context.colors.outlineVariant.withValues(alpha: 0.7),
),
),
child: Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.7),
letterSpacing: 0.22,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/deeplink/pending_deep_link_provider.dart';
import '../../shared/l10n/l10n.dart';
import '../invites/invite_join_provider.dart';
import '../invites/invite_join_sheet.dart';
import 'channel.dart';
import 'channel_detail_page.dart';
import 'channels_provider.dart';
/// Routes pending `buzz://message` deep links into the channel view.
///
/// Wraps the authenticated home subtree. Whenever a parsed link is parked in
/// [pendingDeepLinkProvider] and the channel list is available, this pushes
/// the target [ChannelDetailPage] on the enclosing [Navigator]. Links are
/// held (not dropped) while channels are still loading, so cold-start links
/// dispatch as soon as the first channel fetch completes.
typedef DeepLinkDestinationBuilder =
Widget Function(Channel channel, MessageDeepLink link);
class DeepLinkDispatcher extends ConsumerStatefulWidget {
final Widget child;
final DeepLinkDestinationBuilder? destinationBuilder;
final bool dispatchMessageLinks;
const DeepLinkDispatcher({
super.key,
required this.child,
this.destinationBuilder,
this.dispatchMessageLinks = true,
});
@override
ConsumerState<DeepLinkDispatcher> createState() => _DeepLinkDispatcherState();
}
class _DeepLinkDispatcherState extends ConsumerState<DeepLinkDispatcher> {
bool _preparingInvite = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
}
@override
Widget build(BuildContext context) {
// Re-evaluate dispatch when either a new link arrives or channels load.
ref.listen<BuzzDeepLink?>(pendingDeepLinkProvider, (_, link) {
_maybeDispatch(link);
});
if (widget.dispatchMessageLinks) {
ref.listen<AsyncValue<List<Channel>>>(channelsProvider, (_, _) {
_maybeDispatch(ref.read(pendingDeepLinkProvider));
});
}
return widget.child;
}
void _maybeDispatch(BuzzDeepLink? link) {
if (link == null) return;
if (link is InviteDeepLink) {
_maybeDispatchInvite(link);
return;
}
if (link is! MessageDeepLink || !widget.dispatchMessageLinks) return;
final channels = ref.read(channelsProvider).asData?.value;
// Channels not loaded yet — keep the link parked; the channelsProvider
// listener re-attempts once data arrives.
if (channels == null) return;
ref.read(pendingDeepLinkProvider.notifier).consume();
final channel = channels
.where((c) => c.id == link.channelId)
.cast<Channel?>()
.firstOrNull;
if (channel == null) {
debugPrint(
'deep-link: channel ${link.channelId} not found in workspace; '
'dropping link',
);
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(context.l10n.channelNotFound)),
);
return;
}
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
widget.destinationBuilder?.call(channel, link) ??
ChannelDetailPage(
channel: channel,
initialMessageId: link.messageId,
initialThreadRootId: link.threadRootId,
),
),
);
}
void _maybeDispatchInvite(InviteDeepLink link) {
if (_preparingInvite) return;
_preparingInvite = true;
final navigatorContext = context;
final messenger = ScaffoldMessenger.maybeOf(context);
Future.microtask(() async {
try {
await ref.read(inviteJoinProvider.notifier).prepare(link);
ref.read(pendingDeepLinkProvider.notifier).consume();
if (!navigatorContext.mounted) return;
final status = ref.read(inviteJoinProvider).status;
if (status == InviteJoinStatus.confirming) {
showInviteJoinSheet(navigatorContext, ref);
} else if (status == InviteJoinStatus.switchedExisting) {
messenger?.showSnackBar(
SnackBar(content: Text(navigatorContext.l10n.switchedCommunity)),
);
}
} catch (error) {
debugPrint('deep-link: failed to prepare invite: $error');
if (navigatorContext.mounted) {
messenger?.showSnackBar(
SnackBar(content: Text(navigatorContext.l10n.inviteOpenFailed)),
);
}
} finally {
_preparingInvite = false;
}
});
}
}
@@ -0,0 +1,89 @@
import '../../l10n/app_localizations.dart';
import '../../shared/utils/string_utils.dart';
import 'channel.dart';
const int _dmParticipantPreviewLimit = 3;
bool isGenericDmChannelName(String name) {
final normalized = name.trim().toLowerCase();
if (normalized.isEmpty ||
normalized == 'dm' ||
normalized == 'direct message' ||
normalized == 'direct messages') {
return true;
}
return RegExp(r'^group dm\s*(\(\d+\))?$').hasMatch(normalized);
}
String formatDmParticipantDisplayName(
List<String> displayNames, {
AppLocalizations? l10n,
}) {
final visible = displayNames.take(_dmParticipantPreviewLimit).toList();
final hiddenCount = displayNames.length - visible.length;
return hiddenCount > 0
? [
...visible,
l10n?.dmMore(hiddenCount) ?? '+$hiddenCount more',
].join(', ')
: visible.join(', ');
}
String resolveDmChannelDisplayLabel(
Channel channel, {
String? currentPubkey,
AppLocalizations? l10n,
}) {
if (!channel.isDm || !isGenericDmChannelName(channel.name)) {
return channel.name;
}
final normalizedCurrent = currentPubkey?.toLowerCase();
final participants = <({String label, String? pubkey})>[
for (var index = 0; index < channel.participantPubkeys.length; index++)
(
label: index < channel.participants.length
? channel.participants[index]
: shortPubkey(channel.participantPubkeys[index]),
pubkey: channel.participantPubkeys[index].toLowerCase(),
),
];
final displayParticipants = normalizedCurrent == null
? participants
: participants
.where((participant) => participant.pubkey != normalizedCurrent)
.toList();
final labels = <String>{
for (final participant
in displayParticipants.isNotEmpty ? displayParticipants : participants)
participant.label,
}.toList();
return labels.isNotEmpty
? formatDmParticipantDisplayName(labels, l10n: l10n)
: channel.name;
}
List<Channel> sortDmChannelsByDisplayLabel(
Iterable<Channel> channels, {
String? currentPubkey,
}) {
final sorted = channels.toList();
sorted.sort((left, right) {
final leftLabel = resolveDmChannelDisplayLabel(
left,
currentPubkey: currentPubkey,
);
final rightLabel = resolveDmChannelDisplayLabel(
right,
currentPubkey: currentPubkey,
);
final labelCompare = leftLabel.toLowerCase().compareTo(
rightLabel.toLowerCase(),
);
if (labelCompare != 0) return labelCompare;
return left.id.compareTo(right.id);
});
return sorted;
}
@@ -0,0 +1,304 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/emoji/emoji_data.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/emoji/emoji_search.dart';
import '../../shared/emoji/native_emoji_glyph.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'recent_emoji_provider.dart';
part 'emoji_picker/search_field.dart';
part 'emoji_picker/category_rail.dart';
part 'emoji_picker/emoji_grid.dart';
/// Height of the picker sheet as a fraction of the screen. The full emoji set
/// is ~1.9k glyphs; the old fixed 340px sheet only ever showed a hand-picked
/// subset and had no room to browse.
const _sheetHeightFactor = 0.62;
/// Opens the full emoji picker as a modal bottom sheet.
///
/// [onSelect] receives a single string, normalized the same way desktop's
/// `EmojiPicker` normalizes its selection: a standard emoji emits its glyph, a
/// custom emoji emits `:shortcode:`. Callers store or send that string and let
/// the existing renderers resolve it.
void showEmojiPicker({
required BuildContext context,
required void Function(String emoji) onSelect,
VoidCallback? onDismiss,
}) {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: context.colors.surfaceContainerHighest,
builder: (sheetContext) => EmojiPickerSheet(
onSelect: (emoji) {
Navigator.of(sheetContext).pop();
onSelect(emoji);
},
),
).whenComplete(onDismiss ?? () {});
}
class EmojiPickerSheet extends HookConsumerWidget {
final void Function(String emoji) onSelect;
const EmojiPickerSheet({super.key, required this.onSelect});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
final customEmoji = ref.watch(customEmojiListProvider);
final recent = ref.watch(recentEmojiProvider);
final searchController = useTextEditingController();
final query = useState('');
useEffect(() {
void onChanged() => query.value = searchController.text;
searchController.addListener(onChanged);
return () => searchController.removeListener(onChanged);
}, [searchController]);
final trimmedQuery = query.value.trim();
final isSearching = trimmedQuery.isNotEmpty;
void select(String emoji) => onSelect(emoji);
final sections = useMemoized(
() => _buildSections(
l10n: context.l10n,
dataset: dataset,
customEmoji: customEmoji,
recent: recent,
onSelect: select,
),
[dataset, customEmoji, recent, context.l10n.localeName],
);
final offsets = useMemoized(() => _sectionOffsets(sections), [sections]);
final scrollController = useScrollController();
// A notifier rather than state: the highlight changes on every scroll frame
// and only the rail needs to hear about it. Rebuilding the sheet would
// rebuild the grid underneath it.
final activeSection = useMemoized(() => ValueNotifier(0), [sections]);
useEffect(() => activeSection.dispose, [activeSection]);
useEffect(() {
void onScroll() {
if (!scrollController.hasClients) return;
activeSection.value = _activeSectionIndex(
offsets,
scrollController.offset,
);
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController, offsets, activeSection]);
void jumpToSection(int index) {
activeSection.value = index;
if (!scrollController.hasClients) return;
final max = scrollController.position.maxScrollExtent;
scrollController.animateTo(
offsets[index].clamp(0.0, max),
duration: const Duration(milliseconds: 240),
curve: Curves.easeOutCubic,
);
}
// Recompute only when the query or the underlying sets change — scanning
// ~1.9k entries per keystroke is sub-millisecond, but rebuilds are frequent
// while the sheet animates.
final results = useMemoized(
() => isSearching
? searchEmoji(trimmedQuery, dataset.all)
: const <EmojiEntry>[],
[trimmedQuery, dataset],
);
final customResults = useMemoized(
() => isSearching
? rankByShortcode(
trimmedQuery,
customEmoji,
(emoji) => emoji.shortcode,
)
: const <CustomEmoji>[],
[trimmedQuery, customEmoji],
);
return SizedBox(
height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
child: Column(
children: [
_EmojiSearchField(controller: searchController),
if (!isSearching && sections.isNotEmpty)
ValueListenableBuilder<int>(
valueListenable: activeSection,
builder: (context, active, _) => _CategoryRail(
sections: sections,
activeIndex: active,
onSelect: jumpToSection,
),
),
Divider(height: 1, color: context.colors.outlineVariant),
Expanded(
child: dataset.isEmpty && customEmoji.isEmpty
? const Center(child: CircularProgressIndicator())
: isSearching
? _EmojiSearchResults(
entries: results,
customEmoji: customResults,
onSelect: select,
)
: _ContinuousEmojiGrid(
sections: sections,
controller: scrollController,
),
),
],
),
);
}
}
/// Build the scroll order: frequently-used, the dataset's own categories in
/// emoji-mart order, then the community's custom emoji.
///
/// Frequently-used is omitted rather than shown empty — in a continuous list an
/// empty section is a gap with a label on it, and the rail entry would lead
/// nowhere.
List<_EmojiSection> _buildSections({
required AppLocalizations l10n,
required EmojiDataset dataset,
required List<CustomEmoji> customEmoji,
required List<RecentEmojiEntry> recent,
required void Function(String emoji) onSelect,
}) {
final sections = <_EmojiSection>[];
final recentTiles = _resolveRecentTiles(
recent: recent,
dataset: dataset,
customEmoji: customEmoji,
onSelect: onSelect,
);
if (recentTiles.isNotEmpty) {
sections.add(
_EmojiSection(
id: 'frequent',
label: l10n.emojiFrequentlyUsed,
icon: LucideIcons.clock,
itemCount: recentTiles.length,
itemBuilder: (context, index) => recentTiles[index],
),
);
}
for (final category in dataset.categories) {
sections.add(
_EmojiSection(
id: category.id,
label: _localizedEmojiCategoryLabel(l10n, category),
icon: _categoryIcon(category.id),
itemCount: category.emoji.length,
itemBuilder: (context, index) {
final entry = category.emoji[index];
return _EmojiTile(entry: entry, onTap: () => onSelect(entry.native));
},
),
);
}
if (customEmoji.isNotEmpty) {
sections.add(
_EmojiSection(
id: 'custom',
label: l10n.emojiCustom,
icon: LucideIcons.sparkles,
itemCount: customEmoji.length,
itemBuilder: (context, index) {
final entry = customEmoji[index];
return _CustomEmojiTile(
emoji: entry,
onTap: () => onSelect(':${entry.shortcode}:'),
);
},
),
);
}
return sections;
}
String _localizedEmojiCategoryLabel(
AppLocalizations l10n,
EmojiCategory category,
) => switch (category.id) {
'people' => l10n.emojiPeople,
'nature' => l10n.emojiNature,
'foods' => l10n.emojiFoods,
'activity' => l10n.emojiActivity,
'places' => l10n.emojiPlaces,
'objects' => l10n.emojiObjects,
'symbols' => l10n.emojiSymbols,
'flags' => l10n.emojiFlags,
_ => category.label,
};
/// Resolve the recency list back to renderable tiles.
///
/// Entries are stored as the selected string, so a standard emoji is a glyph and
/// a custom one is `:shortcode:` — resolve each against the dataset and the
/// palette, and drop anything that no longer exists (a custom emoji removed from
/// the community would otherwise render as literal text).
List<Widget> _resolveRecentTiles({
required List<RecentEmojiEntry> recent,
required EmojiDataset dataset,
required List<CustomEmoji> customEmoji,
required void Function(String emoji) onSelect,
}) {
final customByShortcode = {
for (final emoji in customEmoji) emoji.shortcode.toLowerCase(): emoji,
};
final entriesByNative = {
for (final entry in dataset.all) entry.native: entry,
};
final tiles = <Widget>[];
for (final item in recent) {
final value = item.emoji;
if (value.startsWith(':') && value.endsWith(':')) {
final custom =
customByShortcode[value.substring(1, value.length - 1).toLowerCase()];
if (custom == null) continue;
tiles.add(
_CustomEmojiTile(
emoji: custom,
onTap: () => onSelect(value),
keyPrefix: 'emoji-tile-frequent-custom',
),
);
continue;
}
final entry = entriesByNative[value];
if (entry == null) continue;
tiles.add(
_EmojiTile(
entry: entry,
onTap: () => onSelect(entry.native),
keyPrefix: 'emoji-tile-frequent',
),
);
}
return tiles;
}
@@ -0,0 +1,98 @@
part of '../emoji_picker.dart';
/// Rail icon for each emoji-mart category. Ids come from the dataset, so this
/// map is keyed on emoji-mart's own category ids.
IconData _categoryIcon(String categoryId) => switch (categoryId) {
'people' => LucideIcons.smile,
'nature' => LucideIcons.leaf,
'foods' => LucideIcons.coffee,
'activity' => LucideIcons.volleyball,
'places' => LucideIcons.plane,
'objects' => LucideIcons.lightbulb,
'symbols' => LucideIcons.heart,
'flags' => LucideIcons.flag,
_ => LucideIcons.layoutGrid,
};
/// Height of the rail. Sized to a comfortable tap target rather than to the
/// 18px icon it holds.
const _railHeight = 36.0;
/// Category selector: one icon per section of the continuous grid, in scroll
/// order. Tapping jumps to that section; scrolling moves the highlight.
///
/// The icons divide the full content width evenly — the same width the search
/// field above spans — so the rail reads as a segmented control rather than a
/// short left-aligned strip.
class _CategoryRail extends StatelessWidget {
final List<_EmojiSection> sections;
final int activeIndex;
final ValueChanged<int> onSelect;
const _CategoryRail({
required this.sections,
required this.activeIndex,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: _railHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Row(
children: [
for (var i = 0; i < sections.length; i++)
Expanded(
child: _CategoryIcon(
icon: sections[i].icon,
tooltip: sections[i].label,
selected: i == activeIndex,
onTap: () => onSelect(i),
),
),
],
),
),
);
}
}
class _CategoryIcon extends StatelessWidget {
final IconData icon;
final String tooltip;
final bool selected;
final VoidCallback onTap;
const _CategoryIcon({
required this.icon,
required this.tooltip,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.colors;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(Radii.sm),
child: Semantics(
button: true,
selected: selected,
label: tooltip,
child: Center(
child: Icon(
icon,
size: 18,
color: selected ? colors.primary : colors.onSurfaceVariant,
),
),
),
),
);
}
}
@@ -0,0 +1,344 @@
part of '../emoji_picker.dart';
/// Emoji per row. Matches desktop's `perLine={8}`.
const _emojiPerLine = 8;
/// Rendered glyph size. Standard and custom emoji share this so a community's
/// own emoji sit in the grid at the same weight as the native set.
const _emojiGlyphSize = 28.0;
/// Height of one grid cell. Fixed rather than derived from the cell width so
/// section offsets are exact — [_EmojiSection.extent] is what the rail scrolls
/// to, and a width-dependent row height would make it a guess.
const _cellExtent = 40.0;
/// Height of a pinned section header, padding included. Also fixed, for the
/// same reason.
const _sectionHeaderExtent = 28.0;
/// Cells divide the content width evenly with no gaps, the way emoji-mart lays
/// its grid out; the space around each glyph comes from the cell being larger
/// than the glyph.
SliverGridDelegate get _gridDelegate =>
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _emojiPerLine,
mainAxisExtent: _cellExtent,
);
/// One labelled run of tiles in the continuous grid.
///
/// Sections know their own scroll extent so the rail can jump to them without
/// the grid having been laid out — with ~1.9k emoji most sections are far
/// off-screen, so there is no render object to measure or `ensureVisible`.
class _EmojiSection {
final String id;
final String label;
final IconData icon;
final int itemCount;
final Widget Function(BuildContext context, int index) itemBuilder;
const _EmojiSection({
required this.id,
required this.label,
required this.icon,
required this.itemCount,
required this.itemBuilder,
});
int get rowCount => (itemCount / _emojiPerLine).ceil();
double get extent => _sectionHeaderExtent + rowCount * _cellExtent;
}
/// Offset of each section's header, in scroll order. Element `i` is where the
/// rail should land for section `i`.
List<double> _sectionOffsets(List<_EmojiSection> sections) {
final offsets = <double>[];
var running = 0.0;
for (final section in sections) {
offsets.add(running);
running += section.extent;
}
return offsets;
}
/// Which section owns [offset] — the one whose header is pinned right now.
int _activeSectionIndex(List<double> offsets, double offset) {
var active = 0;
for (var i = 0; i < offsets.length; i++) {
// Half a header of slack so the highlight flips as a header reaches the
// top rather than a pixel before.
if (offsets[i] <= offset + _sectionHeaderExtent / 2) active = i;
}
return active;
}
/// One tappable standard emoji.
///
/// [keyPrefix] exists because the frequently-used section repeats emoji that
/// also appear in their own category, and both are in the same scroll view — two
/// widgets keyed `emoji-tile-fire` would be indistinguishable to a test.
class _EmojiTile extends StatelessWidget {
final EmojiEntry entry;
final VoidCallback onTap;
final String keyPrefix;
const _EmojiTile({
required this.entry,
required this.onTap,
this.keyPrefix = 'emoji-tile',
});
@override
Widget build(BuildContext context) {
return GestureDetector(
key: ValueKey('$keyPrefix-${entry.tileId}'),
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Semantics(
button: true,
label: entry.name,
child: Center(
child: NativeEmojiGlyph(emoji: entry.native, size: _emojiGlyphSize),
),
),
);
}
}
/// One tappable custom (image) emoji. Same cell and same glyph size as
/// [_EmojiTile] — a community's emoji shouldn't be laid out on its own looser
/// grid, which is what made the custom tab read as a different component.
class _CustomEmojiTile extends StatelessWidget {
final CustomEmoji emoji;
final VoidCallback onTap;
final String keyPrefix;
const _CustomEmojiTile({
required this.emoji,
required this.onTap,
this.keyPrefix = 'emoji-tile-custom',
});
@override
Widget build(BuildContext context) {
return GestureDetector(
key: ValueKey('$keyPrefix-${emoji.shortcode}'),
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Tooltip(
message: ':${emoji.shortcode}:',
child: Center(
child: CustomEmojiImage(
shortcode: emoji.shortcode,
url: emoji.url,
size: _emojiGlyphSize,
),
),
),
);
}
}
/// Every section in one scroll view, headers pinning as they reach the top.
///
/// Replaces the old tab-per-category swap: the rail is a shortcut into a single
/// list, not a set of separate pages, which is how both desktop and every
/// system emoji keyboard behave.
class _ContinuousEmojiGrid extends StatelessWidget {
final List<_EmojiSection> sections;
final ScrollController controller;
const _ContinuousEmojiGrid({
required this.sections,
required this.controller,
});
@override
Widget build(BuildContext context) {
return CustomScrollView(
key: const ValueKey('emoji-picker-grid'),
controller: controller,
slivers: [
for (final section in sections)
SliverMainAxisGroup(
key: ValueKey('emoji-section-${section.id}'),
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _SectionHeaderDelegate(section.label),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
sliver: SliverGrid.builder(
gridDelegate: _gridDelegate,
itemCount: section.itemCount,
itemBuilder: section.itemBuilder,
),
),
],
),
// Clear the home indicator so the last row isn't half-swallowed. Sits
// outside the sections so it doesn't skew their offsets.
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
),
),
],
);
}
}
/// Search results: custom emoji first (a community's own emoji are the ones a
/// user is most likely hunting for by name), then the standard set.
class _EmojiSearchResults extends StatelessWidget {
final List<EmojiEntry> entries;
final List<CustomEmoji> customEmoji;
final void Function(String emoji) onSelect;
const _EmojiSearchResults({
required this.entries,
required this.customEmoji,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
if (entries.isEmpty && customEmoji.isEmpty) {
return _EmojiEmptyState(
icon: LucideIcons.searchX,
message: context.l10n.emojiNoResults,
);
}
return CustomScrollView(
key: const ValueKey('emoji-picker-search-results'),
slivers: [
if (customEmoji.isNotEmpty) ...[
_SectionHeader(label: context.l10n.emojiCustom),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
sliver: SliverGrid.builder(
gridDelegate: _gridDelegate,
itemCount: customEmoji.length,
itemBuilder: (context, index) {
final entry = customEmoji[index];
return _CustomEmojiTile(
emoji: entry,
onTap: () => onSelect(':${entry.shortcode}:'),
);
},
),
),
],
if (entries.isNotEmpty) ...[
if (customEmoji.isNotEmpty)
_SectionHeader(label: context.l10n.emojiStandard),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
sliver: SliverGrid.builder(
gridDelegate: _gridDelegate,
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
return _EmojiTile(
entry: entry,
onTap: () => onSelect(entry.native),
);
},
),
),
],
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
),
),
],
);
}
}
/// Pinned header for a section of the continuous grid. Opaque, so rows scroll
/// under it rather than showing through.
class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
final String label;
const _SectionHeaderDelegate(this.label);
@override
double get minExtent => _sectionHeaderExtent;
@override
double get maxExtent => _sectionHeaderExtent;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Container(
color: context.colors.surfaceContainerHighest,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Text(label, style: _sectionLabelStyle(context)),
);
}
@override
bool shouldRebuild(_SectionHeaderDelegate oldDelegate) =>
oldDelegate.label != label;
}
/// Non-pinned header, for the search results list.
class _SectionHeader extends StatelessWidget {
final String label;
const _SectionHeader({required this.label});
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
height: _sectionHeaderExtent,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Text(label, style: _sectionLabelStyle(context)),
),
);
}
}
TextStyle? _sectionLabelStyle(BuildContext context) =>
context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
class _EmojiEmptyState extends StatelessWidget {
final IconData icon;
final String message;
const _EmojiEmptyState({required this.icon, required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: Grid.md, color: context.colors.onSurfaceVariant),
const SizedBox(height: Grid.xxs),
Text(
message,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}
@@ -0,0 +1,78 @@
part of '../emoji_picker.dart';
/// Search field at the top of the picker sheet.
///
/// Desktop reaches into emoji-mart's shadow DOM to turn off spellcheck,
/// autocorrect, and autocapitalize on its search input (see
/// `disableSearchInputCorrections` in `EmojiPicker.tsx`) — shortcodes are not
/// words and the OS mangles them. Flutter exposes the same switches directly.
class _EmojiSearchField extends StatelessWidget {
final TextEditingController controller;
const _EmojiSearchField({required this.controller});
@override
Widget build(BuildContext context) {
final colors = context.colors;
return Padding(
padding: const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xxs),
child: TextField(
key: const ValueKey('emoji-picker-search'),
controller: controller,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.search,
style: searchInputTextStyle.copyWith(color: colors.onSurface),
decoration: InputDecoration(
hintText: context.l10n.emojiSearch,
hintStyle: searchInputTextStyle.copyWith(
color: colors.onSurfaceVariant,
),
prefixIcon: Icon(
LucideIcons.search,
size: 18,
color: colors.onSurfaceVariant,
),
prefixIconConstraints: const BoxConstraints(
minWidth: Grid.md,
minHeight: Grid.md,
),
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
if (value.text.isEmpty) return const SizedBox.shrink();
return IconButton(
key: const ValueKey('emoji-picker-search-clear'),
onPressed: controller.clear,
icon: Icon(
LucideIcons.x,
size: 16,
color: colors.onSurfaceVariant,
),
visualDensity: VisualDensity.compact,
tooltip: context.l10n.emojiClearSearch,
);
},
),
filled: true,
fillColor: colors.surface,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.lg),
borderSide: BorderSide(color: colors.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.lg),
borderSide: BorderSide(color: colors.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.lg),
borderSide: BorderSide(color: colors.primary),
),
),
),
);
}
}
@@ -0,0 +1,174 @@
import 'dart:math' as math;
import 'package:intl/intl.dart';
import '../../shared/l10n/l10n.dart';
import 'channel.dart';
class EphemeralChannelDisplay {
final String? detailLabel;
final String tooltipLabel;
const EphemeralChannelDisplay({
required this.detailLabel,
required this.tooltipLabel,
});
}
bool isEphemeralChannel(Channel channel) =>
channel.ttlSeconds != null || channel.ttlDeadline != null;
EphemeralChannelDisplay? ephemeralChannelDisplay(
Channel channel, {
DateTime? now,
AppLocalizations? l10n,
}) {
if (!isEphemeralChannel(channel)) return null;
final deadline = channel.ttlDeadline;
final remainingSeconds = deadline == null
? null
: ((deadline.millisecondsSinceEpoch -
(now ?? DateTime.now()).millisecondsSinceEpoch) /
1000)
.ceil();
if (remainingSeconds == null) {
final ttlSeconds = channel.ttlSeconds;
return EphemeralChannelDisplay(
detailLabel: ttlSeconds == null
? null
: formatCompactTtl(ttlSeconds, l10n: l10n),
tooltipLabel: ttlSeconds == null
? l10n?.ephemeralAutoCleanup ??
'Ephemeral channel. Cleans up automatically after inactivity.'
: l10n?.ephemeralCleanupAfter(
formatVerboseTtl(ttlSeconds, l10n: l10n),
) ??
'Ephemeral channel. Cleans up after ${formatVerboseTtl(ttlSeconds)} of inactivity.',
);
}
final compactRemaining = formatCompactRemaining(
remainingSeconds,
l10n: l10n,
);
final verboseRemaining = formatVerboseRemaining(
remainingSeconds,
l10n: l10n,
);
final absoluteDeadlineLabel = formatAbsoluteDeadline(deadline!, l10n: l10n);
return EphemeralChannelDisplay(
detailLabel: compactRemaining,
tooltipLabel: remainingSeconds <= 0
? l10n?.ephemeralCleanupDueTooltip ??
'Ephemeral channel. Cleanup is due now.'
: l10n?.ephemeralScheduled(verboseRemaining, absoluteDeadlineLabel) ??
'Ephemeral channel. Cleans up $verboseRemaining. Scheduled for $absoluteDeadlineLabel.',
);
}
String formatCompactRemaining(
int remainingSeconds, {
AppLocalizations? l10n,
}) {
if (remainingSeconds <= 0) return l10n?.ephemeralCleanupDue ?? 'Cleanup due';
if (remainingSeconds <= 60) return l10n?.ephemeralOneMinuteLeft ?? '1m left';
if (remainingSeconds < 60 * 60) {
final minutes = math.max(1, (remainingSeconds / 60).ceil());
return l10n?.ephemeralMinutesLeft(minutes) ?? '${minutes}m left';
}
if (remainingSeconds < 60 * 60 * 24) {
final hours = math.max(1, (remainingSeconds / (60 * 60)).ceil());
return l10n?.ephemeralHoursLeft(hours) ?? '${hours}h left';
}
final days = math.max(1, (remainingSeconds / (60 * 60 * 24)).ceil());
return l10n?.ephemeralDaysLeft(days) ?? '${days}d left';
}
String formatVerboseRemaining(
int remainingSeconds, {
AppLocalizations? l10n,
}) {
if (remainingSeconds <= 0) return l10n?.timeNow ?? 'now';
if (remainingSeconds <= 60) {
return l10n?.ephemeralInOneMinute ?? 'in 1 minute';
}
if (remainingSeconds < 60 * 60) {
final minutes = math.max(1, (remainingSeconds / 60).ceil());
return l10n?.ephemeralInMinutes(minutes) ??
'in $minutes minute${minutes == 1 ? '' : 's'}';
}
if (remainingSeconds < 60 * 60 * 24) {
final hours = math.max(1, (remainingSeconds / (60 * 60)).ceil());
return l10n?.ephemeralInHours(hours) ??
'in $hours hour${hours == 1 ? '' : 's'}';
}
final days = math.max(1, (remainingSeconds / (60 * 60 * 24)).ceil());
return l10n?.ephemeralInDays(days) ??
'in $days day${days == 1 ? '' : 's'}';
}
String formatCompactTtl(int ttlSeconds, {AppLocalizations? l10n}) {
if (ttlSeconds < 60) {
final seconds = math.max(1, ttlSeconds);
return l10n?.ephemeralSeconds(seconds) ?? '${seconds}s TTL';
}
if (ttlSeconds < 60 * 60) {
final minutes = math.max(1, (ttlSeconds / 60).ceil());
return l10n?.ephemeralMinutes(minutes) ?? '${minutes}m TTL';
}
if (ttlSeconds < 60 * 60 * 24) {
final hours = math.max(1, (ttlSeconds / (60 * 60)).ceil());
return l10n?.ephemeralHours(hours) ?? '${hours}h TTL';
}
final days = math.max(1, (ttlSeconds / (60 * 60 * 24)).ceil());
return l10n?.ephemeralDays(days) ?? '${days}d TTL';
}
String formatVerboseTtl(int ttlSeconds, {AppLocalizations? l10n}) {
if (ttlSeconds < 60) {
final seconds = math.max(1, ttlSeconds);
return l10n?.ephemeralSeconds(seconds) ??
'$seconds second${seconds == 1 ? '' : 's'}';
}
if (ttlSeconds < 60 * 60) {
final minutes = math.max(1, (ttlSeconds / 60).ceil());
return l10n?.ephemeralMinutes(minutes) ??
'$minutes minute${minutes == 1 ? '' : 's'}';
}
if (ttlSeconds < 60 * 60 * 24) {
final hours = math.max(1, (ttlSeconds / (60 * 60)).ceil());
return l10n?.ephemeralHours(hours) ??
'$hours hour${hours == 1 ? '' : 's'}';
}
final days = math.max(1, (ttlSeconds / (60 * 60 * 24)).ceil());
return l10n?.ephemeralDays(days) ??
'$days day${days == 1 ? '' : 's'}';
}
String formatAbsoluteDeadline(DateTime deadline, {AppLocalizations? l10n}) {
if (l10n != null) {
return DateFormat.yMMMd(l10n.localeName).add_jm().format(deadline.toLocal());
}
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final local = deadline.toLocal();
final hour12 = local.hour % 12 == 0 ? 12 : local.hour % 12;
final minute = local.minute.toString().padLeft(2, '0');
final period = local.hour < 12 ? 'AM' : 'PM';
return '${months[local.month - 1]} ${local.day}, $hour12:$minute $period';
}
@@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
import 'channel_mutes/channel_mutes_provider.dart';
class ManageChannelSheet extends HookConsumerWidget {
final Channel channel;
const ManageChannelSheet({super.key, required this.channel});
@override
Widget build(BuildContext context, WidgetRef ref) {
final canvasAsync = ref.watch(channelCanvasProvider(channel.id));
final isEditingCanvas = useState(false);
final isSavingCanvas = useState(false);
final isBusy = useState(false);
final actionError = useState<String?>(null);
final canvasController = useTextEditingController();
useEffect(() {
final canvas = canvasAsync.asData?.value;
if (!isEditingCanvas.value) {
canvasController.text = canvas?.content ?? '';
}
return null;
}, [canvasAsync.asData?.value.content, isEditingCanvas.value]);
final mutesState = ref.watch(channelMutesProvider);
final isMuted = mutesState.store.channels[channel.id]?.muted == true;
final canJoin =
channel.visibility == 'open' &&
!channel.isArchived &&
!channel.isMember &&
!channel.isDm;
final canLeave = channel.isMember && !channel.isArchived && !channel.isDm;
final canEditCanvas = channel.isMember && !channel.isArchived;
Future<void> joinChannel() async {
if (isBusy.value) return;
isBusy.value = true;
actionError.value = null;
try {
await ref.read(channelActionsProvider).joinChannel(channel.id);
if (context.mounted) {
Navigator.of(context).pop(false);
}
} catch (error) {
actionError.value = error.toString();
} finally {
isBusy.value = false;
}
}
Future<void> leaveChannel() async {
if (isBusy.value) return;
isBusy.value = true;
actionError.value = null;
try {
await ref.read(channelActionsProvider).leaveChannel(channel.id);
if (context.mounted) {
Navigator.of(context).pop(true);
}
} catch (error) {
actionError.value = error.toString();
} finally {
isBusy.value = false;
}
}
Future<void> saveCanvas() async {
if (isSavingCanvas.value) {
return;
}
isSavingCanvas.value = true;
actionError.value = null;
try {
await ref
.read(channelActionsProvider)
.setCanvas(
channelId: channel.id,
content: canvasController.text.trim(),
);
if (context.mounted) {
isEditingCanvas.value = false;
}
} catch (error) {
actionError.value = error.toString();
} finally {
isSavingCanvas.value = false;
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: SafeArea(
top: false,
child: ListView(
shrinkWrap: true,
children: [
Text(
context.l10n.manageChannel,
style: context.textTheme.titleMedium,
),
const SizedBox(height: Grid.xxs),
Text(
context.l10n.basicManagement(channel.name),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (actionError.value case final error?) ...[
const SizedBox(height: Grid.xs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.xs),
SwitchListTile(
title: Text(context.l10n.muteChannel),
subtitle: Text(context.l10n.suppressNotifications),
secondary: const Icon(LucideIcons.bellOff),
contentPadding: EdgeInsets.zero,
value: isMuted,
onChanged: (value) {
if (value) {
ref
.read(channelMutesProvider.notifier)
.muteChannel(channel.id);
} else {
ref
.read(channelMutesProvider.notifier)
.unmuteChannel(channel.id);
}
},
),
if (canJoin || canLeave) ...[
const SizedBox(height: Grid.xs),
Wrap(
spacing: Grid.xxs,
children: [
if (canJoin)
FilledButton.tonal(
onPressed: isBusy.value ? null : joinChannel,
child: Text(
isBusy.value
? context.l10n.joining
: context.l10n.joinChannel,
),
),
if (canLeave)
OutlinedButton(
onPressed: isBusy.value ? null : leaveChannel,
child: Text(
isBusy.value
? context.l10n.leaving
: context.l10n.leaveChannelAction,
),
),
],
),
],
const SizedBox(height: Grid.sm),
Text(context.l10n.context, style: context.textTheme.labelLarge),
const SizedBox(height: Grid.xxs),
_ContextCard(
label: context.l10n.description,
value: channel.description,
emptyLabel: context.l10n.noDescription,
),
const SizedBox(height: Grid.xxs),
_ContextCard(
label: context.l10n.topic,
value: channel.topic,
emptyLabel: context.l10n.noTopic,
),
const SizedBox(height: Grid.xxs),
_ContextCard(
label: context.l10n.purpose,
value: channel.purpose,
emptyLabel: context.l10n.noPurpose,
),
if (!channel.isDm) ...[
const SizedBox(height: Grid.sm),
Text(context.l10n.canvas, style: context.textTheme.labelLarge),
const SizedBox(height: Grid.xxs),
canvasAsync.when(
data: (canvas) {
if (isEditingCanvas.value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: canvasController,
maxLines: 8,
minLines: 6,
decoration: InputDecoration(
hintText: context.l10n.canvasHint,
),
),
const SizedBox(height: Grid.xxs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isSavingCanvas.value
? null
: () {
isEditingCanvas.value = false;
canvasController.text =
canvas.content ?? '';
},
child: Text(context.l10n.cancel),
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: isSavingCanvas.value
? null
: saveCanvas,
child: Text(
isSavingCanvas.value
? context.l10n.saving
: context.l10n.saveCanvas,
),
),
],
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
),
child: Text(
canvas.content?.trim().isNotEmpty == true
? canvas.content!
: context.l10n.noCanvas,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(height: Grid.xxs),
Align(
alignment: Alignment.centerRight,
child: FilledButton.tonal(
onPressed: canEditCanvas
? () => isEditingCanvas.value = true
: null,
child: Text(
canvas.content?.trim().isNotEmpty == true
? context.l10n.editCanvas
: context.l10n.createCanvas,
),
),
),
],
);
},
loading: () => Center(
child: BuzzLoadingIndicator(
size: 40,
semanticLabel: context.l10n.loadingChannelDetails,
),
),
error: (error, _) => Text(
error.toString(),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
],
],
),
),
);
}
}
class _ContextCard extends StatelessWidget {
final String label;
final String? value;
final String emptyLabel;
const _ContextCard({
required this.label,
required this.value,
required this.emptyLabel,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.half),
Text(
value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
/// Keeps image-viewer shared-element motion consistent at every source.
class MediaViewerHero extends StatelessWidget {
/// The identity shared by the inline image and full-screen image.
final Object tag;
/// The image rendered during and after the shared-element transition.
final Widget child;
/// Creates an image-viewer shared element.
const MediaViewerHero({super.key, required this.tag, required this.child});
@override
Widget build(BuildContext context) {
return Hero(
tag: tag,
createRectTween: (begin, end) => RectTween(begin: begin, end: end),
flightShuttleBuilder:
(
flightContext,
animation,
flightDirection,
fromHeroContext,
toHeroContext,
) {
final sourceHero = fromHeroContext.widget;
final destinationHero = toHeroContext.widget;
final sourceChild = sourceHero is Hero ? sourceHero.child : child;
final destinationChild = destinationHero is Hero
? destinationHero.child
: child;
return _MediaViewerHeroFlight(
animation: animation,
sourceChild: sourceChild,
destinationChild: destinationChild,
);
},
child: child,
);
}
}
class _MediaViewerHeroFlight extends StatelessWidget {
final Animation<double> animation;
final Widget sourceChild;
final Widget destinationChild;
const _MediaViewerHeroFlight({
required this.animation,
required this.sourceChild,
required this.destinationChild,
});
@override
Widget build(BuildContext context) {
final destinationOpacity = CurvedAnimation(
parent: animation,
curve: const Interval(0.18, 0.82, curve: Curves.easeInOutCubic),
);
return Stack(
fit: StackFit.expand,
children: [
FadeTransition(
opacity: ReverseAnimation(destinationOpacity),
child: sourceChild,
),
FadeTransition(opacity: destinationOpacity, child: destinationChild),
],
);
}
}
@@ -0,0 +1,703 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:video_player/video_player.dart';
import '../../shared/relay/relay.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import 'media_viewer_hero.dart';
export 'media_viewer_hero.dart';
part 'media_viewer_page/image_controls.dart';
part 'media_viewer_page/route_transition.dart';
part 'media_viewer_page/video_controls.dart';
part 'media_viewer_page/video_viewer.dart';
const _imageViewerPushDuration = Duration(milliseconds: 260);
const _imageViewerPopDuration = Duration(milliseconds: 170);
const _identityTransformEpsilon = 0.0001;
final List<double> _identityTransformStorage = List<double>.unmodifiable(
Matrix4.identity().storage,
);
/// Opens message-specific actions for the currently visible image.
typedef MediaViewerMoreAction =
void Function(BuildContext context, String imageUrl);
/// An image and its source Hero tag in a full-screen media gallery.
@immutable
class MediaViewerImage {
/// The image URL.
final String url;
/// The shared-element transition tag for the source thumbnail.
final Object heroTag;
/// The accessible image description.
final String? semanticLabel;
/// The logical decode width already cached by the source thumbnail.
final double? previewDecodeWidth;
/// The image's intrinsic width-to-height ratio, when provided by metadata.
final double? aspectRatio;
/// A display-sized provider that can be warmed before this page is shown.
final ImageProvider<Object>? preloadProvider;
/// Creates a media-viewer image.
const MediaViewerImage({
required this.url,
required this.heroTag,
this.semanticLabel,
this.previewDecodeWidth,
this.aspectRatio,
this.preloadProvider,
});
}
PageRoute<void> buildImageViewerRoute({
required String imageUrl,
required Object heroTag,
String? semanticLabel,
double? previewDecodeWidth,
double? aspectRatio,
List<MediaViewerImage>? galleryItems,
int initialIndex = 0,
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
bool disableAnimations = false,
}) {
final images =
galleryItems ??
[
MediaViewerImage(
url: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
previewDecodeWidth: previewDecodeWidth,
aspectRatio: aspectRatio,
),
];
final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt();
return PageRouteBuilder<void>(
transitionDuration: disableAnimations
? Duration.zero
: _imageViewerPushDuration,
reverseTransitionDuration: disableAnimations
? Duration.zero
: _imageViewerPopDuration,
pageBuilder: (context, animation, secondaryAnimation) =>
MediaImageViewerPage(
imageUrl: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
galleryItems: images,
initialIndex: safeInitialIndex,
onReply: onReply,
onMore: onMore,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
_MediaViewerRouteTransition(animation: animation, child: child),
);
}
void openImageViewer(
BuildContext context, {
required String imageUrl,
required Object heroTag,
String? semanticLabel,
double? previewDecodeWidth,
double? aspectRatio,
List<MediaViewerImage>? galleryItems,
int initialIndex = 0,
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
}) {
Navigator.of(context).push(
buildImageViewerRoute(
imageUrl: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
previewDecodeWidth: previewDecodeWidth,
aspectRatio: aspectRatio,
galleryItems: galleryItems,
initialIndex: initialIndex,
onReply: onReply,
onMore: onMore,
disableAnimations: MediaQuery.disableAnimationsOf(context),
),
);
}
void openVideoViewer(
BuildContext context, {
required String videoUrl,
String? posterUrl,
VoidCallback? onReply,
}) {
Navigator.of(context).push(
PageRouteBuilder<void>(
transitionDuration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: _imageViewerPushDuration,
reverseTransitionDuration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: _imageViewerPopDuration,
pageBuilder: (context, animation, secondaryAnimation) =>
MediaVideoViewerPage(
videoUrl: videoUrl,
posterUrl: posterUrl,
onReply: onReply,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
_MediaViewerRouteTransition(animation: animation, child: child),
),
);
}
class MediaImageViewerPage extends HookConsumerWidget {
final String imageUrl;
final Object heroTag;
final String? semanticLabel;
final List<MediaViewerImage>? galleryItems;
final int initialIndex;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const MediaImageViewerPage({
super.key,
required this.imageUrl,
required this.heroTag,
this.semanticLabel,
this.galleryItems,
this.initialIndex = 0,
this.onReply,
this.onMore,
});
static const _dismissThreshold = 100.0;
static const _dismissVelocity = 700.0;
static const _backgroundFadeDivisor = 300.0;
static const _filmstripScrubExtent = 44.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final images = useMemoized(
() =>
galleryItems ??
[
MediaViewerImage(
url: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
),
],
[galleryItems, imageUrl, heroTag, semanticLabel],
);
final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt();
final currentIndex = useState(safeInitialIndex);
final pageController = usePageController(initialPage: safeInitialIndex);
final pagePosition = useState(safeInitialIndex.toDouble());
final transformationControllers = useMemoized(
() => [
for (var index = 0; index < images.length; index++)
TransformationController(),
],
[images],
);
final snapBackController = useAnimationController(
duration: const Duration(milliseconds: 200),
);
final zoomResetController = useAnimationController(
duration: const Duration(milliseconds: 180),
);
final zoomResetListener = useRef<VoidCallback?>(null);
final fullResolutionIndices = useState<Set<int>>(<int>{});
final isTransformed = useState(false);
final disableHeroOnDismiss = useState(false);
final dragOffset = useState(0.0);
final isDragging = useState(false);
void handlePagePositionChanged() {
if (!pageController.hasClients) return;
final nextPosition = pageController.page;
if (nextPosition == null ||
(nextPosition - pagePosition.value).abs() < 0.0001) {
return;
}
pagePosition.value = nextPosition;
}
void handleTransformChanged(int index) {
if (index != currentIndex.value) return;
final nextIsTransformed = _hasImageTransform(
transformationControllers[index].value,
);
if (nextIsTransformed == isTransformed.value) {
return;
}
isTransformed.value = nextIsTransformed;
if (nextIsTransformed && isDragging.value) {
isDragging.value = false;
dragOffset.value = 0;
}
}
useEffect(() {
pageController.addListener(handlePagePositionChanged);
return () => pageController.removeListener(handlePagePositionChanged);
}, [pageController]);
useEffect(() {
final listeners = <VoidCallback>[];
for (var index = 0; index < transformationControllers.length; index++) {
final controllerIndex = index;
void listener() => handleTransformChanged(controllerIndex);
listeners.add(listener);
transformationControllers[index].addListener(listener);
}
return () {
for (var index = 0; index < transformationControllers.length; index++) {
transformationControllers[index]
..removeListener(listeners[index])
..dispose();
}
};
}, [transformationControllers]);
useEffect(() {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!cancelled && context.mounted) {
_precacheViewerImages(context, images, currentIndex.value);
}
});
return () => cancelled = true;
}, [images]);
useEffect(() {
return () {
final listener = zoomResetListener.value;
if (listener != null) {
zoomResetController.removeListener(listener);
}
};
}, [zoomResetController]);
void onPageChanged(int index) {
_precacheViewerImages(context, images, index);
currentIndex.value = index;
isTransformed.value = _hasImageTransform(
transformationControllers[index].value,
);
disableHeroOnDismiss.value = index != safeInitialIndex;
dragOffset.value = 0;
isDragging.value = false;
}
void onFilmstripScrubUpdate(double delta) {
if (isTransformed.value || !pageController.hasClients) return;
final position = pageController.position;
final viewport = position.viewportDimension;
if (viewport <= 0) return;
final target =
(pageController.offset - ((delta / _filmstripScrubExtent) * viewport))
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble();
pageController.jumpTo(target);
}
void onFilmstripScrubEnd() {
if (!pageController.hasClients) return;
final targetPage = (pageController.page ?? currentIndex.value.toDouble())
.round()
.clamp(0, images.length - 1);
if (MediaQuery.disableAnimationsOf(context)) {
pageController.jumpToPage(targetPage);
return;
}
pageController.animateToPage(
targetPage,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
);
}
void upgradeToFullResolution(int index) {
if (images[index].previewDecodeWidth == null ||
fullResolutionIndices.value.contains(index)) {
return;
}
fullResolutionIndices.value = {...fullResolutionIndices.value, index};
}
void onImageInteractionStart(int index, ScaleStartDetails details) {
if (details.pointerCount > 1) {
upgradeToFullResolution(index);
}
if (details.pointerCount == 1 && !isTransformed.value) {
isDragging.value = true;
}
}
void onImageInteractionUpdate(int index, ScaleUpdateDetails details) {
if (details.pointerCount > 1 || details.scale != 1) {
final needsFullResolution =
images[index].previewDecodeWidth != null &&
!fullResolutionIndices.value.contains(index);
if (isDragging.value || needsFullResolution) {
if (needsFullResolution) {
fullResolutionIndices.value = {
...fullResolutionIndices.value,
index,
};
}
isDragging.value = false;
dragOffset.value = 0;
}
return;
}
if (!isDragging.value || isTransformed.value) return;
dragOffset.value = (dragOffset.value + details.focalPointDelta.dy).clamp(
0.0,
MediaQuery.sizeOf(context).height,
);
}
void resetImageTransform(int index) {
final controller = transformationControllers[index];
if (!_hasImageTransform(controller.value)) {
return;
}
final previousListener = zoomResetListener.value;
if (previousListener != null) {
zoomResetController.removeListener(previousListener);
}
zoomResetController.stop();
if (MediaQuery.disableAnimationsOf(context)) {
controller.value = Matrix4.identity();
zoomResetListener.value = null;
return;
}
final animation =
Matrix4Tween(
begin: Matrix4.copy(controller.value),
end: Matrix4.identity(),
).animate(
CurvedAnimation(
parent: zoomResetController,
curve: Curves.easeOutCubic,
),
);
void listener() => controller.value = animation.value;
zoomResetListener.value = listener;
zoomResetController
..reset()
..addListener(listener)
..forward();
}
bool canDismissWithHero() =>
!isTransformed.value || disableHeroOnDismiss.value;
Future<void> prepareHeroFallbackDismiss() async {
if (canDismissWithHero()) {
return;
}
disableHeroOnDismiss.value = true;
await WidgetsBinding.instance.endOfFrame;
}
Future<void> dismiss() async {
await prepareHeroFallbackDismiss();
if (!context.mounted) {
return;
}
Navigator.of(context).maybePop();
}
void animateSnapBack() {
final tween = Tween<double>(begin: dragOffset.value, end: 0);
void listener() {
dragOffset.value = tween.evaluate(snapBackController);
}
snapBackController
..stop()
..reset()
..addListener(listener);
snapBackController
.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 260),
bounce: 0.14,
),
0,
1,
0,
snapToEnd: true,
),
)
.whenCompleteOrCancel(() {
snapBackController.removeListener(listener);
});
}
void finishVerticalDismiss(double velocity) {
isDragging.value = false;
if (dragOffset.value > _dismissThreshold || velocity > _dismissVelocity) {
unawaited(dismiss());
} else {
animateSnapBack();
}
}
void onImageInteractionEnd(ScaleEndDetails details) {
if (!isDragging.value) return;
finishVerticalDismiss(details.velocity.pixelsPerSecond.dy);
}
Future<void> replyInThread() async {
final callback = onReply;
if (callback == null) return;
final route = ModalRoute.of(context);
await dismiss();
await route?.completed;
callback();
}
void showMoreActions() {
onMore?.call(context, images[currentIndex.value].url);
}
final viewportHeight = MediaQuery.sizeOf(context).height;
final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0);
final imageScale = 1 - (dragProgress * 0.1);
final chromeOpacity = (1 - (dragOffset.value / 160)).clamp(0.0, 1.0);
return PopScope<void>(
canPop: canDismissWithHero(),
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
return;
}
unawaited(dismiss());
},
child: Scaffold(
key: const ValueKey('message-media-image-viewer'),
backgroundColor: Colors.black.withValues(
alpha: (1 - (dragOffset.value.abs() / _backgroundFadeDivisor)).clamp(
0.3,
1.0,
),
),
body: Stack(
children: [
Positioned.fill(
child: Transform.translate(
offset: Offset(0, dragOffset.value),
child: Transform.scale(
scale: imageScale,
child: PageView.builder(
key: const ValueKey('message-media-image-viewer-pages'),
controller: pageController,
physics: isTransformed.value
? const NeverScrollableScrollPhysics()
: const PageScrollPhysics(),
itemCount: images.length,
onPageChanged: onPageChanged,
itemBuilder: (context, index) {
final image = images[index];
final viewPadding = MediaQuery.viewPaddingOf(context);
return Padding(
padding: EdgeInsets.only(
top: viewPadding.top + 48 + Grid.xxs,
bottom: viewPadding.bottom + 56 + (Grid.xxs * 2),
),
child: LayoutBuilder(
builder: (context, constraints) {
final viewerSize = _imageViewerSize(
Size(constraints.maxWidth, constraints.maxHeight),
image.aspectRatio,
);
return GestureDetector(
key: ValueKey(
'message-media-image-viewer-gesture:$index',
),
behavior: HitTestBehavior.opaque,
onDoubleTap: () => resetImageTransform(index),
child: InteractiveViewer(
transformationController:
transformationControllers[index],
onInteractionStart: (details) =>
onImageInteractionStart(index, details),
onInteractionUpdate: (details) =>
onImageInteractionUpdate(index, details),
onInteractionEnd: onImageInteractionEnd,
panEnabled: isTransformed.value,
scaleEnabled: true,
minScale: 1,
maxScale: 4,
boundaryMargin: const EdgeInsets.all(Grid.xxl),
clipBehavior: Clip.none,
child: Align(
alignment: Alignment.center,
child: SizedBox(
width: viewerSize.width,
height: viewerSize.height,
child: HeroMode(
key: index == safeInitialIndex
? const ValueKey(
'message-media-image-viewer-hero-mode',
)
: ValueKey(
'message-media-image-viewer-hero-mode-$index',
),
enabled:
!disableHeroOnDismiss.value &&
index == safeInitialIndex,
child: MediaViewerHero(
tag: image.heroTag,
child: MediaImage(
key: ValueKey(
'message-media-image-viewer-image:$index',
),
url: image.url,
decodeWidth:
fullResolutionIndices.value
.contains(index)
? null
: image.previewDecodeWidth,
boundDecodeToLayout: false,
fit: BoxFit.contain,
semanticLabel: image.semanticLabel,
errorBuilder: (_, _, _) =>
_MediaLoadFailure(
message:
context.l10n.failedLoadImage,
icon: LucideIcons.imageOff,
),
),
),
),
),
),
),
);
},
),
);
},
),
),
),
),
PositionedDirectional(
bottom: 0,
start: 0,
end: 0,
child: Opacity(
opacity: chromeOpacity,
child: SafeArea(
child: _MediaViewerBottomControls(
images: images,
currentIndex: currentIndex.value,
pagePosition: pagePosition,
onScrubUpdate: onFilmstripScrubUpdate,
onScrubEnd: onFilmstripScrubEnd,
onSelect: (index) {
if (index == currentIndex.value) return;
if (MediaQuery.disableAnimationsOf(context)) {
pageController.jumpToPage(index);
return;
}
pageController.animateToPage(
index,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
},
onReply: onReply == null
? null
: () => unawaited(replyInThread()),
onMore: onMore == null ? null : showMoreActions,
),
),
),
),
PositionedDirectional(
top: 0,
end: Grid.sm,
child: Opacity(
opacity: chromeOpacity,
child: SafeArea(
child: _MediaViewerCircleButton(
key: const ValueKey('message-media-image-viewer-close'),
icon: LucideIcons.x,
tooltip: context.l10n.closeImageViewer,
onPressed: () => unawaited(dismiss()),
),
),
),
),
],
),
),
);
}
}
class _MediaLoadFailure extends StatelessWidget {
final String message;
final IconData icon;
const _MediaLoadFailure({required this.message, required this.icon});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final showMessage = constraints.maxWidth >= 120;
final iconSize = constraints.biggest.shortestSide
.clamp(0.0, 36.0)
.toDouble();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white70, size: iconSize),
if (showMessage) ...[
const SizedBox(height: Grid.xxs),
Text(
message,
style: context.textTheme.bodyMedium?.copyWith(
color: Colors.white70,
),
textAlign: TextAlign.center,
),
],
],
);
},
);
}
}
@@ -0,0 +1,300 @@
part of '../media_viewer_page.dart';
class _MediaViewerBottomControls extends StatelessWidget {
final List<MediaViewerImage> images;
final int currentIndex;
final ValueListenable<double> pagePosition;
final ValueChanged<double> onScrubUpdate;
final VoidCallback onScrubEnd;
final ValueChanged<int> onSelect;
final VoidCallback? onReply;
final VoidCallback? onMore;
const _MediaViewerBottomControls({
required this.images,
required this.currentIndex,
required this.pagePosition,
required this.onScrubUpdate,
required this.onScrubEnd,
required this.onSelect,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(Grid.sm, Grid.xxs, Grid.sm, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_MediaViewerCircleButton(
key: const ValueKey('message-media-image-viewer-reply-thread'),
icon: LucideIcons.messageSquareReply,
tooltip: context.l10n.replyInThread,
onPressed: onReply,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: images.length > 1
? _MediaViewerFilmstrip(
key: const ValueKey('message-media-image-viewer-filmstrip'),
images: images,
currentIndex: currentIndex,
pagePosition: pagePosition,
onScrubUpdate: onScrubUpdate,
onScrubEnd: onScrubEnd,
onSelect: onSelect,
)
: const SizedBox(height: 56),
),
const SizedBox(width: Grid.xxs),
_MediaViewerCircleButton(
key: const ValueKey('message-media-image-viewer-more-actions'),
icon: LucideIcons.ellipsis,
tooltip: context.l10n.moreImageActions,
onPressed: onMore,
),
],
),
);
}
}
class _MediaViewerFilmstrip extends StatelessWidget {
final List<MediaViewerImage> images;
final int currentIndex;
final ValueListenable<double> pagePosition;
final ValueChanged<double> onScrubUpdate;
final VoidCallback onScrubEnd;
final ValueChanged<int> onSelect;
const _MediaViewerFilmstrip({
super.key,
required this.images,
required this.currentIndex,
required this.pagePosition,
required this.onScrubUpdate,
required this.onScrubEnd,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 56,
child: RepaintBoundary(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) =>
onScrubUpdate(details.primaryDelta ?? 0),
onHorizontalDragEnd: (_) => onScrubEnd(),
child: ValueListenableBuilder<double>(
valueListenable: pagePosition,
builder: (context, position, _) {
return LayoutBuilder(
builder: (context, constraints) {
const compactWidth = 40.0;
const focusedWidth = 72.0;
const itemHeight = 52.0;
const spacing = Grid.half;
final clampedPosition = position
.clamp(0.0, images.length - 1.0)
.toDouble();
final widths = <double>[];
final proximities = <double>[];
final centers = <double>[];
var cursor = 0.0;
for (var index = 0; index < images.length; index++) {
final proximity = (1 - (index - clampedPosition).abs())
.clamp(0.0, 1.0)
.toDouble();
final width =
compactWidth +
((focusedWidth - compactWidth) * proximity);
widths.add(width);
proximities.add(proximity);
centers.add(cursor + (width / 2));
cursor += width + spacing;
}
final lowerIndex = clampedPosition.floor();
final upperIndex = clampedPosition.ceil();
final fraction = clampedPosition - lowerIndex;
final lowerCenter = centers[lowerIndex];
final upperCenter = centers[upperIndex];
final focusCenter =
lowerCenter + ((upperCenter - lowerCenter) * fraction);
final viewportCenter = constraints.maxWidth / 2;
return Stack(
clipBehavior: Clip.hardEdge,
children: [
for (var index = 0; index < images.length; index++)
Positioned(
left:
viewportCenter +
centers[index] -
focusCenter -
(widths[index] / 2),
top: Grid.quarter,
width: widths[index],
height: itemHeight,
child: _MediaViewerFilmstripImage(
image: images[index],
index: index,
proximity: proximities[index],
selected: index == currentIndex,
onSelect: onSelect,
),
),
],
);
},
);
},
),
),
),
);
}
}
class _MediaViewerFilmstripImage extends StatelessWidget {
final MediaViewerImage image;
final int index;
final double proximity;
final bool selected;
final ValueChanged<int> onSelect;
const _MediaViewerFilmstripImage({
required this.image,
required this.index,
required this.proximity,
required this.selected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final borderWidth = 1 + (1.5 * proximity);
return Semantics(
button: true,
selected: selected,
label: selected
? context.l10n.imageSelected(index + 1)
: context.l10n.showImage(index + 1),
child: GestureDetector(
key: ValueKey('message-media-image-viewer-thumbnail:$index'),
onTap: () => onSelect(index),
child: Container(
height: 52,
padding: EdgeInsets.all(borderWidth),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: Colors.white.withValues(alpha: 0.28 + (0.72 * proximity)),
width: borderWidth,
),
),
child: ClipRRect(
key: ValueKey('message-media-image-viewer-thumbnail-clip:$index'),
borderRadius: BorderRadius.circular(Radii.sm - borderWidth),
clipBehavior: Clip.antiAlias,
child: Opacity(
opacity: 0.62 + (0.38 * proximity),
child: MediaImage(
url: image.url,
decodeWidth: 72,
fit: BoxFit.cover,
semanticLabel: image.semanticLabel,
errorBuilder: (_, _, _) => const ColoredBox(
color: Color.fromRGBO(255, 255, 255, 0.12),
child: Icon(
LucideIcons.imageOff,
color: Colors.white70,
size: 18,
),
),
),
),
),
),
),
);
}
}
class _MediaViewerCircleButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
const _MediaViewerCircleButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 48,
child: onPressed == null
? const SizedBox.shrink()
: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
child: IconButton(
onPressed: onPressed,
tooltip: tooltip,
icon: Icon(icon, color: Colors.white, size: 20),
),
),
);
}
}
Size _imageViewerSize(Size viewport, double? aspectRatio) {
if (aspectRatio == null || aspectRatio <= 0) {
return viewport;
}
final safeAspectRatio = aspectRatio.clamp(0.05, 20.0).toDouble();
if (viewport.aspectRatio > safeAspectRatio) {
return Size(viewport.height * safeAspectRatio, viewport.height);
}
return Size(viewport.width, viewport.width / safeAspectRatio);
}
void _precacheViewerImages(
BuildContext context,
List<MediaViewerImage> images,
int focusedIndex,
) {
for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) {
if (index < 0 || index >= images.length) {
continue;
}
final provider = images[index].preloadProvider;
if (provider == null) {
continue;
}
unawaited(precacheImage(provider, context, onError: (_, _) {}));
}
}
bool _hasImageTransform(Matrix4 transform) {
final storage = transform.storage;
for (var index = 0; index < storage.length; index++) {
if ((storage[index] - _identityTransformStorage[index]).abs() >
_identityTransformEpsilon) {
return true;
}
}
return false;
}
@@ -0,0 +1,22 @@
part of '../media_viewer_page.dart';
class _MediaViewerRouteTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const _MediaViewerRouteTransition({
required this.animation,
required this.child,
});
@override
Widget build(BuildContext context) {
final fade = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInOutCubic,
);
return FadeTransition(opacity: fade, child: child);
}
}
@@ -0,0 +1,142 @@
part of '../media_viewer_page.dart';
/// Video transport and message actions, kept visually aligned with the image
/// viewer chrome while the native player owns decoding and playback.
class _VideoViewerBottomControls extends StatelessWidget {
final VideoPlayerController? controller;
final VoidCallback? onReply;
const _VideoViewerBottomControls({
required this.controller,
required this.onReply,
});
@override
Widget build(BuildContext context) {
final readyController = controller?.value.isInitialized == true
? controller
: null;
return Padding(
padding: const EdgeInsets.fromLTRB(Grid.sm, Grid.xxs, Grid.sm, 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (readyController != null)
_VideoTransportBar(controller: readyController),
Row(
children: [
_MediaViewerCircleButton(
key: const ValueKey('message-media-video-viewer-reply-thread'),
icon: LucideIcons.messageSquareReply,
tooltip: context.l10n.replyInThread,
onPressed: onReply,
),
const Spacer(),
],
),
],
),
);
}
}
class _VideoTransportBar extends HookConsumerWidget {
final VideoPlayerController controller;
const _VideoTransportBar({required this.controller});
@override
Widget build(BuildContext context, WidgetRef ref) {
useListenable(controller);
final value = controller.value;
final durationMs = value.duration.inMilliseconds;
final positionMs = value.position.inMilliseconds.clamp(0, durationMs);
final hasDuration = durationMs > 0;
return Padding(
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
borderRadius: BorderRadius.circular(Radii.full),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Row(
children: [
IconButton(
key: const ValueKey('message-media-video-viewer-play-pause'),
onPressed: () {
if (value.isPlaying) {
controller.pause();
} else {
controller.play();
}
},
tooltip: value.isPlaying
? context.l10n.pauseVideo
: context.l10n.playVideo,
icon: Icon(
value.isPlaying ? LucideIcons.pause : LucideIcons.play,
color: Colors.white,
),
),
Text(
_formatVideoDuration(Duration(milliseconds: positionMs)),
style: context.textTheme.labelSmall?.copyWith(
color: Colors.white,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white.withValues(alpha: 0.28),
thumbColor: Colors.white,
overlayColor: Colors.white.withValues(alpha: 0.12),
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 5,
),
),
child: Slider(
value: hasDuration ? positionMs.toDouble() : 0,
min: 0,
max: hasDuration ? durationMs.toDouble() : 1,
onChanged: hasDuration
? (next) => controller.seekTo(
Duration(milliseconds: next.round()),
)
: null,
),
),
),
Text(
_formatVideoDuration(value.duration),
style: context.textTheme.labelSmall?.copyWith(
color: Colors.white.withValues(alpha: 0.78),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(width: Grid.xxs),
],
),
),
),
);
}
}
String _formatVideoDuration(Duration duration) {
final totalSeconds = duration.inSeconds.clamp(0, 359999);
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
final paddedSeconds = seconds.toString().padLeft(2, '0');
if (hours > 0) {
return '$hours:${minutes.toString().padLeft(2, '0')}:$paddedSeconds';
}
return '$minutes:$paddedSeconds';
}
@@ -0,0 +1,385 @@
part of '../media_viewer_page.dart';
class MediaVideoViewerPage extends HookConsumerWidget {
final String videoUrl;
final String? posterUrl;
final VoidCallback? onReply;
static const _dismissThreshold = 100.0;
static const _dismissVelocity = 700.0;
static const _backgroundFadeDivisor = 300.0;
const MediaVideoViewerPage({
super.key,
required this.videoUrl,
this.posterUrl,
this.onReply,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useState<VideoPlayerController?>(null);
final videoFile = useRef<File?>(null);
final downloadRequestAbort = useRef<Completer<void>?>(null);
final downloadSubscription = useRef<StreamSubscription<List<int>>?>(null);
final downloadSink = useRef<IOSink?>(null);
final initializeFuture = useState<Future<void>?>(null);
final error = useState<String?>(null);
final dragOffset = useState(0.0);
final isDragging = useState(false);
final snapBackController = useAnimationController(
duration: const Duration(milliseconds: 200),
);
Future<void> deleteVideoFile() async {
final file = videoFile.value;
videoFile.value = null;
if (file == null) return;
try {
if (await file.exists()) await file.delete();
} on FileSystemException {
// Temporary storage cleanup should not make closing the viewer fail.
}
}
useEffect(() {
var disposed = false;
Future<void> initializeVideo() async {
final auth = ref.read(mediaGetAuthServiceProvider);
final uri = Uri.parse(videoUrl);
// ExoPlayer supports the request headers on every range request, so
// keep Android on its streaming path. iOS uses the authenticated local
// copy below because AVPlayer can drop those headers after the first
// request.
if (Platform.isAndroid) {
VideoPlayerController? streamingController;
try {
streamingController = VideoPlayerController.networkUrl(
uri,
httpHeaders: auth.headersFor(videoUrl),
);
await streamingController.initialize();
await streamingController.play();
if (disposed) {
await streamingController.dispose();
return;
}
controller.value = streamingController;
return;
} catch (_) {
if (streamingController != null) {
await streamingController.dispose();
}
// Fall through to the authenticated local-file path only when the
// streaming controller cannot initialize.
}
}
try {
final client = ref.read(mediaHttpClientProvider);
final requestAbort = Completer<void>();
downloadRequestAbort.value = requestAbort;
final request = http.AbortableStreamedRequest(
'GET',
uri,
abortTrigger: requestAbort.future,
)..headers.addAll(auth.headersFor(videoUrl));
late final http.StreamedResponse response;
try {
response = await client.send(request);
} finally {
if (downloadRequestAbort.value == requestAbort) {
downloadRequestAbort.value = null;
}
}
if (disposed) {
await _cancelVideoResponse(response);
return;
}
if (response.statusCode < 200 || response.statusCode >= 300) {
await response.stream.drain<void>();
throw HttpException(
'Video download failed (${response.statusCode})',
uri: uri,
);
}
final responseSubscription = response.stream.listen(null)..pause();
downloadSubscription.value = responseSubscription;
final directory = await getTemporaryDirectory();
if (disposed) {
await responseSubscription.cancel();
if (downloadSubscription.value == responseSubscription) {
downloadSubscription.value = null;
}
return;
}
final file = File(
'${directory.path}${Platform.pathSeparator}'
'buzz-video-${DateTime.now().microsecondsSinceEpoch}'
'${_videoFileExtension(uri)}',
);
videoFile.value = file;
final sink = file.openWrite();
downloadSink.value = sink;
final completed = Completer<void>();
responseSubscription
..onData(sink.add)
..onError((Object error, StackTrace stackTrace) async {
await sink.close();
if (!completed.isCompleted) {
completed.completeError(error, stackTrace);
}
})
..onDone(() async {
await sink.close();
if (!completed.isCompleted) completed.complete();
})
..resume();
await completed.future;
downloadSubscription.value = null;
downloadSink.value = null;
if (disposed) {
await deleteVideoFile();
return;
}
final localController = VideoPlayerController.file(file);
await localController.initialize();
await localController.play();
if (disposed) {
await localController.dispose();
await deleteVideoFile();
return;
}
controller.value = localController;
} catch (loadError) {
if (!disposed) error.value = loadError.toString();
}
}
initializeFuture.value = initializeVideo();
return () {
disposed = true;
final activeRequestAbort = downloadRequestAbort.value;
if (activeRequestAbort != null && !activeRequestAbort.isCompleted) {
activeRequestAbort.complete();
}
unawaited(downloadSubscription.value?.cancel() ?? Future.value());
unawaited(downloadSink.value?.close() ?? Future.value());
final activeController = controller.value;
if (activeController != null) unawaited(activeController.dispose());
unawaited(deleteVideoFile());
};
}, [videoUrl]);
void animateSnapBack() {
isDragging.value = false;
if (MediaQuery.disableAnimationsOf(context)) {
dragOffset.value = 0;
return;
}
final tween = Tween<double>(begin: dragOffset.value, end: 0);
void listener() => dragOffset.value = tween.evaluate(snapBackController);
snapBackController
..stop()
..reset()
..addListener(listener);
snapBackController
.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 260),
bounce: 0.14,
),
0,
1,
0,
snapToEnd: true,
),
)
.whenCompleteOrCancel(
() => snapBackController.removeListener(listener),
);
}
Future<void> replyInThread() async {
final callback = onReply;
if (callback == null) return;
final route = ModalRoute.of(context);
controller.value?.pause();
await Navigator.of(context).maybePop();
await route?.completed;
callback();
}
final viewportHeight = MediaQuery.sizeOf(context).height;
final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0);
final videoScale = 1 - (dragProgress * 0.1);
final chromeOpacity = (1 - (dragOffset.value / 160)).clamp(0.0, 1.0);
return Scaffold(
key: const ValueKey('message-media-video-viewer'),
backgroundColor: Colors.black.withValues(
alpha: (1 - (dragOffset.value / _backgroundFadeDivisor)).clamp(
0.3,
1.0,
),
),
body: Stack(
children: [
Positioned.fill(
child: Transform.translate(
offset: Offset(0, dragOffset.value),
child: Transform.scale(
scale: videoScale,
child: GestureDetector(
key: const ValueKey('message-media-video-viewer-gesture'),
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (_) {
snapBackController.stop();
isDragging.value = true;
},
onVerticalDragUpdate: (details) {
if (!isDragging.value) return;
dragOffset.value = (dragOffset.value + details.delta.dy)
.clamp(0.0, viewportHeight)
.toDouble();
},
onVerticalDragEnd: (details) {
isDragging.value = false;
final velocity = details.primaryVelocity ?? 0;
if (dragOffset.value > _dismissThreshold ||
velocity > _dismissVelocity) {
controller.value?.pause();
Navigator.of(context).maybePop();
return;
}
animateSnapBack();
},
onVerticalDragCancel: animateSnapBack,
child: SafeArea(
child: Center(
child: FutureBuilder<void>(
future: initializeFuture.value,
builder: (context, snapshot) {
if (error.value != null || snapshot.hasError) {
return _MediaLoadFailure(
message: context.l10n.failedLoadVideo,
icon: LucideIcons.videoOff,
);
}
final videoController = controller.value;
if (videoController == null ||
!videoController.value.isInitialized) {
return _VideoLoadingPoster(posterUrl: posterUrl);
}
return AspectRatio(
aspectRatio: videoController.value.aspectRatio,
child: VideoPlayer(videoController),
);
},
),
),
),
),
),
),
),
PositionedDirectional(
top: Grid.sm,
end: Grid.sm,
child: Opacity(
opacity: chromeOpacity,
child: SafeArea(
child: _MediaViewerCircleButton(
key: const ValueKey('message-media-video-viewer-close'),
icon: LucideIcons.x,
tooltip: context.l10n.closeVideoViewer,
onPressed: () => Navigator.of(context).maybePop(),
),
),
),
),
PositionedDirectional(
bottom: 0,
start: 0,
end: 0,
child: Opacity(
opacity: chromeOpacity,
child: SafeArea(
child: _VideoViewerBottomControls(
controller: controller.value,
onReply: onReply == null
? null
: () => unawaited(replyInThread()),
),
),
),
),
],
),
);
}
}
Future<void> _cancelVideoResponse(http.StreamedResponse response) {
return response.stream.listen((_) {}).cancel();
}
String _videoFileExtension(Uri uri) {
final path = uri.path;
final extensionStart = path.lastIndexOf('.');
if (extensionStart < 0 || extensionStart == path.length - 1) return '.mp4';
final extension = path.substring(extensionStart);
return RegExp(r'^\.[A-Za-z0-9]{1,10}$').hasMatch(extension)
? extension
: '.mp4';
}
class _VideoLoadingPoster extends StatelessWidget {
final String? posterUrl;
const _VideoLoadingPoster({required this.posterUrl});
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
if (posterUrl != null)
MediaImage(
url: posterUrl!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _videoPlaceholder(context),
)
else
_videoPlaceholder(context),
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.24)),
Center(
child: BuzzLoadingIndicator(
size: 44,
color: Colors.white,
semanticLabel: context.l10n.loadingVideo,
),
),
],
),
);
}
Widget _videoPlaceholder(BuildContext context) {
return ColoredBox(
color: context.colors.surfaceContainerHighest,
child: Icon(
LucideIcons.video,
size: 40,
color: context.colors.onSurfaceVariant,
),
);
}
}
@@ -0,0 +1,480 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../profile/user_status.dart';
import '../profile/user_status_cache_provider.dart';
import 'agent_activity/agent_activity_sheet.dart';
import 'agent_activity/working_bots_provider.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
class MembersSheet extends HookConsumerWidget {
final Channel channel;
final String? currentPubkey;
const MembersSheet({
super.key,
required this.channel,
required this.currentPubkey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final membersAsync = ref.watch(channelMembersProvider(channel.id));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final people = allMembers.where((member) => !member.isBot).toList();
final bots = allMembers.where((member) => member.isBot).toList();
final userCache = ref.watch(userCacheProvider);
final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id));
final statusCache = ref.watch(userStatusCacheProvider);
// Determine if the current user can manage members.
final currentMember = allMembers.cast<ChannelMember?>().firstWhere(
(m) => m!.pubkey.toLowerCase() == currentPubkey?.toLowerCase(),
orElse: () => null,
);
final canManage =
currentMember != null &&
currentMember.isElevated &&
!channel.isArchived;
void openActivity(ChannelMember bot) {
final navigator = Navigator.of(context);
navigator.pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!navigator.mounted) return;
showBuzzModalBottomSheet<void>(
context: navigator.context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => AgentActivitySheet(
channelId: channel.id,
agentPubkey: bot.pubkey,
),
);
});
}
// Preload profiles for all members so avatars appear.
useEffect(() {
if (allMembers.isNotEmpty) {
ref
.read(userCacheProvider.notifier)
.preload(allMembers.map((m) => m.pubkey).toList());
// Track user statuses for people (not bots).
final peoplePubkeys = allMembers
.where((m) => !m.isBot)
.map((m) => m.pubkey)
.toList();
if (peoplePubkeys.isNotEmpty) {
ref.read(userStatusCacheProvider.notifier).track(peoplePubkeys);
}
}
return null;
}, [allMembers.length]);
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.members, style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
if (!channel.isDm) ...[const Divider(height: 1)],
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: membersAsync.when(
data: (_) => ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(top: Grid.xxs),
children: [
if (people.isNotEmpty) ...[
_SectionLabel(
label: context.l10n.peopleCount(people.length),
),
for (final member in people)
_MemberTile(
member: member,
currentPubkey: currentPubkey,
profile: userCache[member.pubkey.toLowerCase()],
canManage: canManage,
isSelf:
member.pubkey.toLowerCase() ==
currentPubkey?.toLowerCase(),
channelId: channel.id,
userStatus: statusCache[member.pubkey.toLowerCase()],
),
],
if (bots.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
_SectionLabel(
label: context.l10n.botsCount(bots.length),
),
for (final bot in bots)
_MemberTile(
member: bot,
currentPubkey: currentPubkey,
profile: userCache[bot.pubkey.toLowerCase()],
canManage: canManage,
isSelf: false,
channelId: channel.id,
isWorking: typingBotPubkeys.contains(
bot.pubkey.toLowerCase(),
),
onViewActivity: () => openActivity(bot),
onActivityTap:
typingBotPubkeys.contains(
bot.pubkey.toLowerCase(),
)
? () => openActivity(bot)
: null,
),
],
if (people.isEmpty && bots.isEmpty)
Center(
child: Text(
context.l10n.noMembersFound,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
loading: () => Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: context.l10n.loadingMembers,
),
),
error: (_, _) => Center(
child: Text(
context.l10n.peopleLoadFailed,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
),
),
],
),
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String label;
const _SectionLabel({required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: Grid.half, bottom: Grid.half),
child: Text(
label.toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
),
),
);
}
}
const _changeableRoles = ['admin', 'member', 'guest'];
String _roleLabel(BuildContext context, String role) {
if (role.isEmpty) return context.l10n.roleMember;
if (role == 'admin') return context.l10n.roleAdmin;
if (role == 'owner') return context.l10n.roleOwner;
if (role == 'member') return context.l10n.roleMember;
if (role == 'guest') return context.l10n.roleGuest;
return '${role[0].toUpperCase()}${role.substring(1)}';
}
class _MemberTile extends ConsumerWidget {
final ChannelMember member;
final String? currentPubkey;
final UserProfile? profile;
final bool canManage;
final bool isSelf;
final String channelId;
final bool isWorking;
final VoidCallback? onActivityTap;
final VoidCallback? onViewActivity;
final UserStatus? userStatus;
const _MemberTile({
required this.member,
required this.currentPubkey,
required this.profile,
required this.canManage,
required this.isSelf,
required this.channelId,
this.isWorking = false,
this.onActivityTap,
this.onViewActivity,
this.userStatus,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final label = isSelf
? context.l10n.you
: (profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.labelFor(currentPubkey, youLabel: context.l10n.you));
final initial = label.substring(0, 1).toUpperCase();
final showManagementActions = canManage && !isSelf && !member.isOwner;
final showMenu = showManagementActions || onViewActivity != null;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial),
title: Text(label),
subtitle: isWorking
? Row(
mainAxisSize: MainAxisSize.min,
children: [
BuzzLoadingIndicator(
size: 14,
color: context.appColors.success,
semanticLabel: context.l10n.agentWorking,
),
const SizedBox(width: Grid.half),
Text(
context.l10n.working,
style: context.textTheme.bodySmall?.copyWith(
color: context.appColors.success,
fontWeight: FontWeight.w600,
),
),
],
)
: userStatus != null && !userStatus!.isEmpty
? Text(
'${userStatus!.emoji.isNotEmpty ? '${userStatus!.emoji} ' : ''}${userStatus!.text}',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
_roleLabel(context, member.role),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
trailing: showMenu
? IconButton(
icon: const Icon(LucideIcons.ellipsis, size: 18),
onPressed: () => _showMemberActions(
context,
ref,
showManagementActions: showManagementActions,
),
visualDensity: VisualDensity.compact,
)
: null,
onTap: onActivityTap,
);
}
void _showMemberActions(
BuildContext context,
WidgetRef ref, {
required bool showManagementActions,
}) {
final label = isSelf
? context.l10n.you
: (profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.labelFor(currentPubkey, youLabel: context.l10n.you));
final canChangeRole = showManagementActions && !member.isBot;
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Text(label, style: context.textTheme.titleSmall),
),
const SizedBox(height: Grid.xxs),
if (onViewActivity != null)
ListTile(
leading: Icon(
LucideIcons.activity,
size: 18,
color: context.colors.primary,
),
title: Text(context.l10n.viewActivity),
onTap: () {
Navigator.of(sheetContext).pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
onViewActivity?.call();
});
},
),
if (showManagementActions) ...[
if (canChangeRole) ...[
const SizedBox(height: Grid.xxs),
_RoleSelector(
selectedRole: member.role,
onChanged: (role) async {
Navigator.of(sheetContext).pop();
await ref
.read(channelActionsProvider)
.changeMemberRole(
channelId: channelId,
pubkey: member.pubkey,
role: role,
);
},
),
const SizedBox(height: Grid.xs),
],
ListTile(
leading: Icon(
LucideIcons.userMinus,
size: 18,
color: context.colors.error,
),
title: Text(
context.l10n.removeFromChannel,
style: TextStyle(color: context.colors.error),
),
onTap: () async {
Navigator.of(context).pop();
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.removeMember),
content: Text(context.l10n.removeMemberConfirm(label)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
context.l10n.remove,
style: TextStyle(color: context.colors.error),
),
),
],
),
);
if (confirmed == true) {
await ref
.read(channelActionsProvider)
.removeMember(
channelId: channelId,
pubkey: member.pubkey,
);
}
},
),
],
const SizedBox(height: Grid.xxs),
],
),
),
);
}
}
class _RoleSelector extends StatelessWidget {
final String selectedRole;
final ValueChanged<String> onChanged;
const _RoleSelector({required this.selectedRole, required this.onChanged});
@override
Widget build(BuildContext context) {
final hasKnownRole = _changeableRoles.contains(selectedRole);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.role,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.xxs),
SizedBox(
width: double.infinity,
child: SegmentedButton<String>(
segments: [
for (final role in _changeableRoles)
ButtonSegment<String>(
value: role,
label: Text(_roleLabel(context, role)),
),
],
selected: hasKnownRole ? {selectedRole} : const <String>{},
emptySelectionAllowed: !hasKnownRole,
showSelectedIcon: false,
style: ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
textStyle: WidgetStatePropertyAll(context.textTheme.labelSmall),
),
onSelectionChanged: (roles) {
if (roles.isEmpty) return;
final role = roles.single;
if (role == selectedRole) return;
onChanged(role);
},
),
),
],
),
);
}
}
class _MemberAvatar extends StatelessWidget {
final String? avatarUrl;
final String initial;
const _MemberAvatar({required this.avatarUrl, required this.initial});
@override
Widget build(BuildContext context) {
return AvatarImage(
imageUrl: avatarUrl,
radius: 20,
fallback: Text(initial),
);
}
}
@@ -0,0 +1,146 @@
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../profile/user_profile.dart';
import '../channel_management_provider.dart';
import 'mention_ranking.dart';
/// Whether a non-member relay agent should be mentionable by the current
/// user. Mirrors desktop's `relayAgentIsSharedWithUser`:
/// - allowlist mode: user must be on the allowlist
/// - anyone mode: agent must share at least one channel with the user
bool agentIsSharedWithUser(
AgentDirectoryEntry agent,
Set<String> sharedChannelIds,
String? currentPubkey,
) {
if (agent.respondTo == 'allowlist' && currentPubkey != null) {
return agent.respondToAllowlist.contains(currentPubkey.toLowerCase());
}
return agent.respondTo == 'anyone' &&
agent.channelIds.any(sharedChannelIds.contains);
}
/// Format the "managed by …" label. Mirrors desktop's `formatOwnerLabel`.
String? formatOwnerLabel(
String? ownerPubkey,
String? currentPubkey,
Map<String, UserProfile> userCache, {
String currentUserLabel = 'you',
}) {
if (ownerPubkey == null) return null;
final owner = ownerPubkey.toLowerCase();
if (currentPubkey != null && owner == currentPubkey.toLowerCase()) {
return currentUserLabel;
}
final profile = userCache[owner];
final name = profile?.displayName?.trim();
if (name != null && name.isNotEmpty) return name;
final handle = profile?.nip05Handle?.trim();
if (handle != null && handle.isNotEmpty) return handle;
return '${ownerPubkey.substring(0, 8)}';
}
/// Assemble the full mention candidate list: channel members first-class,
/// then eligible non-member relay agents, then global user-search results.
/// Mirrors desktop's `useMentions` candidate assembly (minus personas and
/// managed agents, which live in the desktop app's local store; the
/// owner-check on search results below covers their mention-eligibility
/// semantics).
List<MentionCandidate> buildMentionCandidates({
required List<ChannelMember> members,
required List<AgentDirectoryEntry> relayAgents,
required Set<String> sharedChannelIds,
required Map<String, UserProfile> userCache,
required Map<String, String> ownerByAgentPubkey,
List<UserProfile> searchResults = const [],
String? currentPubkey,
}) {
final candidates = <MentionCandidate>[];
final seen = <String>{};
for (final member in members) {
final pk = member.pubkey.toLowerCase();
if (!seen.add(pk)) continue;
final profile = userCache[pk];
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey;
final isAgent = member.isBot || ownerPubkey != null;
candidates.add(
MentionCandidate(
pubkey: pk,
displayName: profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: member.displayName,
secondaryLabel: profile?.nip05Handle,
avatarUrl: profile?.avatarUrl,
isAgent: isAgent,
isMember: true,
role: member.role,
ownerPubkey: ownerPubkey,
),
);
}
final directoryPubkeys = <String>{};
final sharedAgentPubkeys = <String>{};
for (final agent in relayAgents) {
directoryPubkeys.add(agent.pubkey);
if (agentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) {
sharedAgentPubkeys.add(agent.pubkey);
}
}
for (final agent in relayAgents) {
final pk = agent.pubkey;
if (seen.contains(pk)) continue;
if (!sharedAgentPubkeys.contains(pk)) continue;
seen.add(pk);
final profile = userCache[pk];
candidates.add(
MentionCandidate(
pubkey: pk,
displayName: profile?.displayName?.trim().isNotEmpty == true
? profile!.displayName!.trim()
: agent.displayName,
secondaryLabel: profile?.nip05Handle,
avatarUrl: profile?.avatarUrl,
isAgent: true,
isMember: false,
ownerPubkey: ownerByAgentPubkey[pk] ?? profile?.ownerPubkey,
),
);
}
final currentLower = currentPubkey?.toLowerCase();
for (final profile in searchResults) {
final pk = profile.pubkey.toLowerCase();
if (seen.contains(pk)) continue;
final ownerPubkey = ownerByAgentPubkey[pk] ?? profile.ownerPubkey;
final isAgent = ownerPubkey != null || directoryPubkeys.contains(pk);
if (isAgent) {
// Mirrors desktop's `shouldHideAgentFromMentions` for non-member
// agents: show only when invocable. Invocable = owned by the current
// user (desktop's managed-agent semantics, derived here from the
// verified NIP-OA owner) or shared via the relay agent directory.
final ownedByCurrentUser =
currentLower != null && ownerPubkey?.toLowerCase() == currentLower;
if (!ownedByCurrentUser && !sharedAgentPubkeys.contains(pk)) {
continue;
}
}
seen.add(pk);
candidates.add(
MentionCandidate(
pubkey: pk,
displayName: profile.displayName?.trim().isNotEmpty == true
? profile.displayName!.trim()
: null,
secondaryLabel: profile.nip05Handle,
avatarUrl: profile.avatarUrl,
isAgent: isAgent,
isMember: false,
ownerPubkey: ownerPubkey,
),
);
}
return candidates;
}
@@ -0,0 +1,107 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/crypto/nip_oa.dart';
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../../shared/relay/relay.dart';
import '../../profile/user_cache_provider.dart';
import '../../profile/user_profile.dart';
import '../channel.dart';
import '../channel_management_provider.dart';
import '../channels_provider.dart';
import 'mention_candidates.dart';
import 'mention_ranking.dart';
/// Debounce before a mention query hits the relay search endpoint.
const _mentionSearchDebounce = Duration(milliseconds: 250);
/// Global user search for mention autocomplete — kind:0 prefix search via
/// the relay HTTP bridge. Mirrors desktop's `useInfiniteUserSearchQuery`
/// feeding `useMentions` (source 4: people and agents outside the channel).
///
/// Debounced: the provider waits [_mentionSearchDebounce] before querying;
/// keystrokes dispose the stale family member so its request never fires.
final mentionUserSearchProvider = FutureProvider.autoDispose
.family<List<UserProfile>, String>((ref, query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
var disposed = false;
ref.onDispose(() => disposed = true);
await Future<void>.delayed(_mentionSearchDebounce);
if (disposed) return const [];
final session = ref.read(relaySessionProvider.notifier);
final events = await session.queryRelay([
NostrFilters.searchUsers(trimmed),
]);
// Keep only the latest kind:0 event per pubkey (the bridge does not
// honor the `kinds` filter under search, and may return several
// profile revisions — mirrors desktop's `list_user_search_results`).
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) continue;
final pk = event.pubkey.toLowerCase();
final current = latestByPubkey[pk];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pk] = event;
}
}
return [
for (final event in latestByPubkey.values) _profileFromEvent(event),
];
});
UserProfile _profileFromEvent(NostrEvent event) {
final data = ProfileData.fromEvent(event);
return UserProfile(
pubkey: event.pubkey.toLowerCase(),
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
);
}
/// Ranked mention candidates for a channel + query. Channel members first,
/// then non-member relay agents the user can actually reach, then global
/// search results; ordering matches desktop's `rankMentionCandidates`.
final mentionCandidatesProvider = Provider.family
.autoDispose<List<MentionCandidate>, ({String channelId, String query})>((
ref,
args,
) {
final members =
ref.watch(channelMembersProvider(args.channelId)).asData?.value ??
const <ChannelMember>[];
final relayAgents =
ref.watch(agentDirectoryProvider).asData?.value ??
const <AgentDirectoryEntry>[];
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
final channels =
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
final userCache = ref.watch(userCacheProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
final searchResults =
ref.watch(mentionUserSearchProvider(args.query)).asData?.value ??
const <UserProfile>[];
final sharedChannelIds = {
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel.id,
};
final candidates = buildMentionCandidates(
members: members,
relayAgents: relayAgents,
sharedChannelIds: sharedChannelIds,
userCache: userCache,
ownerByAgentPubkey: owners,
searchResults: searchResults,
currentPubkey: currentPubkey,
);
return rankMentionCandidates(candidates, args.query);
});
@@ -0,0 +1,104 @@
import 'package:flutter/foundation.dart';
/// A mention autocomplete candidate. Mirrors the desktop's
/// `MentionCandidateForRanking` (desktop/src/features/messages/lib/mentionRanking.ts).
@immutable
class MentionCandidate {
final String pubkey;
final String? displayName;
final String? secondaryLabel;
final String? avatarUrl;
final bool isAgent;
final bool isMember;
final String? role;
final String? ownerPubkey;
const MentionCandidate({
required this.pubkey,
this.displayName,
this.secondaryLabel,
this.avatarUrl,
this.isAgent = false,
this.isMember = false,
this.role,
this.ownerPubkey,
});
String get label {
final name = displayName?.trim();
if (name != null && name.isNotEmpty) return name;
return pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey;
}
}
/// Group rank: channel members, then people, then other agents.
/// Mirrors desktop's `getMentionCandidateGroupRank` (personas are a
/// desktop-only concept; their slot between members and people is unused
/// here so numbering stays aligned).
int _groupRank(MentionCandidate candidate) {
if (candidate.isMember) return 0;
if (!candidate.isAgent) return 2;
return 3;
}
/// Match-quality score for one label. Lower is better; null means no match.
/// Mirrors desktop's `scoreMentionCandidateLabel`.
int? _scoreLabel(String label, String lowerQuery) {
final lower = label.toLowerCase();
if (lower == lowerQuery) return 0;
if (lower.startsWith(lowerQuery)) return 1;
final words = lower.split(RegExp(r'[\s\-_]+')).where((w) => w.isNotEmpty);
if (words.any((word) => word == lowerQuery)) return 2;
if (words.any((word) => word.startsWith(lowerQuery))) return 3;
return null;
}
/// Rank candidates for a mention query. Mirrors desktop's
/// `rankMentionCandidates`: sort by group, then match quality, then the
/// stable original order.
List<MentionCandidate> rankMentionCandidates(
List<MentionCandidate> candidates,
String query,
) {
final lowerQuery = query.toLowerCase();
final ranked = <(MentionCandidate, int, int, int)>[];
for (var order = 0; order < candidates.length; order++) {
final candidate = candidates[order];
final labelScores = [candidate.displayName, candidate.secondaryLabel].map((
value,
) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return _scoreLabel(trimmed, lowerQuery);
}).whereType<int>();
int? score = labelScores.isEmpty
? null
: labelScores.reduce((a, b) => a < b ? a : b);
if (score == null) {
final pubkeyLower = candidate.pubkey.toLowerCase();
if (pubkeyLower.startsWith(lowerQuery)) {
score = 4;
} else if (pubkeyLower.contains(lowerQuery)) {
score = 5;
}
}
if (score == null) continue;
ranked.add((candidate, _groupRank(candidate), score, order));
}
ranked.sort((a, b) {
final group = a.$2.compareTo(b.$2);
if (group != 0) return group;
final score = a.$3.compareTo(b.$3);
if (score != 0) return score;
return a.$4.compareTo(b.$4);
});
return [for (final item in ranked) item.$1];
}
@@ -0,0 +1,978 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/deeplink/deep_link.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/emoji/native_emoji_glyph.dart';
import '../../shared/widgets/sheet_divider.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/reminders/remind_me_later_sheet.dart';
import '../../shared/reminders/reminder_service.dart';
import 'channel_management_provider.dart';
import 'emoji_picker.dart';
import 'reaction_row.dart';
import 'recent_emoji_provider.dart';
import '../../shared/read_state/message_read_state.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'thread_detail_page.dart';
import 'thread_follows/thread_follows_provider.dart';
import 'timeline_message.dart';
part 'message_actions/reaction_popover.dart';
/// Preview length for reminder targets — matches desktop's
/// `msg.body.slice(0, 100)`.
const _reminderPreviewLength = 100;
void showMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
List<TimelineMessage>? allMessages,
String? currentPubkey,
bool isMember = false,
bool isArchived = false,
Rect? anchorRect,
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
}) {
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
if (anchorRect != null && hasReactionOnlyActions) {
_showMessageReactionPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
spotlightPadding: popoverSpotlightPadding,
);
return;
}
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
showCloseButton: false,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_QuickReactionRow(
message: message,
sheetContext: sheetContext,
pageContext: context,
pageRef: ref,
),
const SizedBox(height: Grid.xs),
if (!message.isSystem) ...[
// Fast actions: respond now, hand off context, defer.
_FastActionsRow(
message: message,
channelId: channelId,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
pageContext: context,
),
const SizedBox(height: Grid.xs),
// Triage: come back to this message later.
_MarkReadUnreadTile(message: message, channelId: channelId),
_FollowThreadTile(message: message),
const SheetDivider(),
// Export: take the content out of the conversation.
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.copy),
title: Text(context.l10n.copyText),
onTap: () {
Navigator.of(sheetContext).pop();
// Copy to clipboard
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
],
if (canManageMessage) ...[
if (!message.isSystem) const SheetDivider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.pencil),
title: Text(context.l10n.editMessage),
onTap: () {
Navigator.of(sheetContext).pop();
_showEditSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
context.l10n.deleteMessage,
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
);
},
),
],
],
),
),
),
),
),
),
);
}
/// Image-focused actions shown from the full-screen viewer.
void showImageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required String imageUrl,
required bool canManageMessage,
VoidCallback? onDeleted,
}) {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.download),
title: Text(context.l10n.saveImage),
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(_saveImage(context, ref, imageUrl));
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.share2),
title: Text(context.l10n.shareImage),
onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
final shareOrigin = renderBox == null
? null
: renderBox.localToGlobal(Offset.zero) & renderBox.size;
Navigator.of(sheetContext).pop();
unawaited(
_shareImage(
context,
ref,
imageUrl,
shareOrigin: shareOrigin,
),
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.link2),
title: Text(context.l10n.copyImageLink),
onTap: () {
Navigator.of(sheetContext).pop();
copyToClipboard(
context,
imageUrl,
message: context.l10n.imageLinkCopied,
);
},
),
if (canManageMessage) ...[
const SheetDivider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
context.l10n.deleteMessage,
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
onDeleted: onDeleted,
);
},
),
],
],
),
),
),
),
);
}
@immutable
class _DownloadedImage {
final Uint8List bytes;
final String filename;
const _DownloadedImage({required this.bytes, required this.filename});
}
Future<_DownloadedImage> _downloadImage(WidgetRef ref, String imageUrl) async {
final response = await ref
.read(mediaHttpClientProvider)
.get(
Uri.parse(imageUrl),
headers: ref.read(mediaGetAuthServiceProvider).headersFor(imageUrl),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'Image download failed (${response.statusCode})',
uri: Uri.parse(imageUrl),
);
}
return _DownloadedImage(
bytes: response.bodyBytes,
filename: downloadedImageFilename(
imageUrl,
response.headers['content-type'],
),
);
}
/// Returns a safe image filename while preserving supported image formats.
@visibleForTesting
String downloadedImageFilename(String imageUrl, String? contentType) {
final pathSegments = Uri.tryParse(imageUrl)?.pathSegments;
final rawName = pathSegments == null || pathSegments.isEmpty
? ''
: pathSegments.last;
final safeName = rawName
.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '-')
.replaceAll(RegExp(r'-+'), '-');
if (RegExp(
r'\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$',
caseSensitive: false,
).hasMatch(safeName)) {
return safeName;
}
final extension = switch (contentType?.split(';').first.trim()) {
'image/avif' => '.avif',
'image/gif' => '.gif',
'image/heic' => '.heic',
'image/heif' => '.heif',
'image/png' => '.png',
'image/webp' => '.webp',
_ => '.jpg',
};
return 'buzz-${DateTime.now().millisecondsSinceEpoch}$extension';
}
Future<void> _saveImage(
BuildContext context,
WidgetRef ref,
String imageUrl,
) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final needsPhotoLibraryPermission =
defaultTargetPlatform == TargetPlatform.iOS ||
await requiresLegacyMediaStoragePermission();
if (needsPhotoLibraryPermission) {
final permission = await PhotoManager.requestPermissionExtend(
requestOption: const PermissionRequestOption(
iosAccessLevel: IosAccessLevel.addOnly,
androidPermission: AndroidPermission(
type: RequestType.image,
mediaLocation: false,
),
),
);
if (!permission.isAuth) {
throw const FileSystemException(
'Photo library permission was not granted.',
);
}
}
final image = await _downloadImage(ref, imageUrl);
await PhotoManager.editor.saveImage(image.bytes, filename: image.filename);
messenger?.showSnackBar(
SnackBar(content: Text(context.l10n.imageSaved)),
);
} catch (_) {
messenger?.showSnackBar(
SnackBar(content: Text(context.l10n.imageSaveFailed)),
);
}
}
Future<void> _shareImage(
BuildContext context,
WidgetRef ref,
String imageUrl, {
Rect? shareOrigin,
}) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final image = await _downloadImage(ref, imageUrl);
final directory = await getTemporaryDirectory();
final file = File(
'${directory.path}${Platform.pathSeparator}${image.filename}',
);
await file.writeAsBytes(image.bytes, flush: true);
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)], sharePositionOrigin: shareOrigin),
);
} catch (_) {
messenger?.showSnackBar(
SnackBar(content: Text(context.l10n.imageShareFailed)),
);
}
}
/// Canonical `buzz://message` link for a timeline message, including thread
/// context when the message is a reply.
String messageLinkFor({
required TimelineMessage message,
required String channelId,
}) {
return buildMessageLink(
channelId: channelId,
messageId: message.id,
threadRootId: message.rootId,
);
}
class _MarkReadUnreadTile extends ConsumerWidget {
final TimelineMessage message;
final String channelId;
const _MarkReadUnreadTile({required this.message, required this.channelId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final readState = ref.watch(readStateProvider);
if (!readState.isReady) return const SizedBox.shrink();
final unread = isMessageUnread(
readState,
channelId: channelId,
messageId: message.id,
createdAt: message.createdAt,
threadRootId: message.rootId,
);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(unread ? LucideIcons.mailCheck : LucideIcons.mailOpen),
title: Text(unread ? context.l10n.markRead : context.l10n.markUnread),
onTap: () {
Navigator.of(context).pop();
final notifier = ref.read(readStateProvider.notifier);
if (unread) {
// Advances the `msg:` marker and clears this message's own forced
// flag — a channel-level forced unread (channel tile) is a separate
// choice and stays untouched.
notifier.markContextRead(
msgContextKey(message.id),
message.createdAt,
);
} else {
// Read markers are monotonic and cannot move backward, so mark
// unread forces this message unread locally (session-local, does
// not survive relaunch). The channel surfaces it as unread too.
notifier.markContextUnread(
msgContextKey(message.id),
channelId: channelId,
);
}
},
);
}
}
class _FollowThreadTile extends ConsumerWidget {
final TimelineMessage message;
const _FollowThreadTile({required this.message});
@override
Widget build(BuildContext context, WidgetRef ref) {
final follows = ref.watch(threadFollowsProvider);
// Follow acts on the effective thread root: the root for replies, the
// message itself for potential thread heads (matches desktop).
final rootId = message.rootId ?? message.id;
final following = follows.isFollowing(rootId);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(following ? LucideIcons.bellOff : LucideIcons.bellRing),
title: Text(
following ? context.l10n.unfollowThread : context.l10n.followThread,
),
onTap: () {
Navigator.of(context).pop();
final notifier = ref.read(threadFollowsProvider.notifier);
if (following) {
notifier.unfollowThread(rootId);
} else {
notifier.followThread(rootId);
}
},
);
}
}
/// Promoted actions for the three dominant mobile jobs: respond now (Reply),
/// hand off context (Copy link — the `buzz://message` link is the workspace's
/// context-transfer primitive), and defer (Remind me).
class _FastActionsRow extends ConsumerWidget {
final TimelineMessage message;
final String channelId;
final List<TimelineMessage>? allMessages;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
/// The long-pressed message's page context — survives the sheet pop, used
/// for the thread push and the copy-link snackbar.
final BuildContext pageContext;
const _FastActionsRow({
required this.message,
required this.channelId,
required this.allMessages,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
required this.pageContext,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = allMessages;
final canRemind = ref.watch(reminderServiceProvider) != null;
final tiles = <Widget>[
if (messages != null)
_FastActionTile(
icon: LucideIcons.messageSquareReply,
label: context.l10n.reply,
onTap: () {
Navigator.of(context).pop();
Navigator.of(pageContext).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: messages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
),
_FastActionTile(
icon: LucideIcons.link2,
label: context.l10n.copyLink,
onTap: () {
Navigator.of(context).pop();
copyToClipboard(
pageContext,
messageLinkFor(message: message, channelId: channelId),
message: pageContext.l10n.messageLinkCopied,
);
},
),
if (canRemind)
_FastActionTile(
icon: LucideIcons.clock,
label: context.l10n.remindMe,
onTap: () {
final rootContext = Navigator.of(
context,
rootNavigator: true,
).context;
Navigator.of(context).pop();
showRemindMeLaterSheet(
context: rootContext,
ref: ref,
target: ReminderTarget(
eventId: message.id,
channelId: channelId,
preview: message.content.characters
.take(_reminderPreviewLength)
.toString(),
authorPubkey: message.pubkey,
),
);
},
),
];
return Row(
children: [
for (var index = 0; index < tiles.length; index++) ...[
Expanded(child: tiles[index]),
if (index < tiles.length - 1) const SizedBox(width: Grid.twelve),
],
],
);
}
}
class _FastActionTile extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _FastActionTile({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The full-width tiles deliberately contrast with the compact emoji
// reactions immediately above them.
Container(
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
],
),
);
}
}
/// The row of one-tap reactions at the top of the action sheet, plus the "+"
/// tile that opens the full picker.
///
/// The emoji shown are the user's own frequently-used set (desktop's
/// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so
/// the row is full on a fresh install.
class _QuickReactionRow extends ConsumerWidget {
final TimelineMessage message;
/// The sheet's context, popped before the reaction fires.
final BuildContext sheetContext;
/// The long-pressed message's page context — survives the sheet pop, so the
/// picker opened from "+" isn't torn down with the sheet.
final BuildContext pageContext;
/// The long-pressed message's page ref. The picker callback outlives this
/// bottom sheet, so it must not read through the sheet's disposed ref.
final WidgetRef pageRef;
/// Drives the staged glyph reveal when this row is shown in the popover.
/// The bottom sheet leaves this null and retains its existing static row.
final Animation<double>? presentationAnimation;
const _QuickReactionRow({
required this.message,
required this.sheetContext,
required this.pageContext,
required this.pageRef,
this.presentationAnimation,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final customEmoji = ref.watch(customEmojiListProvider);
final emoji = quickReactionEmoji(
ref.watch(recentEmojiProvider),
customShortcodes: {
for (final entry in customEmoji) entry.shortcode.toLowerCase(),
},
);
final customByShortcode = {
for (final entry in customEmoji) entry.shortcode.toLowerCase(): entry,
};
void react(String value) {
// The generic picker is also used for composing and statuses. Record
// recency here, at the reaction call site, so only reactions drive the
// quick-reaction row.
pageRef.read(recentEmojiProvider.notifier).record(value);
// The sheet is on its way out, so the burst can't come from this tile —
// hand it to the pill that's about to appear in the timeline.
armReactionBurst(pageRef, message, value);
pageRef.read(channelActionsProvider).addReaction(message.id, value);
}
return LayoutBuilder(
builder: (context, constraints) {
const desiredCircleSize = 52.0;
const minimumCircleSize = 44.0;
final itemCount = emoji.length + 1;
final gapCount = itemCount - 1;
final circleSize =
((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
.clamp(minimumCircleSize, desiredCircleSize)
.toDouble();
final gap =
((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
.clamp(0.0, Grid.twelve)
.toDouble();
final circles = <Widget>[
for (var index = 0; index < emoji.length; index++)
_ReactionItemReveal(
key: ValueKey('quick-reaction-${emoji[index]}'),
animation: presentationAnimation,
index: index,
child: _QuickReactionCircle(
size: circleSize,
onTap: () {
Navigator.of(sheetContext).pop();
react(emoji[index]);
},
child: _QuickReactionGlyph(
value: emoji[index],
customByShortcode: customByShortcode,
),
),
),
_ReactionItemReveal(
key: const ValueKey('quick-reaction-more'),
animation: presentationAnimation,
index: emoji.length,
child: _QuickReactionCircle(
size: circleSize,
onTap: () {
Navigator.of(sheetContext).pop();
showEmojiPicker(context: pageContext, onSelect: react);
},
child: Icon(
LucideIcons.plus,
size: 24,
color: context.colors.onSurfaceVariant,
),
),
),
];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var index = 0; index < circles.length; index++) ...[
circles[index],
if (index < circles.length - 1) SizedBox(width: gap),
],
],
);
},
);
}
}
class _ReactionItemReveal extends StatelessWidget {
final Animation<double>? animation;
final int index;
final Widget child;
const _ReactionItemReveal({
super.key,
required this.animation,
required this.index,
required this.child,
});
@override
Widget build(BuildContext context) {
final parent = animation;
if (parent == null) return child;
final start = 0.06 + (index * 0.11);
final opacityEnd = math.min(1.0, start + 0.20);
final scaleEnd = math.min(1.0, start + 0.30);
final opacity = CurvedAnimation(
parent: parent,
curve: Interval(start, opacityEnd, curve: Curves.easeOutCubic),
);
final scale = Tween<double>(begin: 0.86, end: 1).animate(
CurvedAnimation(
parent: parent,
curve: Interval(start, scaleEnd, curve: _reactionSpringCurve),
),
);
return FadeTransition(
opacity: opacity,
child: ScaleTransition(scale: scale, child: child),
);
}
}
/// Renders a quick-reaction value: a Unicode glyph as text, a `:shortcode:` as
/// its custom-emoji image.
class _QuickReactionGlyph extends StatelessWidget {
final String value;
final Map<String, CustomEmoji> customByShortcode;
const _QuickReactionGlyph({
required this.value,
required this.customByShortcode,
});
@override
Widget build(BuildContext context) {
if (value.startsWith(':') && value.endsWith(':')) {
final shortcode = value.substring(1, value.length - 1).toLowerCase();
final custom = customByShortcode[shortcode];
if (custom != null) {
return CustomEmojiImage(
shortcode: custom.shortcode,
url: custom.url,
size: 24,
);
}
}
return NativeEmojiGlyph(emoji: value, size: 24);
}
}
/// Circular filled tap target shared by the quick-reaction emojis and the
/// "+" emoji-picker tile — one treatment, one place to change it.
class _QuickReactionCircle extends StatelessWidget {
final VoidCallback onTap;
final Widget child;
final double size;
const _QuickReactionCircle({
required this.onTap,
required this.child,
required this.size,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Color.lerp(
context.colors.surface,
context.colors.primaryContainer,
0.78,
),
shape: BoxShape.circle,
),
child: child,
),
);
}
}
void _showEditSheet({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
}) {
final controller = TextEditingController(text: message.content);
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(sheetContext).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
autofocus: true,
minLines: 1,
maxLines: 5,
decoration: InputDecoration(hintText: context.l10n.editMessage),
),
const SizedBox(height: Grid.xxs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(context.l10n.cancel),
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: () {
final text = controller.text.trim();
if (text.isEmpty || text == message.content) {
Navigator.of(sheetContext).pop();
return;
}
ref
.read(channelActionsProvider)
.editMessage(
channelId: channelId,
eventId: message.id,
content: text,
mediaTags: buildCustomEmojiTags(
text,
ref.read(customEmojiListProvider),
),
);
Navigator.of(sheetContext).pop();
},
child: Text(context.l10n.save),
),
],
),
],
),
),
);
}
void _confirmDelete({
required BuildContext context,
required WidgetRef ref,
required String channelId,
required String messageId,
VoidCallback? onDeleted,
}) {
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.deleteMessage),
content: Text(context.l10n.cannotUndo),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(channelActionsProvider)
.deleteMessage(channelId: channelId, eventId: messageId);
onDeleted?.call();
} catch (error) {
messenger.showSnackBar(
SnackBar(
content: Text(context.l10n.deleteFailed(error.toString())),
),
);
}
},
style: FilledButton.styleFrom(
backgroundColor: dialogContext.colors.error,
),
child: Text(context.l10n.delete),
),
],
),
);
}
@@ -0,0 +1,260 @@
part of '../message_actions.dart';
const _reactionTrayMaxWidth = (52.0 * 6) + (Grid.twelve * 5) + (Grid.xxs * 2);
const _reactionTrayMaxHeight = 52.0 + (Grid.xxs * 2);
const _reactionTraySpringAllowance = 16.0;
const _reactionPopoverDuration = Duration(milliseconds: 320);
final _reactionSpringCurve = _SpringEaseOutCurve(
duration: const Duration(milliseconds: 260),
bounce: 0.22,
);
/// Presents the frequently-used reaction tray over a long-pressed message.
///
/// The conversation is frosted around [anchorRect] so the selected message
/// remains visually connected to the tray.
void _showMessageReactionPopover({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required Rect anchorRect,
required EdgeInsets spotlightPadding,
}) {
unawaited(HapticFeedback.mediumImpact());
final reduceMotion = MediaQuery.disableAnimationsOf(context);
showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: 'Dismiss reaction picker',
barrierColor: Colors.transparent,
transitionDuration: reduceMotion ? Duration.zero : _reactionPopoverDuration,
transitionBuilder: (context, animation, secondaryAnimation, child) => child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_MessageReactionPopover(
anchorRect: anchorRect,
spotlightPadding: spotlightPadding,
animation: animation,
message: message,
pageContext: context,
pageRef: ref,
),
);
}
class _MessageReactionPopover extends StatelessWidget {
final Rect anchorRect;
final EdgeInsets spotlightPadding;
final Animation<double> animation;
final TimelineMessage message;
final BuildContext pageContext;
final WidgetRef pageRef;
const _MessageReactionPopover({
required this.anchorRect,
required this.spotlightPadding,
required this.animation,
required this.message,
required this.pageContext,
required this.pageRef,
});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final safeTop = mediaQuery.padding.top + mediaQuery.viewInsets.top;
final safeBottom =
constraints.maxHeight -
mediaQuery.padding.bottom -
mediaQuery.viewInsets.bottom;
final availableAbove = anchorRect.top - Grid.xxs - safeTop;
final availableBelow = safeBottom - anchorRect.bottom - Grid.xxs;
final showAbove =
availableAbove >= _reactionTrayMaxHeight ||
(availableBelow < _reactionTrayMaxHeight &&
availableAbove >= availableBelow);
final proposedTop = showAbove
? anchorRect.top - _reactionTrayMaxHeight - Grid.xxs
: anchorRect.bottom + Grid.xxs;
final maxTop = math.max(safeTop, safeBottom - _reactionTrayMaxHeight);
final top = proposedTop.clamp(safeTop, maxTop).toDouble();
final trayScaleAlignment = showAbove
? Alignment.bottomLeft
: Alignment.topLeft;
final trayWidth = math.min(
_reactionTrayMaxWidth,
constraints.maxWidth - (Grid.xxs * 2),
);
final maxLeft = constraints.maxWidth - trayWidth - Grid.xxs;
final left = (anchorRect.center.dx - (trayWidth / 2))
.clamp(Grid.xxs, maxLeft)
.toDouble();
return Stack(
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final blurProgress = const Interval(
0,
0.30,
curve: Curves.easeOutCubic,
).transform(animation.value);
final sigma = 20 * blurProgress;
return ClipPath(
key: const ValueKey('reaction-popover-background'),
clipper: _OutsideAnchorClipper(
anchorRect,
spotlightPadding,
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
child: ColoredBox(
color: context.colors.inverseSurface.withValues(
alpha: 0.10 * blurProgress,
),
),
),
);
},
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).pop(),
),
),
Positioned(
top: top,
left: left,
width: trayWidth + _reactionTraySpringAllowance,
height: _reactionTrayMaxHeight,
child: AnimatedBuilder(
animation: animation,
child: SizedBox(
width: trayWidth,
height: _reactionTrayMaxHeight,
child: Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: _QuickReactionRow(
message: message,
sheetContext: context,
pageContext: pageContext,
pageRef: pageRef,
presentationAnimation: animation,
),
),
),
builder: (context, child) {
final appearance = const Interval(
0.04,
0.23,
curve: Curves.easeOutCubic,
).transform(animation.value);
final expansion = const Interval(
0.16,
0.92,
).transform(animation.value);
final springExpansion = _reactionSpringCurve.transform(
expansion,
);
final width = lerpDouble(
_reactionTrayMaxHeight,
trayWidth,
springExpansion,
)!;
return Opacity(
opacity: appearance,
child: Transform.scale(
alignment: trayScaleAlignment,
scale: lerpDouble(0.95, 1, appearance)!,
child: Align(
alignment: Alignment.centerLeft,
child: SizedBox(
key: const ValueKey('reaction-popover-tray'),
width: width,
height: _reactionTrayMaxHeight,
child: Material(
color: context.colors.surface,
surfaceTintColor: Colors.transparent,
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.2),
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: trayWidth,
maxWidth: trayWidth,
minHeight: _reactionTrayMaxHeight,
maxHeight: _reactionTrayMaxHeight,
child: child,
),
),
),
),
),
);
},
),
),
],
);
},
);
}
}
class _OutsideAnchorClipper extends CustomClipper<Path> {
final Rect anchorRect;
final EdgeInsets spotlightPadding;
const _OutsideAnchorClipper(this.anchorRect, this.spotlightPadding);
@override
Path getClip(Size size) {
final spotlightRect = Rect.fromLTRB(
anchorRect.left - spotlightPadding.left,
anchorRect.top - spotlightPadding.top,
anchorRect.right + spotlightPadding.right,
anchorRect.bottom + spotlightPadding.bottom,
);
return Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size)
..addRRect(
RRect.fromRectAndRadius(spotlightRect, const Radius.circular(Radii.lg)),
);
}
@override
bool shouldReclip(_OutsideAnchorClipper oldClipper) =>
oldClipper.anchorRect != anchorRect ||
oldClipper.spotlightPadding != spotlightPadding;
}
class _SpringEaseOutCurve extends Curve {
final double _durationSeconds;
final SpringSimulation _simulation;
_SpringEaseOutCurve({required Duration duration, required double bounce})
: _durationSeconds =
duration.inMicroseconds / Duration.microsecondsPerSecond,
_simulation = SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: duration,
bounce: bounce,
),
0,
1,
0,
snapToEnd: true,
);
@override
double transformInternal(double t) => _simulation.x(t * _durationSeconds);
}
@@ -0,0 +1,936 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:video_player/video_player.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/relay/relay.dart';
import '../../shared/syntax_highlight.dart';
import '../../shared/theme/theme.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/emoji/emoji_only.dart';
import 'media_viewer_page.dart';
import 'message_media.dart';
part 'message_content/media_carousel.dart';
part 'message_content/video_preview.dart';
const _messageMediaMaxInlineWidth = 320.0;
const _messageMediaMaxImageHeight = 240.0;
typedef OpenDownloadedFile =
Future<void> Function(
String url,
Map<String, String> headers,
String filename,
);
final openDownloadedFileProvider = Provider<OpenDownloadedFile>((ref) {
final client = ref.watch(mediaHttpClientProvider);
return (url, headers, filename) async {
final response = await client.get(Uri.parse(url), headers: headers);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'Attachment download failed (${response.statusCode})',
uri: Uri.parse(url),
);
}
final directory = await getTemporaryDirectory();
final safeName = _safeDownloadedFilename(filename);
final file = File(
'${directory.path}${Platform.pathSeparator}'
'${DateTime.now().microsecondsSinceEpoch}-$safeName',
);
await file.writeAsBytes(response.bodyBytes, flush: true);
final result = await OpenFilex.open(file.path);
if (result.type != ResultType.done) {
throw FileSystemException(result.message, file.path);
}
};
});
String _safeDownloadedFilename(String filename) {
final safe = filename
.split(RegExp(r'[/\\]'))
.last
.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '')
.trim();
return safe.isEmpty ? 'attachment' : safe;
}
/// Renders message content with markdown formatting, @mentions, #channel links,
/// and media-aware markdown images/videos.
class MessageContent extends HookConsumerWidget {
final String content;
/// Display names for mentioned pubkeys, extracted from event p-tags.
/// Keys are lowercase pubkeys, values are display names.
final Map<String, String> mentionNames;
/// Mentioned pubkeys that resolve to agents. Agent chips use the desktop
/// robot treatment instead of an `@` prefix.
final Set<String> agentMentionPubkeys;
/// Known channel names for #channel links. Keys are lowercase channel
/// names, values are channel IDs.
final Map<String, String> channelNames;
/// Raw event tags, used for `imeta` media metadata lookups.
final List<List<String>> tags;
/// Called when a #channel link is tapped.
final void Function(String channelId)? onChannelTap;
/// Called when an @mention of a known user is tapped, with the
/// mentioned user's pubkey.
final void Function(String pubkey)? onMentionTap;
/// Opens the message's thread from the full-screen image viewer.
final VoidCallback? onMediaReply;
/// Opens message-specific actions for an image in the full-screen viewer.
final MediaViewerMoreAction? onMediaMore;
final TextStyle? baseStyle;
/// Alignment applied to rendered markdown text.
final TextAlign? textAlign;
final int? maxLines;
/// Render a body that is nothing but emoji at [kEmojiOnlyFontSize], the way
/// desktop's `MessageRow` does. Off by default: previews, search hits, and
/// notification rows want a message to occupy its usual line height whatever
/// it contains.
final bool scaleEmojiOnly;
/// Allows a multi-image carousel to reclaim leading space reserved by the
/// surrounding message layout, while keeping its image count aligned with
/// the message body.
final double mediaCarouselLeadingOverflow;
/// Allows a multi-image carousel to continue through the trailing page
/// gutter while keeping its first image and count aligned with the body.
final double mediaCarouselTrailingOverflow;
const MessageContent({
super.key,
required this.content,
this.mentionNames = const {},
this.agentMentionPubkeys = const {},
this.channelNames = const {},
this.tags = const [],
this.onChannelTap,
this.onMentionTap,
this.onMediaReply,
this.onMediaMore,
this.baseStyle,
this.textAlign,
this.maxLines,
this.scaleEmojiOnly = false,
this.mediaCarouselLeadingOverflow = 0,
this.mediaCarouselTrailingOverflow = 0,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final baseTextStyle =
baseStyle ??
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
final resolvedMentionNames = mentionNames;
final resolvedAgentMentionPubkeys = {
...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()),
};
final imetaByUrl = parseImetaTags(tags);
final trailingGallery = maxLines == null
? _extractTrailingImageGallery(
content,
imetaByUrl,
context.l10n.messageImage,
)
: null;
final markdownContent = trailingGallery?.content ?? content;
final customEmoji = _mergeCustomEmoji(
customEmojiFromTags(tags),
ref.watch(customEmojiListProvider),
);
final mentionPresentationKey = [
for (final entry
in (resolvedMentionNames.entries.toList()
..sort((a, b) => a.key.compareTo(b.key))))
'${entry.key}\u0000${entry.value}',
...(resolvedAgentMentionPubkeys.toList()..sort()),
].join('\u0001');
// Decided here rather than by the caller: this is where the event's own
// emoji tags and the community palette have already been merged, and a
// `:shortcode:` only counts as emoji if it resolves against that palette.
final emojiOnly =
scaleEmojiOnly &&
isEmojiOnlyMessage(
markdownContent,
nativeEmoji: ref.watch(nativeEmojiGlyphsProvider),
customEmoji: customEmoji,
);
final style = emojiOnly
? baseTextStyle?.copyWith(
fontSize: kEmojiOnlyFontSize,
height: kEmojiOnlyHeight,
)
: baseTextStyle;
final inlineCustomEmojiSize = emojiOnly
? kEmojiOnlyCustomEmojiSize
: kCustomEmojiInlineSize;
final finalContent = useMemoized(() {
// Convert autolinks and bare URLs to standard markdown links,
// but skip content inside backticks (inline code / fenced blocks).
final buffer = StringBuffer();
final parts = markdownContent.split('`');
for (var i = 0; i < parts.length; i++) {
if (i.isOdd) {
// Inside backticks — preserve as-is.
buffer.write('`${parts[i]}`');
} else {
// 1. Angle-bracket autolinks: <https://...>
var segment = parts[i].replaceAllMapped(
RegExp(r'<(https?://[^>]+)>'),
(m) => '[${m[1]}](${m[1]})',
);
// 2. Bare URLs not already inside markdown link/image syntax.
// Negative lookbehind avoids matching URLs preceded by ]( or =
// which are already part of markdown links or imeta tags.
segment = segment.replaceAllMapped(
RegExp(r'(?<![(\]=])https?://[^\s)>\]]+'),
(m) {
final url = m[0]!;
// Skip if this URL is already a markdown link label that equals
// the URL (produced by step 1 or authored as [url](url)).
final start = m.start;
if (start >= 1 && segment[start - 1] == '[') return url;
return '[$url]($url)';
},
);
buffer.write(segment);
}
}
final processed = buffer.toString();
// Replace spaces with non-breaking spaces inside known mention names
// so the gpt_markdown combined regex can match multi-word names
// even when caseSensitive is not preserved.
// Skip content inside backticks to avoid altering inline code.
final mentionParts = processed.split('`');
final mentionBuf = StringBuffer();
for (var i = 0; i < mentionParts.length; i++) {
if (i.isOdd) {
mentionBuf.write('`${mentionParts[i]}`');
} else {
var segment = mentionParts[i];
for (final name in resolvedMentionNames.values) {
if (name.contains(' ')) {
final normalizedName = _markdownMentionName(name);
segment = segment.replaceAllMapped(
RegExp('@${RegExp.escape(name)}', caseSensitive: false),
(m) => '@$normalizedName',
);
}
}
mentionBuf.write(segment);
}
}
final mentionProcessed = mentionBuf.toString();
// Ensure channel links at the very start of content don't get
// swallowed by markdown processing.
var result = mentionProcessed;
if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) {
result = '\u200B$result';
}
return result;
}, [markdownContent, resolvedMentionNames]);
final markdown = KeyedSubtree(
key: ValueKey('$finalContent\u0000$mentionPresentationKey'),
child: GptMarkdown(
finalContent,
style: style,
followLinkColor: false,
codeBuilder: (context, name, code, closed) =>
_MessageCodeBlock(name: name, code: code),
linkBuilder: (context, linkText, url, linkStyle) =>
_buildLink(context, ref, linkText, url, linkStyle, style),
imageBuilder: (context, imageUrl) =>
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
textAlign: textAlign,
maxLines: maxLines,
inlineComponents: [
_MentionMd(
mentionNames: resolvedMentionNames,
agentMentionPubkeys: resolvedAgentMentionPubkeys,
onMentionTap: onMentionTap,
),
CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize),
_ChannelLinkMd(
channelNames: channelNames,
onChannelTap: onChannelTap,
),
...MarkdownComponent.inlineComponents,
],
),
);
if (trailingGallery == null) return markdown;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (trailingGallery.content.trim().isNotEmpty) markdown,
_MessageImageCarousel(
key: ValueKey(
trailingGallery.items.map((item) => item.url).join('\u0000'),
),
items: trailingGallery.items,
leadingOverflow: mediaCarouselLeadingOverflow,
trailingOverflow: mediaCarouselTrailingOverflow,
onReply: onMediaReply,
onMore: onMediaMore,
),
],
);
}
Widget _buildMedia(BuildContext context, String imageUrl, ImetaEntry? imeta) {
final mediaKind = classifyMediaUrl(imageUrl, imeta: imeta);
if (mediaKind == MessageMediaKind.video) {
return _MessageVideoPreview(
url: imageUrl,
imeta: imeta,
onReply: onMediaReply,
);
}
return _MessageImagePreview(
url: imageUrl,
imeta: imeta,
semanticLabel: imeta?.alt ?? context.l10n.messageImage,
onReply: onMediaReply,
onMore: onMediaMore,
);
}
Widget _buildLink(
BuildContext context,
WidgetRef ref,
InlineSpan linkText,
String url,
TextStyle linkStyle,
TextStyle? fallbackStyle,
) {
String text = '';
linkText.visitChildren((span) {
if (span is TextSpan && span.text != null) {
text += span.text!;
}
return true;
});
final baseStyle = fallbackStyle ?? linkStyle;
return GestureDetector(
onTap: () async {
final uri = Uri.tryParse(url);
if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) {
return;
}
final auth = ref.read(mediaGetAuthServiceProvider);
if (!auth.isRelayMediaUrl(url)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
return;
}
try {
await ref.read(openDownloadedFileProvider)(
url,
auth.headersFor(url),
text,
);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(context.l10n.openAttachmentFailed)),
);
}
},
child: Text(
text,
style: baseStyle.copyWith(
color: context.colors.primary,
decoration: TextDecoration.underline,
decorationColor: context.colors.primary,
),
),
);
}
}
List<CustomEmoji> _mergeCustomEmoji(
List<CustomEmoji> eventEmoji,
List<CustomEmoji> paletteEmoji,
) {
if (eventEmoji.isEmpty) return paletteEmoji;
if (paletteEmoji.isEmpty) return eventEmoji;
final seen = <String>{};
final merged = <CustomEmoji>[];
for (final emoji in [...eventEmoji, ...paletteEmoji]) {
if (seen.add(emoji.shortcode)) merged.add(emoji);
}
return merged;
}
class _MessageImagePreview extends HookConsumerWidget {
final String url;
final ImetaEntry? imeta;
final String semanticLabel;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const _MessageImagePreview({
required this.url,
required this.imeta,
required this.semanticLabel,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final heroTag = useMemoized(() => Object());
final layout = _resolveImagePreviewLayout(context, imeta?.aspectRatio);
final previewDecodeWidth = layout.width ?? _messageMediaMaxWidth(context);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: GestureDetector(
onTap: () => openImageViewer(
context,
imageUrl: url,
heroTag: heroTag,
semanticLabel: semanticLabel,
previewDecodeWidth: previewDecodeWidth,
aspectRatio: imeta?.aspectRatio,
onReply: onReply,
onMore: onMore,
),
child: _MessageMediaPreviewFrame(
previewKey: ValueKey('message-media-image-preview:$url'),
backgroundColor: context.colors.surfaceContainerHighest,
width: layout.width,
height: layout.height,
constraints: layout.constraints,
child: MediaViewerHero(
tag: heroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: MediaImage(
url: url,
decodeWidth: previewDecodeWidth,
fit: layout.fit,
semanticLabel: semanticLabel,
errorBuilder: (_, _, _) => _MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: context.l10n.imageUnavailable,
),
),
),
),
),
),
);
}
}
class _MessageMediaPreviewFrame extends StatelessWidget {
final Key previewKey;
final Color backgroundColor;
final double? width;
final double? height;
final BoxConstraints? constraints;
final Widget child;
const _MessageMediaPreviewFrame({
required this.previewKey,
required this.backgroundColor,
this.width,
this.height,
this.constraints,
required this.child,
});
@override
Widget build(BuildContext context) {
final resolvedWidth = constraints == null
? (width ?? _messageMediaMaxWidth(context))
: width;
return Container(
key: previewKey,
width: resolvedWidth,
height: height,
constraints: constraints,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: child,
);
}
}
double _messageMediaMaxWidth(BuildContext context) {
return math
.min(MediaQuery.sizeOf(context).width * 0.72, _messageMediaMaxInlineWidth)
.toDouble();
}
_ImagePreviewLayout _resolveImagePreviewLayout(
BuildContext context,
double? aspectRatio,
) {
if (aspectRatio == null) {
return _ImagePreviewLayout(
constraints: BoxConstraints(
maxWidth: _messageMediaMaxWidth(context),
maxHeight: _messageMediaMaxImageHeight,
),
fit: BoxFit.contain,
);
}
final previewSize = _imagePreviewSize(context, aspectRatio);
return _ImagePreviewLayout(
width: previewSize.width,
height: previewSize.height,
fit: BoxFit.cover,
);
}
Size _imagePreviewSize(BuildContext context, double? aspectRatio) {
final maxWidth = _messageMediaMaxWidth(context);
final safeAspectRatio = (aspectRatio ?? 1.0).clamp(0.2, 4.0).toDouble();
var width = maxWidth;
var height = width / safeAspectRatio;
if (height > _messageMediaMaxImageHeight) {
height = _messageMediaMaxImageHeight;
width = height * safeAspectRatio;
}
return Size(width, height);
}
class _ImagePreviewLayout {
final double? width;
final double? height;
final BoxConstraints? constraints;
final BoxFit fit;
const _ImagePreviewLayout({
this.width,
this.height,
this.constraints,
required this.fit,
});
}
class _MediaPreviewFallback extends StatelessWidget {
final IconData icon;
final String label;
const _MediaPreviewFallback({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: context.colors.surfaceContainerHighest,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: context.colors.onSurfaceVariant),
const SizedBox(height: Grid.quarter),
Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
class _MessageCodeBlock extends HookWidget {
final String name;
final String code;
const _MessageCodeBlock({required this.name, required this.code});
@override
Widget build(BuildContext context) {
final isCopied = useState(false);
Future<void> handleCopy() async {
await copyToClipboard(
context,
code,
message: context.l10n.copiedCodeToClipboard,
);
if (!context.mounted) return;
isCopied.value = true;
Future.delayed(const Duration(seconds: 2), () {
if (context.mounted) isCopied.value = false;
});
}
final codeBaseStyle = TextStyle(
fontFamily: 'GeistMono',
fontSize: 13,
height: 1.5,
color: context.colors.onSurface,
);
final isDark = context.theme.brightness == Brightness.dark;
final codeTheme = isDark ? highlightDarkTheme : highlightLightTheme;
final codeSpans = useMemoized(
() => highlightCode(code, name, codeTheme, codeBaseStyle),
[code, name, isDark],
);
return Container(
margin: const EdgeInsets.only(top: Grid.half),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: context.colors.outline.withValues(alpha: 0.7),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (name.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: Grid.twelve,
top: Grid.half + Grid.quarter,
),
child: Text(
name,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
Stack(
children: [
Padding(
padding: EdgeInsets.fromLTRB(
Grid.twelve,
name.isEmpty ? Grid.half + Grid.quarter : Grid.quarter,
44,
Grid.half + Grid.quarter,
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: RichText(
softWrap: false,
text: TextSpan(style: codeBaseStyle, children: codeSpans),
),
),
),
Positioned(
top: 0,
right: Grid.quarter,
child: SizedBox(
width: 28,
height: 28,
child: IconButton(
onPressed: handleCopy,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
isCopied.value ? LucideIcons.check : LucideIcons.copy,
size: 14,
color: isCopied.value
? context.colors.primary
: context.colors.onSurfaceVariant,
),
),
),
),
],
),
],
),
);
}
}
class _MentionMd extends InlineMd {
final Map<String, String> mentionNames;
final Set<String> agentMentionPubkeys;
final void Function(String pubkey)? onMentionTap;
late final RegExp _exp = _buildPrefixPattern(
prefix: '@',
knownNames: _mentionAliases(mentionNames.values),
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*',
);
_MentionMd({
required this.mentionNames,
required this.agentMentionPubkeys,
this.onMentionTap,
});
@override
RegExp get exp => _exp;
@override
InlineSpan span(
BuildContext context,
String text,
final GptMarkdownConfig config,
) {
final raw = exp.firstMatch(text.trim())?.group(0);
if (raw == null) {
return TextSpan(text: text, style: config.style);
}
final name = raw.substring(1).replaceAll('\u00A0', ' ').toLowerCase();
String? displayName;
String? pubkey;
for (final entry in mentionNames.entries) {
final entryName = entry.value.toLowerCase();
final firstName = entryName.split(RegExp(r'\s+')).first;
if (entryName == name || firstName == name) {
displayName = entry.value;
pubkey = entry.key;
break;
}
}
final isAgent =
pubkey != null && agentMentionPubkeys.contains(pubkey.toLowerCase());
final pill = _MentionPill(
label: displayName ?? raw.substring(1),
isAgent: isAgent,
textStyle: config.style,
);
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: pubkey != null && onMentionTap != null
? GestureDetector(onTap: () => onMentionTap!(pubkey!), child: pill)
: pill,
);
}
}
class _MentionPill extends StatelessWidget {
final String label;
final bool isAgent;
final TextStyle? textStyle;
const _MentionPill({
required this.label,
required this.isAgent,
this.textStyle,
});
@override
Widget build(BuildContext context) {
final style =
(textStyle ?? context.textTheme.bodyMedium)?.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
height: 1,
) ??
TextStyle(
color: context.colors.primary,
fontWeight: FontWeight.w500,
height: 1,
);
final fontSize = style.fontSize ?? 16;
return Container(
padding: const EdgeInsets.fromLTRB(
Grid.half,
Grid.quarter + 1,
Grid.half,
Grid.quarter,
),
decoration: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (isAgent) ...[
Icon(
LucideIcons.bot,
size: fontSize * 0.95,
color: context.colors.primary,
),
const SizedBox(width: Grid.quarter),
] else
Transform.translate(
offset: const Offset(0, -Grid.quarter),
child: Text('@', style: style),
),
Text(label, style: style),
],
),
);
}
}
class _ChannelLinkMd extends InlineMd {
final Map<String, String> channelNames;
final void Function(String channelId)? onChannelTap;
late final RegExp _exp = _buildPrefixPattern(
prefix: '#',
knownNames: channelNames.keys,
genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_-]*',
);
_ChannelLinkMd({required this.channelNames, this.onChannelTap});
@override
RegExp get exp => _exp;
@override
InlineSpan span(
BuildContext context,
String text,
final GptMarkdownConfig config,
) {
final raw = exp.firstMatch(text.trim())?.group(0);
if (raw == null) {
return TextSpan(text: text, style: config.style);
}
final channelId = channelNames[raw.substring(1).toLowerCase()];
final child = _TokenPill(
text: raw,
textStyle: config.style?.copyWith(fontWeight: FontWeight.w500),
);
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: channelId != null && onChannelTap != null
? GestureDetector(onTap: () => onChannelTap!(channelId), child: child)
: child,
);
}
}
class _TokenPill extends StatelessWidget {
final String text;
final TextStyle? textStyle;
const _TokenPill({required this.text, this.textStyle});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Text(
text,
style:
textStyle?.copyWith(color: context.colors.primary) ??
context.textTheme.bodyMedium?.copyWith(
color: context.colors.primary,
),
),
);
}
}
RegExp _buildPrefixPattern({
required String prefix,
required Iterable<String> knownNames,
required String genericTokenPattern,
}) {
final names =
knownNames
.map((name) => name.trim())
.where((name) => name.isNotEmpty)
.toSet()
.toList()
..sort((a, b) => b.length.compareTo(a.length));
final escapedPrefix = RegExp.escape(prefix);
const leadingBoundary = r'(?<![\w./:-])';
const trailingBoundary = r'(?=$|[\s,;.!?:)\]}])';
if (names.isEmpty) {
return RegExp(
'$leadingBoundary$escapedPrefix(?:$genericTokenPattern)$trailingBoundary',
caseSensitive: false,
multiLine: true,
);
}
final knownAlternatives = names.map(RegExp.escape).join('|');
return RegExp(
'$leadingBoundary$escapedPrefix(?:(?:$knownAlternatives)$trailingBoundary|(?:$genericTokenPattern)$trailingBoundary)',
caseSensitive: false,
multiLine: true,
);
}
String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0');
Iterable<String> _mentionAliases(Iterable<String> mentionNames) sync* {
for (final name in mentionNames) {
final trimmed = name.trim();
if (trimmed.isEmpty) continue;
yield _markdownMentionName(trimmed);
final firstName = trimmed.split(RegExp(r'\s+')).first;
if (firstName.isNotEmpty) {
yield firstName;
}
}
}
@@ -0,0 +1,303 @@
part of '../message_content.dart';
const _messageMediaCarouselHeight = 220.0;
@immutable
class _MessageGalleryItem {
final String url;
final String semanticLabel;
final double? aspectRatio;
const _MessageGalleryItem({
required this.url,
required this.semanticLabel,
required this.aspectRatio,
});
}
@immutable
class _TrailingImageGallery {
final String content;
final List<_MessageGalleryItem> items;
const _TrailingImageGallery({required this.content, required this.items});
}
class _MessageGalleryPrecache extends HookWidget {
final List<ImageProvider<Object>> providers;
final int focusedIndex;
const _MessageGalleryPrecache({
required this.providers,
required this.focusedIndex,
});
@override
Widget build(BuildContext context) {
final providerSignature = Object.hashAll(providers);
useEffect(() {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (cancelled || !context.mounted) {
return;
}
for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) {
if (index < 0 || index >= providers.length) {
continue;
}
unawaited(
precacheImage(providers[index], context, onError: (_, _) {}),
);
}
});
return () => cancelled = true;
}, [focusedIndex, providerSignature]);
return const SizedBox.shrink();
}
}
_TrailingImageGallery? _extractTrailingImageGallery(
String content,
Map<String, ImetaEntry> imetaByUrl,
String fallbackLabel,
) {
final lines = content.split('\n');
var cursor = lines.length - 1;
while (cursor >= 0 && lines[cursor].trim().isEmpty) {
cursor -= 1;
}
final items = <_MessageGalleryItem>[];
final imagePattern = RegExp(r'^!\[([^\]]*)\]\((https?://[^)\s]+)\)$');
while (cursor >= 0) {
final match = imagePattern.firstMatch(lines[cursor].trim());
if (match == null) break;
final url = match.group(2)!;
final imeta = imetaByUrl[url];
if (classifyMediaUrl(url, imeta: imeta) == MessageMediaKind.video) {
break;
}
final markdownLabel = match.group(1)?.trim();
items.insert(
0,
_MessageGalleryItem(
url: url,
semanticLabel:
imeta?.alt ??
(markdownLabel?.isNotEmpty == true
? markdownLabel!
: fallbackLabel),
aspectRatio: imeta?.aspectRatio,
),
);
cursor -= 1;
}
if (items.length < 2) return null;
return _TrailingImageGallery(
content: lines.take(cursor + 1).join('\n').trimRight(),
items: items,
);
}
class _MessageImageCarousel extends HookConsumerWidget {
final List<_MessageGalleryItem> items;
final double leadingOverflow;
final double trailingOverflow;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const _MessageImageCarousel({
super.key,
required this.items,
required this.leadingOverflow,
required this.trailingOverflow,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemSignature = items.map((item) => item.url).join('\u0000');
final heroTags = useMemoized(
() => [for (var index = 0; index < items.length; index++) Object()],
[itemSignature],
);
final controller = usePageController(viewportFraction: 0.9);
final currentIndex = useState(0);
final mediaAuth = ref.watch(mediaGetAuthServiceProvider);
final mediaClient = ref.watch(mediaHttpClientProvider);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.imagesCount(items.length),
key: const ValueKey('message-media-carousel-count'),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: Grid.half + Grid.quarter),
LayoutBuilder(
builder: (context, constraints) {
final contentWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: _messageMediaMaxWidth(context);
final carouselWidth =
contentWidth + leadingOverflow + trailingOverflow;
final leadingExtent = leadingOverflow;
final isLeftToRight =
Directionality.of(context) == TextDirection.ltr;
final itemTrailingPaddings = [
for (var index = 0; index < items.length; index++)
index == items.length - 1 ? Grid.gutter : Grid.half,
];
final previewDecodeWidths = [
for (var index = 0; index < items.length; index++)
math.max(
1.0,
carouselWidth * controller.viewportFraction -
itemTrailingPaddings[index],
),
];
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final previewProviders = [
for (var index = 0; index < items.length; index++)
ResizeImage.resizeIfNeeded(
(previewDecodeWidths[index] * devicePixelRatio).ceil(),
null,
MediaImageProvider(
url: items[index].url,
auth: mediaAuth,
client: mediaClient,
),
),
];
final viewerItems = [
for (var index = 0; index < items.length; index++)
MediaViewerImage(
url: items[index].url,
heroTag: heroTags[index],
semanticLabel: items[index].semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: items[index].aspectRatio,
preloadProvider: previewProviders[index],
),
];
final carousel = SizedBox(
key: const ValueKey('message-media-carousel'),
width: carouselWidth,
height: _messageMediaCarouselHeight,
child: PageView.builder(
controller: controller,
allowImplicitScrolling: true,
// Keep the first image aligned with the message body, but
// allow later pages to paint through the avatar gutter as
// the row scrolls.
clipBehavior: Clip.none,
padEnds: false,
itemCount: items.length,
onPageChanged: (index) => currentIndex.value = index,
itemBuilder: (context, index) {
final item = items[index];
return Padding(
key: ValueKey('message-media-carousel-page:${item.url}'),
padding: EdgeInsetsDirectional.only(
end: itemTrailingPaddings[index],
),
child: Semantics(
button: true,
excludeSemantics: true,
label: context.l10n.openMedia(item.semanticLabel),
child: GestureDetector(
key: ValueKey(
'message-media-carousel-item:${item.url}',
),
onTap: () => openImageViewer(
context,
imageUrl: item.url,
heroTag: heroTags[index],
semanticLabel: item.semanticLabel,
previewDecodeWidth: previewDecodeWidths[index],
aspectRatio: item.aspectRatio,
galleryItems: viewerItems,
initialIndex: index,
onReply: onReply,
onMore: onMore,
),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(
color: context.colors.outlineVariant,
),
),
child: MediaViewerHero(
tag: heroTags[index],
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: MediaImage(
url: item.url,
decodeWidth: previewDecodeWidths[index],
fit: BoxFit.cover,
semanticLabel: item.semanticLabel,
errorBuilder: (_, _, _) =>
_MediaPreviewFallback(
icon: LucideIcons.imageOff,
label: context.l10n.imageUnavailable,
),
),
),
),
),
),
),
);
},
),
);
final carouselSurface = Stack(
clipBehavior: Clip.none,
children: [
carousel,
_MessageGalleryPrecache(
providers: previewProviders,
focusedIndex: currentIndex.value,
),
],
);
if (leadingExtent <= 0 && trailingOverflow <= 0) {
return carouselSurface;
}
return SizedBox(
width: contentWidth,
height: _messageMediaCarouselHeight,
child: OverflowBox(
alignment: AlignmentDirectional.centerStart,
minWidth: carouselWidth,
maxWidth: carouselWidth,
child: Transform.translate(
offset: Offset(
isLeftToRight ? -leadingExtent : leadingExtent,
0,
),
child: carouselSurface,
),
),
);
},
),
],
),
);
}
}
@@ -0,0 +1,228 @@
part of '../message_content.dart';
/// A decoded, paused video surface used as a posterless message preview.
class LoadedVideoPreviewFrame {
/// Widget backed by the initialized native video texture.
final Widget child;
/// Aspect ratio reported by the video decoder.
final double aspectRatio;
final Future<void> Function() _dispose;
/// Creates a loaded preview frame with its resource cleanup callback.
const LoadedVideoPreviewFrame({
required this.child,
required this.aspectRatio,
required Future<void> Function() dispose,
}) : _dispose = dispose;
/// Releases the native decoder and any downloaded temporary file.
Future<void> dispose() => _dispose();
}
/// Loads a paused first-frame surface for a video URL.
typedef VideoPreviewFrameLoader =
Future<LoadedVideoPreviewFrame?> Function(String url);
/// Loader used when an older video event has no NIP-71 poster URL.
final videoPreviewFrameLoaderProvider = Provider<VideoPreviewFrameLoader>((
ref,
) {
final auth = ref.watch(mediaGetAuthServiceProvider);
return (url) => _loadVideoPreviewFrame(url, headers: auth.headersFor(url));
});
class _MessageVideoPreview extends HookConsumerWidget {
final String url;
final ImetaEntry? imeta;
final VoidCallback? onReply;
const _MessageVideoPreview({
required this.url,
required this.imeta,
required this.onReply,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final rawAspectRatio = imeta?.aspectRatio ?? (16 / 9);
final aspectRatio = rawAspectRatio.clamp(0.75, 1.91);
final posterUrl = imeta?.posterUrl;
final loadPreviewFrame = ref.watch(videoPreviewFrameLoaderProvider);
final previewFrameFuture = useMemoized(
() => posterUrl == null
? loadPreviewFrame(url)
: Future<LoadedVideoPreviewFrame?>.value(),
[loadPreviewFrame, posterUrl, url],
);
useEffect(() {
var disposed = false;
LoadedVideoPreviewFrame? loadedFrame;
unawaited(
previewFrameFuture.then((frame) {
if (disposed) {
if (frame != null) unawaited(frame.dispose());
} else {
loadedFrame = frame;
}
}),
);
return () {
disposed = true;
final frame = loadedFrame;
if (frame != null) unawaited(frame.dispose());
};
}, [previewFrameFuture]);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: GestureDetector(
onTap: () => openVideoViewer(
context,
videoUrl: url,
posterUrl: posterUrl,
onReply: onReply,
),
child: _MessageMediaPreviewFrame(
previewKey: ValueKey('message-media-video-preview:$url'),
backgroundColor: Colors.black,
child: AspectRatio(
aspectRatio: aspectRatio.toDouble(),
child: Stack(
fit: StackFit.expand,
children: [
if (posterUrl != null)
MediaImage(
url: posterUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _MediaPreviewFallback(
icon: LucideIcons.video,
label: context.l10n.videoPreviewUnavailable,
),
)
else
FutureBuilder<LoadedVideoPreviewFrame?>(
future: previewFrameFuture,
builder: (context, snapshot) {
final frame = snapshot.data;
if (frame == null) {
return _MediaPreviewFallback(
icon: LucideIcons.video,
label: context.l10n.videoAttachment,
);
}
return ColoredBox(
key: ValueKey('message-media-video-first-frame:$url'),
color: Colors.black,
child: Center(
child: AspectRatio(
aspectRatio: frame.aspectRatio,
child: frame.child,
),
),
);
},
),
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.28)),
Center(
child: Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 0.6),
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.play,
color: Colors.white,
size: 24,
),
),
),
Positioned(
left: Grid.xxs,
right: Grid.xxs,
bottom: Grid.xxs,
child: Text(
context.l10n.video,
style: context.textTheme.labelSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
),
);
}
}
Future<LoadedVideoPreviewFrame?> _loadVideoPreviewFrame(
String url, {
required Map<String, String> headers,
}) async {
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme) return null;
VideoPlayerController? controller;
Future<void> disposeResources() async {
final currentController = controller;
controller = null;
if (currentController != null) await currentController.dispose();
}
Future<bool> initialize(VideoPlayerController candidate) async {
controller = candidate;
await candidate.initialize();
final duration = candidate.value.duration;
if (duration > const Duration(milliseconds: 100)) {
await candidate.seekTo(const Duration(milliseconds: 100));
}
await candidate.pause();
return candidate.value.isInitialized;
}
try {
final networkController = VideoPlayerController.networkUrl(
uri,
httpHeaders: headers,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
if (!await initialize(networkController)) {
await disposeResources();
return null;
}
} on MissingPluginException {
await disposeResources();
return null;
} on UnimplementedError {
await disposeResources();
return null;
} catch (_) {
// A timeline preview must stay bounded: falling back to a local file here
// would fully download every posterless video encountered while scrolling.
// The full-screen viewer can use its authenticated fallback after a tap.
await disposeResources();
return null;
}
final initializedController = controller;
if (initializedController == null) return null;
var didDispose = false;
return LoadedVideoPreviewFrame(
aspectRatio: initializedController.value.aspectRatio > 0
? initializedController.value.aspectRatio
: 16 / 9,
child: VideoPlayer(initializedController),
dispose: () async {
if (didDispose) return;
didDispose = true;
await disposeResources();
},
);
}
@@ -0,0 +1,112 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// An [InkWell] whose long press is observed above interactive descendants.
class MessageLongPressInkWell extends StatelessWidget {
final VoidCallback? onTap;
final ValueChanged<Rect> onLongPress;
final BorderRadius? borderRadius;
final Color? highlightColor;
final Widget child;
const MessageLongPressInkWell({
super.key,
this.onTap,
required this.onLongPress,
this.borderRadius,
this.highlightColor,
required this.child,
});
@override
Widget build(BuildContext context) {
return _MessageLongPressRegion(
onLongPress: onLongPress,
child: InkWell(
onTap: onTap,
borderRadius: borderRadius,
highlightColor: highlightColor,
child: child,
),
);
}
}
/// Detects a message long press without competing with interactive descendants.
///
/// Links, media, reactions, and other nested controls keep their normal tap
/// gestures. Moving far enough to scroll cancels the timer; recognizing the
/// hold cancels the pointer so a descendant tap cannot fire on release.
class _MessageLongPressRegion extends HookWidget {
final ValueChanged<Rect> onLongPress;
final Widget child;
const _MessageLongPressRegion({
required this.onLongPress,
required this.child,
});
@override
Widget build(BuildContext context) {
final activePointer = useRef<int?>(null);
final origin = useRef<Offset?>(null);
final timer = useRef<Timer?>(null);
void cancel() {
timer.value?.cancel();
timer.value = null;
activePointer.value = null;
origin.value = null;
}
useEffect(() => cancel, const []);
void recognize() {
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) return;
onLongPress(renderObject.localToGlobal(Offset.zero) & renderObject.size);
}
void handlePointerDown(PointerDownEvent event) {
if (activePointer.value != null) return;
activePointer.value = event.pointer;
origin.value = event.position;
timer.value = Timer(kLongPressTimeout, () {
final pointer = activePointer.value;
if (pointer == null) return;
timer.value = null;
activePointer.value = null;
origin.value = null;
GestureBinding.instance.cancelPointer(pointer);
recognize();
});
}
void handlePointerMove(PointerMoveEvent event) {
if (event.pointer != activePointer.value) return;
final start = origin.value;
if (start == null) return;
final delta = event.position - start;
if (delta.distanceSquared > kTouchSlop * kTouchSlop) cancel();
}
void handlePointerEnd(PointerEvent event) {
if (event.pointer == activePointer.value) cancel();
}
return Semantics(
onLongPress: recognize,
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerEnd,
onPointerCancel: handlePointerEnd,
child: child,
),
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/foundation.dart';
enum MessageMediaKind { image, video }
@immutable
class ImetaEntry {
final String url;
final String? mimeType;
final String? dimensions;
final String? thumb;
final String? image;
final String? alt;
const ImetaEntry({
required this.url,
this.mimeType,
this.dimensions,
this.thumb,
this.image,
this.alt,
});
bool get isVideo => mimeType?.startsWith('video/') == true;
String? get posterUrl => image ?? thumb;
double? get aspectRatio {
final parts = dimensions?.split('x');
if (parts == null || parts.length != 2) return null;
final width = double.tryParse(parts[0]);
final height = double.tryParse(parts[1]);
if (width == null || height == null || width <= 0 || height <= 0) {
return null;
}
return width / height;
}
}
Map<String, ImetaEntry> parseImetaTags(List<List<String>> tags) {
final byUrl = <String, ImetaEntry>{};
for (final tag in tags) {
if (tag.isEmpty || tag.first != 'imeta') continue;
String? url;
String? mimeType;
String? dimensions;
String? thumb;
String? image;
String? alt;
for (final part in tag.skip(1)) {
final separator = part.indexOf(' ');
if (separator <= 0) continue;
final key = part.substring(0, separator);
final value = part.substring(separator + 1);
switch (key) {
case 'url':
url = value;
case 'm':
mimeType = value;
case 'dim':
dimensions = value;
case 'thumb':
thumb = value;
case 'image':
image = value;
case 'alt':
alt = value;
}
}
if (url == null || url.isEmpty) continue;
byUrl[url] = ImetaEntry(
url: url,
mimeType: mimeType,
dimensions: dimensions,
thumb: thumb,
image: image,
alt: alt,
);
}
return byUrl;
}
MessageMediaKind? classifyMediaUrl(String url, {ImetaEntry? imeta}) {
final mimeType = imeta?.mimeType;
if (mimeType != null) {
// An imeta MIME type is authoritative. The native video player chooses
// whether the device can decode the specific codec/container; rejecting
// every non-MP4 video here prevents it from even trying.
if (mimeType.startsWith('video/')) return MessageMediaKind.video;
if (mimeType.startsWith('image/')) return MessageMediaKind.image;
}
final path = (Uri.tryParse(url)?.path ?? url).toLowerCase();
if (path.endsWith(_mp4Extension)) {
return MessageMediaKind.video;
}
if (_imageExtensions.any(path.endsWith)) {
return MessageMediaKind.image;
}
return null;
}
const _imageExtensions = {
'.jpg',
'.jpeg',
'.png',
'.webp',
'.bmp',
'.heic',
'.heif',
'.avif',
};
const _mp4Extension = '.mp4';
@@ -0,0 +1,42 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
/// Signed local messages whose publish has not yet been corroborated by an
/// authoritative relay EVENT or query result.
class PendingLocalMessagesNotifier extends Notifier<Map<String, NostrEvent>> {
final String channelId;
PendingLocalMessagesNotifier(this.channelId);
@override
Map<String, NostrEvent> build() => const {};
void add(NostrEvent event) {
state = {...state, event.id: event};
}
NostrEvent? take(String eventId) {
final event = state[eventId];
if (event == null) return null;
final next = {...state}..remove(eventId);
state = next;
return event;
}
void confirm(Iterable<String> eventIds) {
final confirmed = eventIds.toSet();
if (!state.keys.any(confirmed.contains)) return;
state = {
for (final entry in state.entries)
if (!confirmed.contains(entry.key)) entry.key: entry.value,
};
}
}
final pendingLocalMessagesProvider =
NotifierProvider.family<
PendingLocalMessagesNotifier,
Map<String, NostrEvent>,
String
>(PendingLocalMessagesNotifier.new);
@@ -0,0 +1,104 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:photo_manager/photo_manager.dart';
const _recentPhotoCount = 30;
const _photoPermissionRequest = PermissionRequestOption(
androidPermission: AndroidPermission(
type: RequestType.image,
mediaLocation: false,
),
);
/// A recent photo exposed to Buzz's compact in-composer gallery.
@immutable
class RecentPhoto {
/// The platform photo-library identifier.
final String id;
/// Thumbnail bytes sized for the compact picker grid.
final Uint8List thumbnailBytes;
/// Creates a recent photo value.
const RecentPhoto({required this.id, required this.thumbnailBytes});
}
/// Reads recent photos and resolves selected library assets to uploadable files.
abstract interface class PhotoLibrary {
/// Requests access when needed and returns the newest visible photos.
Future<List<RecentPhoto>> loadRecentPhotos();
/// Resolves selected photos in the supplied selection order.
Future<List<XFile>> resolveSelectedPhotos(List<RecentPhoto> photos);
}
/// Indicates that the compact gallery cannot read the device photo library.
class PhotoLibraryAccessException implements Exception {
/// Creates a photo-library access error.
const PhotoLibraryAccessException();
@override
String toString() => 'Photo access is turned off for Buzz.';
}
/// Provides the device photo library. Tests can override this with fixtures.
final photoLibraryProvider = Provider<PhotoLibrary>(
(ref) => const DevicePhotoLibrary(),
);
/// The device-backed implementation of [PhotoLibrary].
class DevicePhotoLibrary implements PhotoLibrary {
/// Creates the device photo library.
const DevicePhotoLibrary();
@override
Future<List<RecentPhoto>> loadRecentPhotos() async {
final permission = await PhotoManager.requestPermissionExtend(
requestOption: _photoPermissionRequest,
);
if (!permission.hasAccess) {
throw const PhotoLibraryAccessException();
}
final assets = await PhotoManager.getAssetListPaged(
page: 0,
pageCount: _recentPhotoCount,
type: RequestType.image,
filterOption: FilterOptionGroup(
imageOption: const FilterOption(needTitle: true),
orders: const [
OrderOption(type: OrderOptionType.createDate, asc: false),
],
),
);
final loaded = await Future.wait([
for (final asset in assets) _loadRecentPhoto(asset),
]);
return [for (final photo in loaded) ?photo];
}
Future<RecentPhoto?> _loadRecentPhoto(AssetEntity asset) async {
final bytes = await asset.thumbnailDataWithSize(
const ThumbnailSize.square(256),
quality: 84,
);
if (bytes == null || bytes.isEmpty) return null;
return RecentPhoto(id: asset.id, thumbnailBytes: bytes);
}
@override
Future<List<XFile>> resolveSelectedPhotos(List<RecentPhoto> photos) async {
final resolved = <XFile>[];
for (final photo in photos) {
final asset = await AssetEntity.fromId(photo.id);
final file = await asset?.file;
if (file == null) {
throw const PhotoLibraryAccessException();
}
resolved.add(XFile(file.path, name: asset?.title));
}
return resolved;
}
}
@@ -0,0 +1,503 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/emoji/emoji_burst.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/emoji/native_emoji_glyph.dart';
import '../../shared/emoji/positive_emoji.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_management_provider.dart';
import 'emoji_picker.dart';
import 'recent_emoji_provider.dart';
import 'timeline_message.dart';
/// Pill geometry, ported from desktop's `REACTION_PILL_BASE_CLASSES` in
/// `desktop/src/features/messages/ui/MessageReactions.tsx`: a fully-rounded
/// 28px pill with a fixed minimum width so a row of single-count reactions
/// doesn't look ragged.
const _pillHeight = 28.0;
const _pillMinWidth = 48.0;
const _pillGap = 6.0;
/// The `+` pill is narrower than a reaction pill — desktop's `w-10` vs `min-w-12`.
const _addPillWidth = 40.0;
/// Glyph size inside a pill. Desktop uses 12px in a 28px pill; mobile steps that
/// up because the app's base type is 15sp and a 12px glyph reads as a smudge at
/// phone viewing distance.
const _pillGlyphSize = 16.0;
/// Toggle a reaction on [message]. If the current user already reacted with
/// [emoji], removes the reaction; otherwise adds it.
///
/// Used by channel detail, thread detail, and system message rows to avoid
/// duplicating the toggle wiring.
void toggleReaction(WidgetRef ref, TimelineMessage message, String emoji) {
final actions = ref.read(channelActionsProvider);
final reaction = message.reactions.firstWhere((r) => r.emoji == emoji);
if (reaction.reactedByCurrentUser && reaction.currentUserReactionId != null) {
actions.removeReaction(reaction.currentUserReactionId!, emoji);
} else {
// Only adding counts as a use — removing a reaction shouldn't promote the
// emoji in the frequently-used ranking.
ref.read(recentEmojiProvider.notifier).record(emoji);
actions.addReaction(message.id, emoji);
}
}
/// Arm a burst for a reaction that is about to be added from somewhere the pill
/// doesn't exist yet — the quick-reaction row or the emoji picker.
///
/// Pill taps don't go through here: they burst immediately, because the pill is
/// already on screen. Adding a reaction you already have is a no-op on the
/// relay, so it shouldn't celebrate either.
void armReactionBurst(WidgetRef ref, TimelineMessage message, String emoji) {
final alreadyReacted = message.reactions.any(
(reaction) => reaction.emoji == emoji && reaction.reactedByCurrentUser,
);
if (alreadyReacted) return;
ref.read(pendingReactionBurstProvider.notifier).arm(message.id, emoji);
}
/// Open the emoji picker and add whatever the user chooses as a reaction to
/// [message].
///
/// Shared by the `+` pill in the channel timeline and in the thread view so the
/// recency bookkeeping and burst arming can't drift apart between them.
void showAddReactionPicker({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
}) {
showEmojiPicker(
context: context,
onSelect: (emoji) {
ref.read(recentEmojiProvider.notifier).record(emoji);
armReactionBurst(ref, message, emoji);
ref.read(channelActionsProvider).addReaction(message.id, emoji);
},
);
}
class ReactionRow extends StatelessWidget {
/// The message these reactions belong to. Used to match a pending burst to
/// the right pill — the same emoji can be pending on a different message.
final String messageId;
final List<TimelineReaction> reactions;
final void Function(String emoji) onToggle;
/// Show a trailing `+` pill that opens the emoji picker, mirroring desktop's
/// `InlineReactionPicker`. Thread messages set this so reacting is always one
/// tap away; the channel timeline leaves it off to keep the list dense.
final bool showAddButton;
/// Invoked by the `+` pill. Required when [showAddButton] is set.
final VoidCallback? onAddReaction;
const ReactionRow({
super.key,
required this.messageId,
required this.reactions,
required this.onToggle,
this.showAddButton = false,
this.onAddReaction,
});
@override
Widget build(BuildContext context) {
// With the add button on, an empty row still renders — that affordance is
// the point. Without it, an empty row would be dead space.
if (reactions.isEmpty && !showAddButton) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final reaction in reactions)
_ReactionPill(
messageId: messageId,
reaction: reaction,
onTap: () => onToggle(reaction.emoji),
onLongPress: () => showReactionDetailSheet(
context: context,
reactions: reactions,
initialEmoji: reaction.emoji,
),
),
if (showAddButton && onAddReaction != null)
_AddReactionPill(onTap: onAddReaction!),
],
),
);
}
}
/// Shared pill chrome so the reaction pills and the `+` pill can't drift.
class _PillSurface extends StatelessWidget {
final bool highlighted;
final double minWidth;
final Widget child;
const _PillSurface({
required this.highlighted,
required this.minWidth,
required this.child,
});
@override
Widget build(BuildContext context) {
final colors = context.colors;
return Container(
height: _pillHeight,
// No `alignment:` — it wraps the child in an expanding Align, which makes
// every pill fill the Wrap's full width and stack one per line.
// Children center themselves instead.
constraints: BoxConstraints(minWidth: minWidth),
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
decoration: BoxDecoration(
color: highlighted
? colors.primary.withValues(alpha: 0.10)
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(_pillHeight / 2),
border: Border.all(
color: highlighted
? colors.primary.withValues(alpha: 0.40)
: colors.outlineVariant,
),
),
child: child,
);
}
}
class _ReactionPill extends HookConsumerWidget {
final String messageId;
final TimelineReaction reaction;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _ReactionPill({
required this.messageId,
required this.reaction,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final reacted = reaction.reactedByCurrentUser;
// Fire a burst armed elsewhere (quick-reaction row, emoji picker) now that
// the pill it belongs to is finally on screen. Keyed on [reacted] rather
// than watching the provider: watching would rebuild every pill in the
// timeline each time a burst is armed or claimed, and the only moments that
// matter are this pill's first build and the frame our own reaction lands.
useEffect(() {
if (!reacted) return null;
final target = PendingReactionBurst(
messageId: messageId,
emoji: reaction.emoji,
);
if (ref.read(pendingReactionBurstProvider) != target) return null;
// Post-frame so the pill has been laid out and can report its centre.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
// The same message is mounted on two routes at once when a thread is
// pushed over its channel, and the channel's copy builds first. Without
// this the channel pill claims the burst and it only becomes visible
// after popping back — the burst has to play where the user is looking.
final route = ModalRoute.of(context);
if (route != null && !route.isCurrent) return;
if (!ref.read(pendingReactionBurstProvider.notifier).claim(target)) {
return;
}
burstEmojiFromContext(ref, context, reaction.emoji);
});
return null;
}, [reacted, messageId, reaction.emoji]);
return GestureDetector(
key: ValueKey('reaction-pill-${reaction.emoji}'),
onTap: () {
// The pill is already here, so burst straight away rather than waiting
// for the relay echo — desktop does the same on a pill click.
if (!reacted && isPositiveEmojiParticle(reaction.emoji)) {
burstEmojiFromContext(ref, context, reaction.emoji);
}
onTap();
},
onLongPress: onLongPress,
child: _PillSurface(
highlighted: reacted,
minWidth: _pillMinWidth,
child: Row(
mainAxisSize: MainAxisSize.min,
// Centered so a short reaction sits mid-pill once the min width kicks
// in rather than hugging the left edge.
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ReactionEmoji(reaction: reaction, size: _pillGlyphSize),
const SizedBox(width: _pillGap),
// Desktop shows the count even at 1; hiding it made a fresh
// reaction jump in width the moment a second person joined.
Text(
'${reaction.count}',
style: reactionCountTextStyle.copyWith(
color: reacted
? context.colors.primary
: context.colors.onSurfaceVariant,
),
),
],
),
),
);
}
}
class _AddReactionPill extends StatelessWidget {
final VoidCallback onTap;
const _AddReactionPill({required this.onTap});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: context.l10n.addReaction,
child: GestureDetector(
key: const ValueKey('add-reaction-pill'),
onTap: onTap,
child: _PillSurface(
highlighted: false,
minWidth: _addPillWidth,
child: Center(
widthFactor: 1,
child: Icon(
LucideIcons.smilePlus,
size: _pillGlyphSize,
color: context.colors.onSurfaceVariant,
),
),
),
),
);
}
}
class _ReactionEmoji extends StatelessWidget {
final TimelineReaction reaction;
final double size;
const _ReactionEmoji({required this.reaction, required this.size});
@override
Widget build(BuildContext context) {
final emojiUrl = reaction.emojiUrl;
if (emojiUrl == null || emojiUrl.isEmpty) {
return NativeEmojiGlyph(emoji: reaction.emoji, size: size);
}
final shortcode = reaction.emoji.substring(1, reaction.emoji.length - 1);
return CustomEmojiImage(shortcode: shortcode, url: emojiUrl, size: size);
}
}
void showReactionDetailSheet({
required BuildContext context,
required List<TimelineReaction> reactions,
required String initialEmoji,
}) {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: context.colors.surfaceContainerHighest,
builder: (sheetContext) =>
_ReactionDetailSheet(reactions: reactions, initialEmoji: initialEmoji),
);
}
class _ReactionDetailSheet extends HookConsumerWidget {
final List<TimelineReaction> reactions;
final String initialEmoji;
const _ReactionDetailSheet({
required this.reactions,
required this.initialEmoji,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedEmoji = useState(initialEmoji);
final userCache = ref.watch(userCacheProvider);
final currentReaction = reactions.firstWhere(
(r) => r.emoji == selectedEmoji.value,
orElse: () => reactions.first,
);
// Preload profiles for reactors.
useEffect(() {
if (currentReaction.userPubkeys.isNotEmpty) {
ref
.read(userCacheProvider.notifier)
.preload(currentReaction.userPubkeys);
}
return null;
}, [currentReaction.userPubkeys]);
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.5,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Emoji filter chips (if multiple reaction types).
if (reactions.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final reaction in reactions)
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ReactionEmoji(reaction: reaction, size: 16),
const SizedBox(width: Grid.quarter),
Text('${reaction.count}'),
],
),
selected: reaction.emoji == selectedEmoji.value,
onSelected: (_) {
selectedEmoji.value = reaction.emoji;
},
),
),
],
),
),
),
// Header: emoji + shortcode.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.half,
),
child: Row(
children: [
_ReactionEmoji(reaction: currentReaction, size: 32),
const SizedBox(width: Grid.half),
Text(
// Resolved from the shared emoji-mart dataset, so this name
// matches desktop's `emojiDisplayName` for the whole set
// rather than the 28 glyphs a hardcoded map used to cover.
ref
.watch(emojiDatasetOrEmptyProvider)
.displayName(currentReaction.emoji),
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
const Divider(height: 1),
// Reactor list.
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.only(
top: Grid.half,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.half,
),
itemCount: currentReaction.userPubkeys.length,
itemBuilder: (context, index) {
final pubkey = currentReaction.userPubkeys[index];
final profile = userCache[pubkey.toLowerCase()];
return _ReactorTile(profile: profile, pubkey: pubkey);
},
),
),
],
),
);
}
}
class _ReactorTile extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
const _ReactorTile({required this.profile, required this.pubkey});
@override
Widget build(BuildContext context) {
final displayName =
profile?.label ??
(pubkey.length >= 8 ? '${pubkey.substring(0, 8)}...' : pubkey);
final about = profile?.about;
return ListTile(
leading: _ReactorAvatar(
avatarUrl: profile?.avatarUrl,
initial:
profile?.initial ??
(pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'),
),
title: Text(
displayName,
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
subtitle: about != null && about.isNotEmpty
? Text(
about,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
)
: null,
dense: true,
);
}
}
class _ReactorAvatar extends StatelessWidget {
final String? avatarUrl;
final String initial;
const _ReactorAvatar({required this.avatarUrl, required this.initial});
@override
Widget build(BuildContext context) {
return AvatarImage(
imageUrl: avatarUrl,
radius: 20,
fallback: Text(initial),
);
}
}
@@ -0,0 +1,190 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme_provider.dart';
/// Frequently-used emoji, ranked and persisted.
///
/// Mirrors desktop's `useQuickReactionEmojis` (`desktop/src/features/messages/
/// ui/useQuickReactionEmojis.ts`): rank by use count, break ties by recency,
/// cap the stored set. Same storage key shape, so the two clients describe the
/// same concept even though the stores are per-device.
///
/// Persistence is namespaced by community (relay) and account pubkey, matching
/// `ComposeDraftsNotifier` — reaction history is user behaviour and must not
/// leak across an in-place community or account switch.
const _recentEmojiPrefsKey = 'buzz.quick-reaction-emojis.v1';
/// Desktop's `DEFAULT_QUICK_REACTIONS`, plus one mobile-only slot that fits in
/// the compact action sheet, used until the user has reacted enough to fill it.
const defaultQuickEmojis = <String>[
'\u{1F44D}',
'\u{2764}\u{FE0F}',
'\u{1F602}',
'\u{1F389}',
'\u{1F525}',
];
/// Desktop's `MAX_STORED_REACTIONS`.
const _maxStoredEmoji = 24;
/// One persisted frequently-used emoji ranking entry.
///
/// [emoji] is the Unicode glyph or custom shortcode, [count] is how many times
/// it has been selected, and [lastUsedAt] is the most recent selection time in
/// milliseconds since the Unix epoch.
class RecentEmojiEntry {
/// The Unicode glyph or `:shortcode:` value that was selected.
final String emoji;
/// Number of recorded selections for [emoji].
final int count;
/// Most recent selection time in milliseconds since the Unix epoch.
final int lastUsedAt;
/// Creates a persisted ranking entry for [emoji].
const RecentEmojiEntry({
required this.emoji,
required this.count,
required this.lastUsedAt,
});
Map<String, dynamic> toJson() => {
'emoji': emoji,
'count': count,
'lastUsedAt': lastUsedAt,
};
/// Returns null for anything malformed, so one bad record can't poison the
/// whole list.
static RecentEmojiEntry? fromJson(dynamic raw) {
if (raw is! Map) return null;
final emoji = raw['emoji'];
if (emoji is! String || emoji.trim().isEmpty) return null;
final count = raw['count'];
final lastUsedAt = raw['lastUsedAt'];
return RecentEmojiEntry(
emoji: emoji,
count: count is int && count > 0 ? count : 1,
lastUsedAt: lastUsedAt is int && lastUsedAt > 0 ? lastUsedAt : 0,
);
}
}
/// Sort by count desc, then most-recent-first — desktop's `sortEntries`.
List<RecentEmojiEntry> sortRecentEmoji(List<RecentEmojiEntry> entries) {
final sorted = [...entries];
sorted.sort((left, right) {
final byCount = right.count - left.count;
if (byCount != 0) return byCount;
return right.lastUsedAt - left.lastUsedAt;
});
return sorted;
}
/// Record [emoji] against [entries], returning the new capped, sorted list.
/// Pure so it can be tested without SharedPreferences.
List<RecentEmojiEntry> recordRecentEmoji(
List<RecentEmojiEntry> entries,
String emoji, {
required int now,
}) {
if (emoji.trim().isEmpty) return entries;
final next = <RecentEmojiEntry>[];
var matched = false;
for (final entry in entries) {
if (entry.emoji == emoji) {
matched = true;
next.add(
RecentEmojiEntry(emoji: emoji, count: entry.count + 1, lastUsedAt: now),
);
} else {
next.add(entry);
}
}
if (!matched) {
next.add(RecentEmojiEntry(emoji: emoji, count: 1, lastUsedAt: now));
}
final sorted = sortRecentEmoji(next);
return sorted.length <= _maxStoredEmoji
? sorted
: sorted.sublist(0, _maxStoredEmoji);
}
class RecentEmojiNotifier extends Notifier<List<RecentEmojiEntry>> {
late String _prefsKey;
@override
List<RecentEmojiEntry> build() {
final config = ref.watch(relayConfigProvider);
final pubkey = ref.watch(myPubkeyProvider) ?? 'anon';
_prefsKey = '$_recentEmojiPrefsKey:${config.baseUrl}:$pubkey';
final raw = ref.read(savedPrefsProvider).getString(_prefsKey);
if (raw == null || raw.isEmpty) return const [];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return const [];
return sortRecentEmoji(
decoded.map(RecentEmojiEntry.fromJson).nonNulls.toList(),
);
} catch (_) {
return const [];
}
}
/// Record one use of [emoji]. Called from every place a user picks an emoji:
/// the picker, the quick-reaction row, and toggling an existing pill on.
void record(String emoji) {
final next = recordRecentEmoji(
state,
emoji,
now: DateTime.now().millisecondsSinceEpoch,
);
if (identical(next, state)) return;
state = next;
ref
.read(savedPrefsProvider)
.setString(
_prefsKey,
jsonEncode(next.map((entry) => entry.toJson()).toList()),
);
}
}
final recentEmojiProvider =
NotifierProvider<RecentEmojiNotifier, List<RecentEmojiEntry>>(
RecentEmojiNotifier.new,
);
/// The top [limit] emoji for a quick-reaction row, topped up with
/// [defaultQuickEmojis] so the row is never short. Custom-emoji shortcodes are
/// dropped when they are no longer in the community palette — desktop's
/// `canRenderQuickReactionEmoji` guard, which stops a removed custom emoji from
/// rendering as literal `:shortcode:` text.
List<String> quickReactionEmoji(
List<RecentEmojiEntry> entries, {
required Set<String> customShortcodes,
int limit = 5,
}) {
final result = <String>[];
void add(String emoji) {
if (result.length >= limit || result.contains(emoji)) return;
if (emoji.startsWith(':') && emoji.endsWith(':')) {
final shortcode = emoji.substring(1, emoji.length - 1).toLowerCase();
if (!customShortcodes.contains(shortcode)) return;
}
result.add(emoji);
}
for (final entry in entries) {
add(entry.emoji);
}
for (final emoji in defaultQuickEmojis) {
add(emoji);
}
return result;
}
@@ -0,0 +1,201 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_messages_provider.dart';
/// Sends messages by signing an event with the user's nsec and publishing it
/// over the relay's NIP-42-authenticated WebSocket session.
class SendMessage {
final SignedEventRelay _signedEventRelay;
final Future<List<ChannelMember>> Function(String channelId) _fetchMembers;
final Map<String, UserProfile> Function() _readUserCache;
final void Function(String channelId, NostrEvent event) _addLocalMessage;
final void Function(String channelId, String eventId) _completeLocalMessage;
final void Function(String channelId, String eventId) _removeLocalMessage;
final bool Function()? _isDeliveryValid;
SendMessage({
required SignedEventRelay signedEventRelay,
required Future<List<ChannelMember>> Function(String channelId)
fetchMembers,
required Map<String, UserProfile> Function() readUserCache,
required void Function(String channelId, NostrEvent event) addLocalMessage,
required void Function(String channelId, String eventId)
completeLocalMessage,
required void Function(String channelId, String eventId) removeLocalMessage,
bool Function()? isDeliveryValid,
}) : _signedEventRelay = signedEventRelay,
_fetchMembers = fetchMembers,
_readUserCache = readUserCache,
_addLocalMessage = addLocalMessage,
_completeLocalMessage = completeLocalMessage,
_removeLocalMessage = removeLocalMessage,
_isDeliveryValid = isDeliveryValid;
/// Send a text message to a channel.
///
/// For thread replies, pass [parentEventId] and optionally [rootEventId].
/// If [rootEventId] is null it defaults to [parentEventId] (direct reply to
/// thread head). Tags are built to match the desktop's `buildReplyTags`
/// convention with `root` / `reply` markers. Pass [mediaTags] to append
/// relay-validated `imeta` tags and NIP-30 `emoji` tags.
Future<void> call({
required String channelId,
required String content,
String? parentEventId,
String? rootEventId,
List<String>? mentionPubkeys,
List<List<String>> mediaTags = const [],
}) async {
_ensureDeliveryValid();
// Use explicitly passed pubkeys, or resolve @mentions against
// channel members to avoid matching the wrong user.
final resolvedMentions =
mentionPubkeys ?? await _resolveMentions(content, channelId);
final authorPubkey = _signedEventRelay.pubkey;
// Normalize mentions: lowercase, deduplicate, exclude self (matching
// the desktop's normalizeMentionPubkeys).
final selfLower = authorPubkey?.toLowerCase();
final seenMentions = <String>{?selfLower};
final normalizedMentions = <String>[
for (final pk in resolvedMentions)
if (seenMentions.add(pk.toLowerCase())) pk,
];
final tags = <List<String>>[
['h', channelId],
if (parentEventId != null) ..._buildReplyTags(parentEventId, rootEventId),
for (final pk in normalizedMentions) ['p', pk],
...mediaTags,
];
_ensureDeliveryValid();
NostrEvent? localMessage;
try {
await _signedEventRelay.submit(
kind: EventKind.streamMessage,
content: content,
tags: tags,
onSigned: (event) {
localMessage = event;
_addLocalMessage(channelId, event);
},
);
final event = localMessage;
if (event != null) _completeLocalMessage(channelId, event.id);
} catch (_) {
final event = localMessage;
if (event != null) _removeLocalMessage(channelId, event.id);
rethrow;
}
}
void _ensureDeliveryValid() {
if (_isDeliveryValid?.call() == false) {
throw StateError(
'Message delivery cancelled because the active community changed',
);
}
}
/// Resolve @mentions to pubkeys, scoped to channel members.
///
/// Fetches channel members from the relay and matches @names only
/// against members of that channel. Falls back to the full user cache
/// if the member fetch fails.
Future<List<String>> _resolveMentions(
String content,
String channelId,
) async {
final mentionPattern = RegExp(r'@(\w+)');
final matches = mentionPattern.allMatches(content);
if (matches.isEmpty) return const [];
// Try to get channel member pubkeys for scoped resolution.
Set<String>? memberPubkeys;
try {
final members = await _fetchMembers(channelId);
memberPubkeys = {for (final m in members) m.pubkey.toLowerCase()};
} catch (_) {
// Non-fatal — fall through to unscoped cache lookup.
}
final cache = _readUserCache();
final pubkeys = <String>{};
for (final match in matches) {
final name = match.group(1)?.toLowerCase();
if (name == null || name.isEmpty) continue;
for (final profile in cache.values) {
final displayName = profile.displayName?.toLowerCase();
if (displayName == null) continue;
// Match against full display name or first word.
final firstName = displayName.split(RegExp(r'\s+')).first;
if (displayName != name && firstName != name) continue;
// If we have channel members, only match members of this channel.
if (memberPubkeys != null &&
!memberPubkeys.contains(profile.pubkey.toLowerCase())) {
continue;
}
pubkeys.add(profile.pubkey);
break;
}
}
return pubkeys.toList();
}
/// Build `e`-tags for a thread reply, matching the desktop convention:
/// - Direct reply to thread head: `["e", id, "", "reply"]`
/// - Nested reply: `["e", rootId, "", "root"]` + `["e", parentId, "", "reply"]`
static List<List<String>> _buildReplyTags(
String parentEventId,
String? rootEventId,
) {
final root = rootEventId ?? parentEventId;
if (parentEventId == root) {
return [
['e', root, '', 'reply'],
];
}
return [
['e', root, '', 'root'],
['e', parentEventId, '', 'reply'],
];
}
}
final sendMessageProvider = Provider<SendMessage>((ref) {
final config = ref.watch(relayConfigProvider);
return SendMessage(
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: config.nsec,
),
fetchMembers: (channelId) =>
ref.read(channelMembersProvider(channelId).future),
readUserCache: () => ref.read(userCacheProvider),
addLocalMessage: (channelId, event) => ref
.read(channelMessagesProvider(channelId).notifier)
.addLocalMessage(event),
completeLocalMessage: (channelId, eventId) => ref
.read(channelMessagesProvider(channelId).notifier)
.completeLocalMessage(eventId),
removeLocalMessage: (channelId, eventId) => ref
.read(channelMessagesProvider(channelId).notifier)
.removeLocalMessage(eventId),
isDeliveryValid: () {
final currentConfig = ref.read(relayConfigProvider);
return currentConfig.baseUrl == config.baseUrl &&
currentConfig.nsec == config.nsec;
},
);
});
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../profile/user_profile.dart';
/// 20px circle avatar used in thread summary rows and other compact lists.
class SmallAvatar extends StatelessWidget {
final String pubkey;
final Map<String, UserProfile> userCache;
final double size;
const SmallAvatar({
super.key,
required this.pubkey,
required this.userCache,
this.size = 20,
});
@override
Widget build(BuildContext context) {
final profile = userCache[pubkey.toLowerCase()];
final avatarUrl = profile?.avatarUrl;
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: context.colors.surface, width: 1.5),
),
child: AvatarImage(
imageUrl: avatarUrl,
radius: (size - 2) / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: TextStyle(
fontSize: size * 0.4,
fontWeight: FontWeight.w600,
color: context.colors.onPrimaryContainer,
),
),
),
);
}
}
@@ -0,0 +1,999 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_link_navigation.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'channel_typing_indicator.dart';
import 'thread_replies_provider.dart';
import 'channels_provider.dart';
import 'compose_bar.dart';
import 'composer_dock_size_reporter.dart';
import 'date_formatters.dart';
import 'day_divider.dart';
import '../profile/user_profile_sheet.dart';
import 'message_actions.dart';
import 'message_long_press_region.dart';
import 'message_content.dart';
import 'reaction_row.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'send_message_provider.dart';
import 'small_avatar.dart';
import 'timeline_message.dart';
/// Full-screen thread detail page.
///
/// Shows the thread head message, direct replies, typing indicators scoped to
/// the thread, and a compose bar for replying.
class ThreadDetailPage extends HookConsumerWidget {
final TimelineMessage threadHead;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
final String? initialMessageId;
const ThreadDetailPage({
super.key,
required this.threadHead,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
this.initialMessageId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final composerDockHeight = useState(0.0);
final sendMessage = ref.read(sendMessageProvider);
// Relay thread queries are keyed by the outermost root, even when this
// page displays a nested branch. Query that root, then select this head's
// direct children from the returned subtree below.
final queryRootId = threadHead.rootId ?? threadHead.id;
final repliesState = ref.watch(
threadRepliesWithLocalProvider(
ThreadRepliesArgs(channelId: channelId, rootId: queryRootId),
),
);
// The thread query is one-shot and asks only for content kinds, so a
// reaction, edit, or deletion that lands while the thread is open never
// reaches it — a new pill (and its burst) only showed up after leaving and
// re-entering, which refetched. The channel socket already receives those
// events, so union the two sources and format once.
final liveChannelEvents =
ref.watch(channelMessagesProvider(channelId)).value ??
const <NostrEvent>[];
final replyMessages = repliesState.whenData((events) {
return formatTimeline(
mergeThreadEvents(events, liveChannelEvents),
currentPubkey: currentPubkey,
);
});
final fetchedReplies = replyMessages.value;
final liveDeletionHidesHead = _isDeletedBy(
liveChannelEvents,
threadHead.id,
);
final allMsgs = fetchedReplies == null
? allMessages
: [
// Only fall back to the pushed-route snapshot when neither source
// carries the head, and no live deletion has suppressed it. That
// keeps a temporarily unavailable head visible without restoring
// a head that was deleted while this page was open.
if (!liveDeletionHidesHead &&
!fetchedReplies.any((message) => message.id == threadHead.id))
threadHead,
...fetchedReplies,
];
// Index all messages by parentId so we can find direct children of any
// message and compute thread summaries for nested threads.
final childrenByParent = <String, List<TimelineMessage>>{};
for (final msg in allMsgs) {
final pid = msg.parentId;
if (pid == null) continue;
childrenByParent.putIfAbsent(pid, () => []).add(msg);
}
final replies = childrenByParent[threadHead.id] ?? const [];
final itemScrollController = useMemoized(ItemScrollController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final didJumpToInitialMessage = useRef(false);
final followsThreadTail = useRef(false);
final pendingTailAlignment = useRef<double?>(null);
final tailRealignmentQueued = useRef(false);
// Item 0 is the thread head; reply `i` lives at `i + 1`.
const headIndex = 0;
int indexForReply(int chronologicalIndex) => chronologicalIndex + 1;
bool threadTailIsVisible() {
final lastIndex = replies.isEmpty
? headIndex
: indexForReply(replies.length - 1);
return itemPositionsListener.itemPositions.value.any(
(position) =>
position.index == lastIndex && position.itemTrailingEdge <= 1.001,
);
}
useEffect(() {
void onPositionsChanged() {
if (threadTailIsVisible()) followsThreadTail.value = true;
}
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
return () => itemPositionsListener.itemPositions.removeListener(
onPositionsChanged,
);
}, [itemPositionsListener, replies.length]);
useEffect(() {
final messageId = initialMessageId;
// Wait for the authoritative thread query before consuming the one-shot
// jump; the fallback main-timeline list can contain only the linked reply.
if (messageId == null || fetchedReplies == null) return null;
final chronologicalIndex = replies.indexWhere(
(reply) => reply.id == messageId,
);
final targetIndex = messageId == threadHead.id
? headIndex
: chronologicalIndex < 0
? null
: indexForReply(chronologicalIndex);
if (targetIndex == null || didJumpToInitialMessage.value) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !itemScrollController.isAttached) return;
// The provisional route snapshot can make the linked reply look like
// the tail. This authoritative deep-link jump intentionally leaves
// the user at an older item, so it must opt out of follow-tail first.
followsThreadTail.value = false;
pendingTailAlignment.value = null;
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
didJumpToInitialMessage.value = true;
});
return null;
}, [initialMessageId, fetchedReplies, replies.length]);
// A top-anchored list doesn't stick to the newest item the way the old
// reversed one did, so follow the tail explicitly: when a reply arrives
// while the last item is on screen, scroll it into view. If the user has
// scrolled up to read, leave them where they are.
final hasFetchedReplies = fetchedReplies != null;
final didEstablishInitialReplies = useRef(hasFetchedReplies);
final previousReplyCount = useRef(replies.length);
useEffect(() {
// The first authoritative query result is hydration, not a live arrival.
// Establish the baseline without moving the user away from the head.
if (!hasFetchedReplies) return null;
if (!didEstablishInitialReplies.value) {
didEstablishInitialReplies.value = true;
previousReplyCount.value = replies.length;
return null;
}
final previous = previousReplyCount.value;
previousReplyCount.value = replies.length;
if (replies.length <= previous) return null;
final positions = itemPositionsListener.itemPositions.value;
final lastIndex = indexForReply(replies.length - 1);
// Positions still describe the list as it was *before* these replies, so
// compare against the old tail. Measuring against the new one only reads
// as "at the tail" when exactly one reply arrived.
final previousLastIndex = previous == 0
? headIndex
: indexForReply(previous - 1);
final wasAtTail =
positions.isEmpty ||
positions.any((position) => position.index >= previousLastIndex);
final localPubkey = currentPubkey?.toLowerCase();
final hasNewLocalReply =
localPubkey != null &&
replies
.skip(previous)
.any((reply) => reply.pubkey.toLowerCase() == localPubkey);
// A reply the current user just sent must be visible even if they were
// reading at the head of a long thread. Remote arrivals still respect
// the user's scroll position.
if (!wasAtTail && !hasNewLocalReply) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !itemScrollController.isAttached) return;
itemScrollController.scrollTo(
index: lastIndex,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
});
return null;
}, [hasFetchedReplies, replies.length]);
final readState = ref.watch(readStateProvider);
final visibleReplyReadKey = replies
.map((reply) => '${reply.id}:${reply.createdAt}')
.join(',');
useEffect(() {
if (!readState.isReady || replies.isEmpty) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
for (final reply in replies) {
ref
.read(readStateProvider.notifier)
.markContextRead(msgContextKey(reply.id), reply.createdAt);
}
});
return null;
}, [threadHead.id, readState.isReady, visibleReplyReadKey]);
// Thread-scoped typing indicators (exclude self).
final allTyping = ref.watch(channelTypingProvider(channelId));
final threadTyping = allTyping
.where((e) => e.threadHeadId == threadHead.id)
.where(
(e) =>
currentPubkey == null ||
e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(),
)
.toList();
// Resolve thread head from live data (reactions/edits may have changed).
final liveHead =
allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead;
// The root of the entire thread chain. If the current thread head is
// itself a root message its rootId is null, so fall back to its own id.
final effectiveRootId = threadHead.rootId ?? threadHead.id;
void updateComposerDockHeight(double height) {
final previousHeight = composerDockHeight.value;
final heightDelta = height - previousHeight;
if (heightDelta.abs() < 0.5) return;
final shouldFollowTail = followsThreadTail.value || threadTailIsVisible();
if (shouldFollowTail) followsThreadTail.value = true;
composerDockHeight.value = height;
if (heightDelta <= 0 || !shouldFollowTail) {
pendingTailAlignment.value = null;
return;
}
final lastIndex = replies.isEmpty
? headIndex
: indexForReply(replies.length - 1);
final lastPosition = itemPositionsListener.itemPositions.value
.where((position) => position.index == lastIndex)
.firstOrNull;
if (lastPosition == null) return;
final targetAlignment =
(pendingTailAlignment.value ?? lastPosition.itemLeadingEdge) -
(heightDelta / MediaQuery.sizeOf(context).height);
pendingTailAlignment.value = targetAlignment;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !itemScrollController.isAttached) return;
itemScrollController.jumpTo(
index: lastIndex,
alignment: targetAlignment,
);
});
}
// Composer size changes and keyboard metrics changes are independent:
// the dock grows first, then the Scaffold's viewport shrinks once the
// keyboard appears. Re-align after that latter layout pass too, but only
// while the user was already following the thread tail.
void realignThreadTailAfterMetricsChange() {
final shouldFollowTail = followsThreadTail.value || threadTailIsVisible();
if (!shouldFollowTail || tailRealignmentQueued.value) return;
followsThreadTail.value = true;
tailRealignmentQueued.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
tailRealignmentQueued.value = false;
if (!context.mounted ||
!itemScrollController.isAttached ||
!followsThreadTail.value) {
return;
}
final lastIndex = replies.isEmpty
? headIndex
: indexForReply(replies.length - 1);
itemScrollController.scrollTo(
index: lastIndex,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
});
}
useEffect(() {
final observer = _ThreadTailMetricsObserver(
onMetricsChanged: realignThreadTailAfterMetricsChange,
);
WidgetsBinding.instance.addObserver(observer);
return () => WidgetsBinding.instance.removeObserver(observer);
}, [itemScrollController, replies.length]);
// Channel names for message content rendering.
final channelsAsync = ref.watch(channelsProvider);
final channelNamesMap = <String, String>{};
channelsAsync.whenData((channels) {
for (final ch in channels) {
channelNamesMap[ch.name.toLowerCase()] = ch.id;
}
});
return FrostedScaffold(
appBar: FrostedAppBar(
title: Text(context.l10n.threadTitle),
titleStyle: channelTitleTextStyle,
),
body: Stack(
fit: StackFit.expand,
children: [
Column(
children: [
Expanded(
child: KeyboardDismissOnDrag(
onUserScrollStart: () {
followsThreadTail.value = false;
pendingTailAlignment.value = null;
},
child: ScrollablePositionedList.builder(
key: const ValueKey('thread-message-list'),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
// Top-anchored, head first, replies flowing down — matching
// desktop's thread panel. The old reversed list bottom-anchored
// the content, which jammed the head against the composer
// whenever a thread had only a handful of replies.
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(context),
bottom: Grid.xs + composerDockHeight.value,
),
itemCount: replies.length + 1, // +1 for thread head
itemBuilder: (context, index) {
if (index == headIndex) {
if (liveDeletionHidesHead) {
return Padding(
key: const ValueKey('thread-message-deleted'),
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Text(context.l10n.messageDeleted),
);
}
return Padding(
key: ValueKey('thread-message-group-${liveHead.id}'),
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DayDivider(
label: formatDayHeading(
liveHead.createdAt,
l10n: context.l10n,
),
),
_ThreadMessage(
message: liveHead,
channelNames: channelNamesMap,
channelId: channelId,
currentPubkey: currentPubkey,
showAuthor: true,
isHighlighted: liveHead.id == initialMessageId,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
isThreadHead: true,
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.xxs,
),
child: Row(
children: [
Text(
context.l10n.replyCount(replies.length),
style: context.textTheme.labelMedium
?.copyWith(
color:
context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Divider(
color: context.colors.outlineVariant,
),
),
],
),
),
],
),
);
}
// Chronological list: index 1 = oldest reply.
final chronIdx = index - 1;
final reply = replies[chronIdx];
final prevReply = chronIdx > 0
? replies[chronIdx - 1]
: null;
final previousMessage = prevReply ?? liveHead;
final showDayDivider = !isSameDay(
previousMessage.createdAt,
reply.createdAt,
);
final showAuthor =
prevReply == null ||
showDayDivider ||
prevReply.pubkey.toLowerCase() !=
reply.pubkey.toLowerCase() ||
(reply.createdAt - prevReply.createdAt) > 300;
// Check if this reply itself has children (nested thread).
final nestedChildren = childrenByParent[reply.id];
final nestedSummary =
nestedChildren != null && nestedChildren.isNotEmpty
? _buildNestedSummary(reply.id, nestedChildren)
: null;
return Padding(
key: ValueKey('thread-message-group-${reply.id}'),
// Tail spacing comes from the list's own bottom padding now
// that the list runs top-down; the reversed list used to
// need it here because item 0 sat against the composer.
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(
label: formatDayHeading(
reply.createdAt,
l10n: context.l10n,
),
),
_ThreadMessage(
message: reply,
channelNames: channelNamesMap,
channelId: channelId,
currentPubkey: currentPubkey,
showAuthor: showAuthor,
isHighlighted: reply.id == initialMessageId,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
),
if (nestedSummary != null)
_NestedThreadSummaryRow(
summary: nestedSummary,
replyMessage: reply,
allMessages: allMsgs,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
],
),
);
},
),
),
),
if (!isMember || isArchived)
AnimatedSize(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: threadTyping.isEmpty
? const SizedBox.shrink()
: ChannelTypingIndicator(entries: threadTyping),
),
],
),
if (isMember && !isArchived)
Align(
alignment: Alignment.bottomCenter,
child: ComposerDockSizeReporter(
key: const ValueKey('thread-composer-dock'),
onHeightChanged: updateComposerDockHeight,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSize(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: threadTyping.isEmpty
? const SizedBox.shrink()
: ChannelTypingIndicator(entries: threadTyping),
),
ComposeBar(
channelId: channelId,
hintText: context.l10n.replyInThreadHint,
threadHeadId: threadHead.id,
rootId: effectiveRootId,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) => sendMessage.call(
channelId: channelId,
content: content,
mentionPubkeys: mentionPubkeys,
parentEventId: threadHead.id,
rootEventId: effectiveRootId,
mediaTags: mediaTags,
),
),
],
),
),
),
],
),
);
}
}
bool _isDeletedBy(Iterable<NostrEvent> events, String messageId) {
for (final event in events) {
if (event.kind != EventKind.deletion &&
event.kind != EventKind.nip29DeleteEvent) {
continue;
}
if (event.tags.any(
(tag) => tag.length >= 2 && tag[0] == 'e' && tag[1] == messageId,
)) {
return true;
}
}
return false;
}
/// Build a lightweight summary for a nested thread (reply that has its own
/// replies). Same logic as the top-level [ThreadSummary] but kept local to
/// avoid coupling.
ThreadSummary _buildNestedSummary(
String messageId,
List<TimelineMessage> children,
) {
final seen = <String>{};
final participants = <String>[];
for (var i = children.length - 1; i >= 0 && participants.length < 3; i--) {
final pk = children[i].pubkey.toLowerCase();
if (seen.add(pk)) participants.add(pk);
}
return ThreadSummary(
threadHeadId: messageId,
replyCount: children.length,
participantPubkeys: participants.reversed.toList(),
lastReplyAt: children.last.createdAt,
);
}
/// Tappable summary row shown below a reply that itself has replies.
/// Pushes a new [ThreadDetailPage] for the nested thread.
class _NestedThreadSummaryRow extends ConsumerWidget {
final ThreadSummary summary;
final TimelineMessage replyMessage;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _NestedThreadSummaryRow({
required this.summary,
required this.replyMessage,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: replyMessage,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
child: Padding(
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.half,
bottom: Grid.xs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Stacked participant avatars.
SizedBox(
width:
32.0 +
(summary.participantPubkeys.length - 1).clamp(0, 2) * 20.0,
height: 32,
child: Stack(
children: [
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 20.0,
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
size: 32,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
context.l10n.replyCount(summary.replyCount),
style: replyPreviewTextStyle.copyWith(
color: context.colors.primary,
),
),
if (summary.lastReplyAt case final lastReplyAt?) ...[
TextSpan(
text: ' · ',
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
TextSpan(
text: context.l10n.lastReply(
formatThreadSummaryLastReplyTime(
lastReplyAt,
l10n: context.l10n,
),
),
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
class _ThreadTailMetricsObserver with WidgetsBindingObserver {
final VoidCallback onMetricsChanged;
_ThreadTailMetricsObserver({required this.onMetricsChanged});
@override
void didChangeMetrics() => onMetricsChanged();
}
class _ThreadMessage extends ConsumerWidget {
final TimelineMessage message;
final Map<String, String> channelNames;
final String channelId;
final String? currentPubkey;
final bool showAuthor;
final bool isHighlighted;
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
/// Whether this is the message the thread hangs off, which keeps a standing
/// "+" where replies only get one once they carry a reaction.
final bool isThreadHead;
const _ThreadMessage({
required this.message,
required this.channelNames,
required this.channelId,
required this.currentPubkey,
required this.showAuthor,
this.isHighlighted = false,
this.allMessages,
this.isMember = false,
this.isArchived = false,
this.isThreadHead = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pk = message.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final displayName = profile?.label ?? shortPubkey(message.pubkey);
final canManageMessage =
currentPubkey?.toLowerCase() == pk ||
(profile?.ownerPubkey != null &&
profile?.ownerPubkey == currentPubkey?.toLowerCase());
final userCache = ref.watch(userCacheProvider);
final knownAgentPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)),
profileOwnedAgentPubkeys: [
for (final profile in userCache.values)
if (profile.ownerPubkey != null) profile.pubkey,
],
);
final mentionNames = <String, String>{};
final agentMentionPubkeys = <String>{};
for (final mpk in message.mentionPubkeys) {
final normalizedPubkey = mpk.toLowerCase();
final p = userCache[normalizedPubkey];
if (p?.displayName != null) {
mentionNames[normalizedPubkey] = p!.displayName!;
}
if (knownAgentPubkeys.contains(normalizedPubkey)) {
agentMentionPubkeys.add(normalizedPubkey);
}
}
final resolvedMentionNames = mentionNamesWithDirectoryLabels(
mentionPubkeys: message.mentionPubkeys,
profileMentionNames: mentionNames,
directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider),
agentMentionPubkeys: agentMentionPubkeys,
);
void openMessageActions(Rect anchorRect) {
showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
);
}
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
child: DecoratedBox(
key: ValueKey('thread-message-${message.id}'),
decoration: BoxDecoration(
color: isHighlighted
? context.colors.primary.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
// The media carousel intentionally continues through the list's
// trailing gutter. InkWell still clips its ink to [borderRadius],
// while leaving overflowing message content visible.
clipBehavior: Clip.none,
child: MessageLongPressInkWell(
key: ValueKey('thread-message-row-${message.id}'),
onLongPress: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
GestureDetector(
onTap: () =>
showUserProfileSheet(context, message.pubkey),
child: _Avatar(profile: profile, pubkey: message.pubkey),
)
else
const SizedBox(width: messageAvatarSize),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAuthor)
Padding(
padding: const EdgeInsets.only(
bottom: Grid.quarter,
),
child: Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: displayName,
username: messageUsernameLabel(profile),
timestamp: formatMessageTime(
message.createdAt,
l10n: context.l10n,
),
nameColor: context.colors.onSurface,
metadataColor:
context.colors.onSurfaceVariant,
onAuthorTap: () => showUserProfileSheet(
context,
message.pubkey,
),
displayNameKey: ValueKey(
'thread-message-author-${message.id}',
),
usernameKey: ValueKey(
'thread-message-username-${message.id}',
),
timestampKey: ValueKey(
'thread-message-timestamp-${message.id}',
),
),
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
context.l10n.edited,
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
MessageContent(
content: message.content,
mentionNames: resolvedMentionNames,
agentMentionPubkeys: agentMentionPubkeys,
channelNames: channelNames,
tags: message.tags,
baseStyle: messageBodyTextStyle.copyWith(
color: context.colors.onSurface,
),
scaleEmojiOnly: true,
mediaCarouselTrailingOverflow: Grid.gutter,
onMediaReply: allMessages == null
? null
: () {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: channelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
),
onChannelTap: (targetChannelId) {
openChannelLink(
context: context,
ref: ref,
channelId: targetChannelId,
currentChannelId: channelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton:
isMember &&
!isArchived &&
(isThreadHead || message.reactions.isNotEmpty),
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
],
),
),
),
],
),
),
),
),
),
);
}
}
class _Avatar extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
const _Avatar({required this.profile, required this.pubkey});
@override
Widget build(BuildContext context) {
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
final avatarUrl = profile?.avatarUrl;
return AvatarImage(
imageUrl: avatarUrl,
radius: messageAvatarSize / 2,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,83 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart';
import '../../../shared/read_state/read_state_time.dart';
import 'thread_follows_storage.dart';
class ThreadFollowsState {
final Set<String> followedRootIds;
const ThreadFollowsState({this.followedRootIds = const {}});
bool isFollowing(String rootId) => followedRootIds.contains(rootId);
}
/// Followed thread roots for the signed-in identity. Follows feed the unread
/// pipeline (`shouldNotifyForEvent`'s `followedRootIds`), so replies in a
/// followed thread badge the channel even without participation.
class ThreadFollowsNotifier extends Notifier<ThreadFollowsState> {
List<ThreadFollowEntry> _entries = const [];
String? _pubkey;
@override
ThreadFollowsState build() {
final pubkey = ref.watch(myPubkeyProvider)?.trim().toLowerCase();
_pubkey = pubkey;
if (pubkey == null || pubkey.isEmpty) {
_entries = const [];
return const ThreadFollowsState();
}
try {
_entries = ThreadFollowsStorage(
ref.read(savedPrefsProvider),
).read(pubkey);
} catch (_) {
_entries = const [];
}
return _stateFromEntries();
}
void followThread(String rootId) {
if (rootId.isEmpty || _entries.any((e) => e.rootId == rootId)) return;
_entries = capThreadFollowEntries([
..._entries,
ThreadFollowEntry(rootId: rootId, followedAt: currentUnixSeconds()),
]);
_persist();
state = _stateFromEntries();
}
void unfollowThread(String rootId) {
final next = [
for (final entry in _entries)
if (entry.rootId != rootId) entry,
];
if (next.length == _entries.length) return;
_entries = next;
_persist();
state = _stateFromEntries();
}
void _persist() {
final pubkey = _pubkey;
if (pubkey == null || pubkey.isEmpty) return;
try {
ThreadFollowsStorage(
ref.read(savedPrefsProvider),
).write(pubkey, _entries);
} catch (_) {
// In-memory state still works this session.
}
}
ThreadFollowsState _stateFromEntries() => ThreadFollowsState(
followedRootIds: Set.unmodifiable({for (final e in _entries) e.rootId}),
);
}
final threadFollowsProvider =
NotifierProvider<ThreadFollowsNotifier, ThreadFollowsState>(
ThreadFollowsNotifier.new,
);
@@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// Persistence for followed thread root IDs.
///
/// Mirrors the desktop store (`useThreadFollows.ts`): per-pubkey entries of
/// `{rootId, followedAt}`, capped at the oldest-out limit. Desktop keeps this
/// client-local (localStorage), so mobile does the same with
/// SharedPreferences — following on one device intentionally does not sync.
const threadFollowsMaxEntries = 500;
String threadFollowsKey(String pubkey) => 'buzz-thread-follows.v1:$pubkey';
class ThreadFollowEntry {
final String rootId;
final int followedAt;
const ThreadFollowEntry({required this.rootId, required this.followedAt});
Map<String, dynamic> toJson() => {'rootId': rootId, 'followedAt': followedAt};
}
class ThreadFollowsStorage {
final SharedPreferences _prefs;
ThreadFollowsStorage(this._prefs);
List<ThreadFollowEntry> read(String pubkey) {
final raw = _prefs.getString(threadFollowsKey(pubkey));
if (raw == null || raw.isEmpty) return const [];
try {
final parsed = jsonDecode(raw);
if (parsed is! List) return const [];
return [
for (final entry in parsed)
if (entry is Map &&
entry['rootId'] is String &&
entry['followedAt'] is int)
ThreadFollowEntry(
rootId: entry['rootId'] as String,
followedAt: entry['followedAt'] as int,
),
];
} catch (_) {
return const [];
}
}
void write(String pubkey, List<ThreadFollowEntry> entries) {
_prefs.setString(
threadFollowsKey(pubkey),
jsonEncode([for (final entry in entries) entry.toJson()]),
);
}
}
/// Cap entries at [threadFollowsMaxEntries], evicting the oldest follows
/// first — same policy as desktop's `capEntries`.
List<ThreadFollowEntry> capThreadFollowEntries(
List<ThreadFollowEntry> entries,
) {
if (entries.length <= threadFollowsMaxEntries) return entries;
final sorted = [...entries]
..sort((a, b) => a.followedAt.compareTo(b.followedAt));
return sorted.sublist(sorted.length - threadFollowsMaxEntries);
}
@@ -0,0 +1,154 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import 'pending_local_messages_provider.dart';
class ThreadRepliesArgs {
final String channelId;
final String rootId;
const ThreadRepliesArgs({required this.channelId, required this.rootId});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ThreadRepliesArgs &&
channelId == other.channelId &&
rootId == other.rootId;
@override
int get hashCode => Object.hash(channelId, rootId);
}
class _ThreadCursor {
final int createdAt;
final String eventId;
const _ThreadCursor({required this.createdAt, required this.eventId});
}
final threadRepliesProvider =
FutureProvider.family<List<NostrEvent>, ThreadRepliesArgs>((
ref,
args,
) async {
final session = ref.watch(relaySessionProvider.notifier);
final replies = <NostrEvent>[];
_ThreadCursor? cursor;
for (var page = 0; page < 500; page++) {
final events = await session.queryRelay([
_threadRepliesFilter(args, cursor),
]);
replies.addAll(events);
if (events.length < 200) return replies;
final last = events.last;
cursor = _ThreadCursor(createdAt: last.createdAt, eventId: last.id);
}
throw Exception('Thread ${args.rootId} exceeded the page safety limit.');
});
NostrFilter _threadRepliesFilter(
ThreadRepliesArgs args,
_ThreadCursor? cursor,
) {
return NostrFilter(
kinds: EventKind.channelTimelineContentKinds,
tags: {
'#e': [args.rootId],
'#h': [args.channelId],
},
limit: 200,
extensions: {
'depth_limit': 64,
if (cursor != null) 'thread_cursor': cursor.createdAt,
if (cursor != null) 'thread_cursor_id': cursor.eventId,
},
);
}
class ThreadLocalRepliesNotifier extends Notifier<List<NostrEvent>> {
final ThreadRepliesArgs args;
ThreadLocalRepliesNotifier(this.args);
@override
List<NostrEvent> build() => const [];
void add(NostrEvent event) {
state = _mergeReplies(state, [event]);
}
void remove(String eventId) {
state = state.where((event) => event.id != eventId).toList();
}
void confirm(Set<String> eventIds) {
if (!state.any((event) => eventIds.contains(event.id))) return;
state = state.where((event) => !eventIds.contains(event.id)).toList();
}
}
final threadLocalRepliesProvider =
NotifierProvider.family<
ThreadLocalRepliesNotifier,
List<NostrEvent>,
ThreadRepliesArgs
>(ThreadLocalRepliesNotifier.new);
/// Relay-backed replies merged with signed local replies that are still
/// waiting for acknowledgement.
final threadRepliesWithLocalProvider =
Provider.family<AsyncValue<List<NostrEvent>>, ThreadRepliesArgs>((
ref,
args,
) {
final relayReplies = ref.watch(threadRepliesProvider(args));
final localReplies = ref.watch(threadLocalRepliesProvider(args));
final authoritative = relayReplies.value;
if (authoritative != null && localReplies.isNotEmpty) {
final authoritativeIds = authoritative.map((event) => event.id).toSet();
if (localReplies.any((event) => authoritativeIds.contains(event.id))) {
Future.microtask(() {
ref
.read(threadLocalRepliesProvider(args).notifier)
.confirm(authoritativeIds);
ref
.read(pendingLocalMessagesProvider(args.channelId).notifier)
.confirm(authoritativeIds);
});
}
}
if (localReplies.isEmpty) return relayReplies;
return relayReplies.when(
data: (events) => AsyncData(_mergeReplies(events, localReplies)),
loading: () => AsyncData(localReplies),
error: (error, stackTrace) => AsyncData(localReplies),
);
});
/// Union two event lists by id, newest-wins, in timeline order.
///
/// The thread view needs this to fold the channel's live socket events into its
/// own one-shot query result: the query asks for content kinds only, so
/// reactions, edits, and deletions that land while a thread is open never reach
/// it on their own.
List<NostrEvent> mergeThreadEvents(
Iterable<NostrEvent> first,
Iterable<NostrEvent> second,
) => _mergeReplies(first, second);
List<NostrEvent> _mergeReplies(
Iterable<NostrEvent> first,
Iterable<NostrEvent> second,
) {
final byId = <String, NostrEvent>{};
for (final event in [...first, ...second]) {
byId[event.id] = event;
}
return byId.values.toList()..sort((a, b) {
final createdAt = a.createdAt.compareTo(b.createdAt);
return createdAt != 0 ? createdAt : a.id.compareTo(b.id);
});
}
@@ -0,0 +1,787 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/relay/relay.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import 'channel_window.dart';
enum SystemEventType {
memberJoined,
memberLeft,
memberRemoved,
topicChanged,
purposeChanged,
channelCreated,
channelArchived,
channelUnarchived,
huddleStarted,
huddleEnded,
}
@immutable
class SystemEvent {
final SystemEventType type;
final String? actorPubkey;
final String? targetPubkey;
final String? topic;
final String? purpose;
const SystemEvent({
required this.type,
this.actorPubkey,
this.targetPubkey,
this.topic,
this.purpose,
});
/// Parse a system event from the JSON content of a kind-40099 event.
/// Returns null if the payload is unrecognised.
static SystemEvent? fromContent(String content) {
final Map<dynamic, dynamic> json;
try {
final decoded = jsonDecode(content);
if (decoded is! Map) {
return null;
}
json = decoded;
} catch (_) {
return null;
}
final type = switch (_readString(json, 'type')) {
'member_joined' => SystemEventType.memberJoined,
'member_left' => SystemEventType.memberLeft,
'member_removed' => SystemEventType.memberRemoved,
'topic_changed' => SystemEventType.topicChanged,
'purpose_changed' => SystemEventType.purposeChanged,
'channel_created' => SystemEventType.channelCreated,
'channel_archived' => SystemEventType.channelArchived,
'channel_unarchived' => SystemEventType.channelUnarchived,
_ => null,
};
if (type == null) return null;
return SystemEvent(
type: type,
actorPubkey: _readString(json, 'actor'),
targetPubkey: _readString(json, 'target'),
topic: _readString(json, 'topic'),
purpose: _readString(json, 'purpose'),
);
}
static SystemEvent? fromHuddleEvent(NostrEvent event) {
final type = switch (event.kind) {
EventKind.huddleStarted => SystemEventType.huddleStarted,
EventKind.huddleEnded => SystemEventType.huddleEnded,
_ => null,
};
if (type == null) return null;
return SystemEvent(type: type, actorPubkey: event.pubkey);
}
/// Human-readable description. [resolveLabel] maps a pubkey to a display
/// name — the caller provides it so this class stays free of provider deps.
String describe(String Function(String? pubkey) resolveLabel) {
final actor = resolveLabel(actorPubkey);
return switch (type) {
SystemEventType.memberJoined => () {
if (actorPubkey != null && actorPubkey == targetPubkey) {
return '$actor joined the channel';
}
final target = resolveLabel(targetPubkey);
return '$target was added by $actor';
}(),
SystemEventType.memberLeft => '$actor left the channel',
SystemEventType.memberRemoved => () {
final target = resolveLabel(targetPubkey);
return '$actor removed $target from the channel';
}(),
SystemEventType.topicChanged =>
'$actor ${_describeTextFieldChange('topic', topic)}',
SystemEventType.purposeChanged =>
'$actor ${_describeTextFieldChange('purpose', purpose)}',
SystemEventType.channelCreated => '$actor created this channel',
SystemEventType.channelArchived => '$actor archived this channel',
SystemEventType.channelUnarchived => '$actor unarchived this channel',
SystemEventType.huddleStarted => '$actor started a huddle',
SystemEventType.huddleEnded => '$actor ended the huddle',
};
}
/// Localized description for the rendered channel timeline. The legacy
/// [describe] method remains unchanged for protocol-facing callers/tests.
String describeLocalized(
AppLocalizations l10n,
String Function(String? pubkey) resolveLabel,
) {
final actor = resolveLabel(actorPubkey);
return switch (type) {
SystemEventType.memberJoined => () {
if (actorPubkey != null && actorPubkey == targetPubkey) {
return l10n.systemJoinedChannel(actor);
}
return l10n.systemMemberAdded(resolveLabel(targetPubkey), actor);
}(),
SystemEventType.memberLeft => l10n.systemMemberLeft(actor),
SystemEventType.memberRemoved => l10n.systemMemberRemoved(
actor,
resolveLabel(targetPubkey),
),
SystemEventType.topicChanged => _describeLocalizedTextFieldChange(
l10n,
actor,
topic,
isTopic: true,
),
SystemEventType.purposeChanged => _describeLocalizedTextFieldChange(
l10n,
actor,
purpose,
isTopic: false,
),
SystemEventType.channelCreated => l10n.systemChannelCreated(actor),
SystemEventType.channelArchived => l10n.systemChannelArchived(actor),
SystemEventType.channelUnarchived => l10n.systemChannelUnarchived(actor),
SystemEventType.huddleStarted => l10n.systemHuddleStarted(actor),
SystemEventType.huddleEnded => l10n.systemHuddleEnded(actor),
};
}
}
String _describeLocalizedTextFieldChange(
AppLocalizations l10n,
String actor,
String? value, {
required bool isTopic,
}) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
return isTopic
? l10n.systemTopicCleared(actor)
: l10n.systemPurposeCleared(actor);
}
return isTopic
? l10n.systemTopicChanged(actor, trimmed)
: l10n.systemPurposeChanged(actor, trimmed);
}
/// Caption fragment for a channel topic or purpose change, e.g.
/// `changed the topic to "Release planning"` or `cleared the topic`.
///
/// A blank value means the field was cleared: the relay reports a clear as a
/// `topic_changed` / `purpose_changed` event carrying an empty string, not as a
/// separate event type. Without this branch the timeline renders
/// `changed the topic to ""`, which reads as if the topic were set to two quote
/// marks. Whitespace-only values are treated as cleared for the same reason.
///
/// Mirrors `describeChannelTextFieldChange` in
/// `desktop/src/features/messages/lib/systemEventCopy.ts`.
String _describeTextFieldChange(String field, String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
return 'cleared the $field';
}
return 'changed the $field to "$trimmed"';
}
@immutable
class TimelineReaction {
final String emoji;
final int count;
final bool reactedByCurrentUser;
final List<String> userPubkeys;
final String? emojiUrl;
/// The event ID of the current user's reaction, for deletion.
final String? currentUserReactionId;
const TimelineReaction({
required this.emoji,
required this.count,
required this.reactedByCurrentUser,
required this.userPubkeys,
this.emojiUrl,
this.currentUserReactionId,
});
}
@immutable
class TimelineMessage {
final String id;
final String pubkey;
final int createdAt;
final String content;
final List<List<String>> tags;
final bool isSystem;
final bool edited;
final SystemEvent? systemEvent;
/// Pubkeys mentioned in this message (from p-tags).
final List<String> mentionPubkeys;
/// Aggregated reactions on this message.
final List<TimelineReaction> reactions;
/// Direct parent event ID (null for top-level messages).
final String? parentId;
/// Root event ID of the thread (null for top-level messages).
final String? rootId;
const TimelineMessage({
required this.id,
required this.pubkey,
required this.createdAt,
required this.content,
this.tags = const [],
this.isSystem = false,
this.edited = false,
this.systemEvent,
this.mentionPubkeys = const [],
this.reactions = const [],
this.parentId,
this.rootId,
});
/// Attachment messages stay visually distinct from surrounding messages,
/// even when several are sent by the same author in quick succession.
bool get hasAttachments =>
tags.any((tag) => tag.isNotEmpty && tag.first == 'imeta');
}
@immutable
class ThreadSummary {
final String threadHeadId;
final int replyCount;
/// Up to 3 most recent unique participant pubkeys.
final List<String> participantPubkeys;
final int? lastReplyAt;
const ThreadSummary({
required this.threadHeadId,
required this.replyCount,
required this.participantPubkeys,
this.lastReplyAt,
});
}
/// A main-timeline entry: a root message with an optional thread summary.
@immutable
class MainTimelineEntry {
final TimelineMessage message;
final ThreadSummary? summary;
const MainTimelineEntry({required this.message, this.summary});
}
const _membershipGroupWindowSeconds = 5 * 60;
@immutable
class _MembershipChange {
final String? actor;
final bool isSelfJoin;
const _MembershipChange({required this.actor, required this.isSelfJoin});
}
_MembershipChange? _membershipChange(MainTimelineEntry entry) {
final event = entry.message.systemEvent;
if (!entry.message.isSystem || event?.type != SystemEventType.memberJoined) {
return null;
}
final actor = event?.actorPubkey?.trim().toLowerCase();
final target = event?.targetPubkey?.trim().toLowerCase();
if (actor == null || actor.isEmpty || target == null || target.isEmpty) {
return null;
}
final isSelfJoin = actor == target;
return _MembershipChange(
actor: isSelfJoin ? null : actor,
isSelfJoin: isSelfJoin,
);
}
bool _membershipChangesCanGroup(
_MembershipChange first,
_MembershipChange second,
) {
return first.isSelfJoin == second.isSelfJoin &&
(first.isSelfJoin || first.actor == second.actor);
}
bool _isSameLocalDay(int firstTimestamp, int secondTimestamp) {
final first = DateTime.fromMillisecondsSinceEpoch(firstTimestamp * 1000);
final second = DateTime.fromMillisecondsSinceEpoch(secondTimestamp * 1000);
return first.year == second.year &&
first.month == second.month &&
first.day == second.day;
}
/// Groups consecutive membership arrivals using the same display rule as
/// desktop: matching additions (or self-joins) within a fixed five-minute
/// window become one render item. Other events and local day boundaries break
/// the group.
///
/// Each inner list is one renderable timeline item. Non-grouped entries are
/// returned as single-item lists.
List<List<MainTimelineEntry>> groupMembershipTimelineEntries(
List<MainTimelineEntry> entries,
) {
final groupsByStart = <int, int>{};
for (var end = entries.length - 1; end >= 0;) {
final newestEntry = entries[end];
final newestChange = _membershipChange(newestEntry);
if (newestChange == null) {
end -= 1;
continue;
}
var start = end;
while (start > 0) {
final candidate = entries[start - 1];
final candidateChange = _membershipChange(candidate);
if (candidateChange == null ||
!_membershipChangesCanGroup(candidateChange, newestChange) ||
!_isSameLocalDay(
candidate.message.createdAt,
newestEntry.message.createdAt,
) ||
newestEntry.message.createdAt < candidate.message.createdAt ||
newestEntry.message.createdAt - candidate.message.createdAt >
_membershipGroupWindowSeconds) {
break;
}
start -= 1;
}
if (start < end) groupsByStart[start] = end;
end = start - 1;
}
final result = <List<MainTimelineEntry>>[];
for (var index = 0; index < entries.length;) {
final groupEnd = groupsByStart[index];
if (groupEnd == null) {
result.add([entries[index]]);
index += 1;
continue;
}
result.add(entries.sublist(index, groupEnd + 1));
index = groupEnd + 1;
}
return result;
}
/// Process a chronologically-sorted list of [NostrEvent]s into a list of
/// [TimelineMessage]s, applying deletions, edits, reactions, and system event
/// parsing.
///
/// Mirrors the desktop's `formatTimelineMessages` logic.
/// [currentPubkey] is used to determine if the current user has reacted.
List<TimelineMessage> formatTimeline(
List<NostrEvent> events, {
String? currentPubkey,
}) {
// 1. Collect deletion targets. Both kind:5 (NIP-09) and kind:9005
// (Buzz-native) are deletion markers; mirror desktop's behavior.
final deletedIds = <String>{};
for (final event in events) {
if (event.kind != EventKind.deletion &&
event.kind != EventKind.nip29DeleteEvent) {
continue;
}
for (final tag in event.tags) {
if (tag.length >= 2 && tag[0] == 'e') {
deletedIds.add(tag[1]);
}
}
}
// 2. Build edit map: targetId → latest edit content.
final edits = <String, _Edit>{};
for (final event in events) {
if (event.kind != EventKind.streamMessageEdit) continue;
if (deletedIds.contains(event.id)) continue;
final targetId = _lastETag(event.tags);
if (targetId == null || deletedIds.contains(targetId)) continue;
final existing = edits[targetId];
if (existing == null || event.createdAt > existing.createdAt) {
edits[targetId] = _Edit(
content: event.content,
createdAt: event.createdAt,
tags: event.tags,
);
}
}
// 3. Aggregate reactions: targetId → { emoji → { pubkey → eventId } }.
final reactionMap = <String, Map<String, Map<String, String>>>{};
final reactionEmojiUrls = <String, Map<String, String>>{};
for (final event in events) {
if (event.kind != EventKind.reaction) continue;
if (deletedIds.contains(event.id)) continue;
final targetId = _lastETag(event.tags);
if (targetId == null || deletedIds.contains(targetId)) continue;
final emoji = event.content.trim();
if (emoji.isEmpty) continue;
reactionMap
.putIfAbsent(targetId, () => {})
.putIfAbsent(emoji, () => {})[event.pubkey.toLowerCase()] =
event.id;
final shortcode = normalizeShortcode(emoji);
if (shortcode != null) {
for (final tag in event.tags) {
if (tag.length < 3 || tag[0] != 'emoji') continue;
if (normalizeShortcode(tag[1]) == shortcode) {
reactionEmojiUrls.putIfAbsent(targetId, () => {})[emoji] = tag[2];
break;
}
}
}
}
final normalizedCurrentPubkey = currentPubkey?.toLowerCase();
List<TimelineReaction> reactionsFor(String eventId) {
final emojiMap = reactionMap[eventId];
if (emojiMap == null) return const [];
return [
for (final entry in emojiMap.entries)
TimelineReaction(
emoji: entry.key,
count: entry.value.length,
reactedByCurrentUser:
normalizedCurrentPubkey != null &&
entry.value.containsKey(normalizedCurrentPubkey),
userPubkeys: entry.value.keys.toList(),
emojiUrl: reactionEmojiUrls[eventId]?[entry.key],
currentUserReactionId: normalizedCurrentPubkey != null
? entry.value[normalizedCurrentPubkey]
: null,
),
];
}
// 4. Filter to visible content events and build TimelineMessages.
final result = <TimelineMessage>[];
for (final event in events) {
if (deletedIds.contains(event.id)) continue;
if (event.kind == EventKind.systemMessage) {
final systemEvent = SystemEvent.fromContent(event.content);
if (systemEvent != null) {
result.add(
TimelineMessage(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
content: event.content,
tags: event.tags,
isSystem: true,
systemEvent: systemEvent,
reactions: reactionsFor(event.id),
),
);
}
continue;
}
if (event.kind == EventKind.huddleStarted ||
event.kind == EventKind.huddleEnded) {
final systemEvent = SystemEvent.fromHuddleEvent(event);
if (systemEvent != null) {
result.add(
TimelineMessage(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
content: event.content,
tags: event.tags,
isSystem: true,
systemEvent: systemEvent,
reactions: reactionsFor(event.id),
),
);
}
continue;
}
if (event.kind == EventKind.streamMessage ||
event.kind == EventKind.streamMessageV2 ||
event.kind == EventKind.streamMessageDiff) {
final edit = edits[event.id];
final effectiveTags = edit?.tags ?? event.tags;
// Include both notify (`p`) and reference-only (`mention`) tags —
// mirrors desktop's resolveMentionNames, so names in messages sent
// "without inviting" still render as mentions.
final mentions = <String>[
for (final tag in effectiveTags)
if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention')) tag[1],
];
final threadRef = event.threadReference;
result.add(
TimelineMessage(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
content: edit?.content ?? event.content,
tags: effectiveTags,
edited: edit != null,
mentionPubkeys: mentions,
reactions: reactionsFor(event.id),
parentId: threadRef.parentId,
rootId: threadRef.rootId,
),
);
}
}
return result;
}
/// Build main-timeline entries: only root messages (parentId == null),
/// each with an optional [ThreadSummary] when replies exist.
///
/// Mirrors the desktop's `buildMainTimelineEntries`.
List<MainTimelineEntry> buildMainTimelineEntries(
List<TimelineMessage> messages, {
Map<String, ChannelWindowThreadSummary>? relaySummaries,
}) {
// Index descendant stats by ancestor, so a nested reply updates every summary
// above it and not only the summary of its direct parent.
final descendantStats = _buildDescendantStats(messages);
return [
for (final msg in messages)
if (msg.parentId == null || _isBroadcastReply(msg))
MainTimelineEntry(
message: msg,
summary: _buildSummary(
msg.id,
descendantStats,
relaySummaries?[msg.id],
),
),
];
}
bool _isBroadcastReply(TimelineMessage message) {
return message.tags.any(
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
);
}
/// Combine what the relay counted with what this client has actually seen.
///
/// The count and last-reply time follow the desktop's `mergeThreadSummaries`
/// (`desktop/src/features/messages/lib/threadPanel.ts`): the relay recount is
/// authoritative for replies this client never loaded, and the locally observed
/// replies are authoritative for anything that landed after (or alongside) the
/// last recount. Neither source alone is complete, so take the larger count and
/// the later reply time rather than letting one shadow the other. The facepile
/// order is mobile's own (see [_mergeParticipants]) because this file already
/// renders relay participants in the order the relay sent them.
///
/// Both halves count descendants, not direct replies: the relay's
/// `descendant_count` and the locally assembled [_buildDescendantStats]. A badge
/// on the main timeline stands for the whole thread under that message, so a
/// reply to a reply has to raise it.
ThreadSummary? _buildSummary(
String messageId,
Map<String, _DescendantStats> descendantStats,
ChannelWindowThreadSummary? relaySummary,
) {
final local = _buildLocalSummary(messageId, descendantStats);
final relay = _buildRelaySummary(messageId, relaySummary);
if (relay == null) return local;
if (local == null) return relay;
return ThreadSummary(
threadHeadId: messageId,
replyCount: local.replyCount > relay.replyCount
? local.replyCount
: relay.replyCount,
// Relay participants first: they describe the whole thread, including
// replies this client never loaded, so a recount's facepile keeps rendering
// as it does today. Locally seen repliers (newest first, matching the relay
// order this file already renders) only fill the remaining slots.
participantPubkeys: _mergeParticipants(
relay.participantPubkeys,
local.participantPubkeys.reversed,
),
lastReplyAt: _laterOf(local.lastReplyAt, relay.lastReplyAt),
);
}
/// Summary assembled from the replies present in the loaded timeline.
///
/// Counts every loaded descendant, not only direct children, because the root
/// badge in the main timeline stands for the whole thread. This mirrors the
/// desktop's `buildSummaryForDirectReplies`, which reads the same descendant
/// stats and reverses the newest-first participants to oldest-first.
ThreadSummary? _buildLocalSummary(
String messageId,
Map<String, _DescendantStats> descendantStats,
) {
final stats = descendantStats[messageId];
if (stats == null || stats.descendantCount == 0) return null;
return ThreadSummary(
threadHeadId: messageId,
replyCount: stats.descendantCount,
participantPubkeys: stats.recentParticipantsNewestFirst.reversed.toList(),
lastReplyAt: stats.lastReplyAt,
);
}
/// Descendant count, last reply time, and recent participants for every message
/// that has at least one loaded descendant.
///
/// Mirrors the desktop's `buildDescendantStatsByMessageId`
/// (`desktop/src/features/messages/lib/threadPanel.ts`): each message is
/// attributed to every ancestor on its parent chain, so a reply nested under a
/// reply still counts towards the root it belongs to. Messages are visited
/// newest first so the capped participant list keeps the most recent repliers.
Map<String, _DescendantStats> _buildDescendantStats(
List<TimelineMessage> messages,
) {
final messageById = <String, TimelineMessage>{
for (final msg in messages) msg.id: msg,
};
final statsByMessageId = <String, _DescendantStats>{
for (final msg in messages) msg.id: _DescendantStats(),
};
// Oldest first, keeping the original order for messages sharing a timestamp,
// then walked in reverse so participants are collected newest first.
final ordered = List<int>.generate(messages.length, (index) => index)
..sort((left, right) {
final byCreatedAt = messages[left].createdAt.compareTo(
messages[right].createdAt,
);
return byCreatedAt != 0 ? byCreatedAt : left.compareTo(right);
});
for (var i = ordered.length - 1; i >= 0; i--) {
final message = messages[ordered[i]];
final participant = message.pubkey.toLowerCase();
// Cap the walk so a malformed parent chain (a cycle, for instance) cannot
// spin forever.
var ancestorId = message.parentId;
var hops = 0;
final maxHops = messages.length + 1;
while (ancestorId != null && hops < maxHops) {
final ancestorStats = statsByMessageId[ancestorId];
if (ancestorStats == null) break;
ancestorStats.descendantCount += 1;
ancestorStats.lastReplyAt = _laterOf(
ancestorStats.lastReplyAt,
message.createdAt,
);
if (ancestorStats.recentParticipantsNewestFirst.length < 3 &&
!ancestorStats.recentParticipantsNewestFirst.contains(participant)) {
ancestorStats.recentParticipantsNewestFirst.add(participant);
}
ancestorId = messageById[ancestorId]?.parentId;
hops += 1;
}
}
return statsByMessageId;
}
/// Mutable accumulator for [_buildDescendantStats].
class _DescendantStats {
int descendantCount = 0;
int? lastReplyAt;
final List<String> recentParticipantsNewestFirst = [];
}
/// Summary from the relay's recount, or null when it reports no replies.
ThreadSummary? _buildRelaySummary(
String messageId,
ChannelWindowThreadSummary? relaySummary,
) {
if (relaySummary == null || relaySummary.descendantCount <= 0) return null;
return ThreadSummary(
threadHeadId: messageId,
replyCount: relaySummary.descendantCount,
participantPubkeys: relaySummary.participantPubkeys.take(3).toList(),
lastReplyAt: relaySummary.lastReplyAt,
);
}
int? _laterOf(int? left, int? right) {
if (left == null) return right;
if (right == null) return left;
return left > right ? left : right;
}
/// Up to 3 unique pubkeys, [primary] first.
///
/// [secondary] is expected newest-first so that a capped facepile keeps the
/// most recent participants rather than the oldest ones. Uniqueness is
/// case-insensitive because the locally assembled half lowercases pubkeys while
/// the relay half is passed through as received.
List<String> _mergeParticipants(
Iterable<String> primary,
Iterable<String> secondary,
) {
final seen = <String>{};
final merged = <String>[];
for (final pubkey in [...primary, ...secondary]) {
if (!seen.add(pubkey.toLowerCase())) continue;
merged.add(pubkey);
if (merged.length == 3) break;
}
return merged;
}
class _Edit {
final String content;
final int createdAt;
final List<List<String>> tags;
const _Edit({
required this.content,
required this.createdAt,
required this.tags,
});
}
/// Get the last `e` tag value (reaction/edit target convention).
String? _lastETag(List<List<String>> tags) {
for (var i = tags.length - 1; i >= 0; i--) {
final tag = tags[i];
if (tag.length >= 2 && tag[0] == 'e') return tag[1];
}
return null;
}
String? _readString(Map<dynamic, dynamic> json, String key) {
final value = json[key];
return value is String ? value : null;
}

Some files were not shown because too many files have changed in this diff Show More