mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
258 lines
9.0 KiB
Dart
258 lines
9.0 KiB
Dart
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,
|
||
),
|
||
);
|
||
}
|
||
}
|