9dfa06ffee
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>
462 lines
17 KiB
Dart
462 lines
17 KiB
Dart
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);
|
|
}
|
|
}
|