Files
buzz/mobile/lib/features/channels/channels_page.dart

258 lines
9.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:io';
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/auth/auth.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../profile/profile_avatar.dart';
import '../profile/profile_provider.dart';
import '../settings/settings_page.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_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_stars/channel_stars_provider.dart';
import 'channels_provider.dart';
import 'read_state/deferred_read_state_update.dart';
import 'read_state/read_state_format.dart';
import 'read_state/read_state_provider.dart';
import '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/workspace.dart';
enum _QuickAction { createChannel, newDm }
/// Height of the [_ConnectionBanner]: vertical padding (Grid.quarter + 2) × 2
/// plus the ~16px row content (12px spinner / labelSmall text).
const double _kBannerHeight = 24.0;
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 _kChannelLabelInset =
_kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap;
const double _kWorkspaceAvatarInset =
_kChannelSectionInset - Grid.quarter; // FrostedAppBar adds Grid.quarter.
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;
class _UnreadChannelState {
final Set<String> ids;
final Map<String, int> counts;
const _UnreadChannelState({required this.ids, required this.counts});
}
_UnreadChannelState _computeUnreadChannelState({
required Iterable<Channel> channels,
required ReadStateState readState,
required ChannelsNotifier channelsNotifier,
}) {
if (!readState.isReady) {
return const _UnreadChannelState(ids: {}, counts: {});
}
final latestObservedByChannel = channelsNotifier.latestObservedByChannel;
final observedEventsByChannel =
channelsNotifier.observedUnreadEventsByChannel;
final ids = <String>{};
final counts = <String, int>{};
for (final channel in channels) {
if (readState.locallyForcedChannelIds.contains(channel.id)) {
ids.add(channel.id);
counts[channel.id] = 1;
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);
counts[channel.id] = countUnreadBadgeObservedEvents(
observedEvents,
readAtForObservedEvent,
);
}
return _UnreadChannelState(ids: ids, counts: counts);
}
class ChannelsPage extends HookConsumerWidget {
const ChannelsPage({super.key});
@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;
// 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 workspace switch so we show a full loader instead of
// stale channels from the previous workspace. unwrapPrevious() ensures the
// selector sees null during loading (not the previous workspace's ID).
final activeWorkspaceId = ref.watch(
activeWorkspaceProvider.select((v) => v.unwrapPrevious().value?.id),
);
final cachedChannels = useRef<List<Channel>?>(null);
final lastWorkspaceId = useRef<String?>(null);
if (lastWorkspaceId.value != activeWorkspaceId) {
cachedChannels.value = null;
lastWorkspaceId.value = activeWorkspaceId;
}
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),
),
);
}
Future<void> openQuickActions() async {
final action = await showModalBottomSheet<_QuickAction>(
context: context,
showDragHandle: true,
builder: (_) => const _QuickActionsSheet(),
);
if (!context.mounted || action == null) {
return;
}
switch (action) {
case _QuickAction.createChannel:
final created = await showModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
);
if (created != null && context.mounted) {
await openChannel(created);
}
case _QuickAction.newDm:
final opened = await showModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
_NewDirectMessageSheet(currentPubkey: currentPubkey),
);
if (opened != null && context.mounted) {
await openChannel(opened);
}
}
}
// Defer the error view to absorb transient AsyncError frames caused by
// the relay session cancelling in-flight history fetches on disconnect/
// reconnect (relay_session.dart `_cancelAllHistory`). If the error clears
// (channels populate or the next _fetch succeeds) within the grace
// window, we never render the error UI.
final showError = useState(false);
final hasError = channelsAsync.hasError && channels == null;
useEffect(() {
if (!hasError) {
showError.value = false;
return null;
}
final timer = Timer(const Duration(seconds: 2), () {
showError.value = true;
});
return timer.cancel;
}, [hasError]);
return FrostedScaffold(
appBar: FrostedAppBar(
leading: _WorkspaceIndicator(
onTap: () => showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _WorkspaceSwitcherSheet(),
),
),
title: const SizedBox.shrink(),
actions: [
ProfileAvatar(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsPage()),
),
),
const SizedBox(width: Grid.twelve + Grid.quarter),
],
),
floatingActionButton: FloatingActionButton(
heroTag: 'channels-fab',
onPressed: openQuickActions,
tooltip: 'Create or start conversation',
backgroundColor: context.colors.primary,
foregroundColor: context.colors.onPrimary,
shape: const CircleBorder(),
child: const Icon(LucideIcons.plus),
),
body: _ChannelsBody(
channels: channels,
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
),
);
}
}