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
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:
@@ -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';
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user