Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 64 additions & 47 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' show min;

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
Expand All @@ -11,6 +12,7 @@ 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/skeleton.dart';
import '../profile/presence_cache_provider.dart';
import '../profile/profile_provider.dart';
import '../profile/user_cache_provider.dart';
Expand Down Expand Up @@ -121,6 +123,7 @@ class ChannelDetailPage extends HookConsumerWidget {
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 currentPubkey = ref
.watch(profileProvider)
Expand Down Expand Up @@ -148,6 +151,22 @@ class ChannelDetailPage extends HookConsumerWidget {
channel;
final resolvedChannel =
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
final showConnectionSkeleton = useState(false);
final isReconnectingWithContent =
!resolvedChannel.isForum &&
messagesState.value != null &&
(sessionStatus == SessionStatus.connecting ||
sessionStatus == SessionStatus.reconnecting);
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
useEffect(() {
if (!isReconnectingWithContent) {
showConnectionSkeleton.value = false;
return null;
}
final timer = Timer(const Duration(seconds: 2), () {
showConnectionSkeleton.value = true;
});
return timer.cancel;
}, [isReconnectingWithContent]);
final appBarTitleContentHeight = resolvedChannel.isDm
? _dmAppBarTitleContentHeight(context)
: 0.0;
Expand Down Expand Up @@ -263,61 +282,59 @@ class ChannelDetailPage extends HookConsumerWidget {
channel: resolvedChannel,
currentPubkey: currentPubkey,
)
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
: messagesState.when(
loading: () => Padding(
padding: EdgeInsets.only(
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
),
),
child: const Center(child: CircularProgressIndicator()),
: SkeletonReveal(
loading:
showConnectionSkeleton.value || messagesState.isLoading,
shimmerEnabled: sessionStatus != SessionStatus.disconnected,
skeleton: _MessageTimelineSkeleton(
appBarTitleContentHeight: appBarTitleContentHeight,
status: sessionStatus,
),
error: (e, _) => Padding(
padding: EdgeInsets.only(
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
content: messagesState.when(
loading: SizedBox.shrink,
error: (e, _) => Padding(
padding: EdgeInsets.only(
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
),
),
),
child: Center(
child: Text(
'Failed to load messages',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.error,
child: Center(
child: Text(
'Failed to load messages',
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,
channelId: channel.id,
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
isArchived: resolvedChannel.isArchived,
appBarTitleContentHeight: appBarTitleContentHeight,
);
},
),
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,
channelId: channel.id,
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
isArchived: resolvedChannel.isArchived,
appBarTitleContentHeight: appBarTitleContentHeight,
);
},
),
),
_DetailConnectionBanner(
status: ref.watch(relaySessionProvider).status,
),
if (!resolvedChannel.isForum && typingEntries.isNotEmpty)
_TypingIndicator(entries: typingEntries),
if (!resolvedChannel.isForum &&
Expand Down
119 changes: 87 additions & 32 deletions mobile/lib/features/channels/channel_detail_page/banners.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,45 +54,100 @@ class _HeaderEphemeralBadge extends StatelessWidget {
}
}

class _DetailConnectionBanner extends StatelessWidget {
class _MessageTimelineSkeleton extends StatelessWidget {
final double appBarTitleContentHeight;
final SessionStatus status;

const _DetailConnectionBanner({required this.status});
const _MessageTimelineSkeleton({
required this.appBarTitleContentHeight,
required this.status,
});

@override
Widget build(BuildContext context) {
if (status == SessionStatus.connected ||
status == SessionStatus.disconnected) {
return const SizedBox.shrink();
}

return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.quarter + 2,
),
color: context.colors.surfaceContainerHighest,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(width: Grid.xxs),
Text(
'Reconnecting…',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
final semanticsLabel = status == SessionStatus.reconnecting
? 'Reconnecting'
: 'Connecting';
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(18),
),
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),
],
),
],
),
),
],
);
}
}
27 changes: 15 additions & 12 deletions mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' show pi;
import 'dart:math' show max, min, pi;
import 'dart:ui';

import 'package:flutter/material.dart';
Expand All @@ -16,6 +16,7 @@ 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/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';
Expand Down Expand Up @@ -46,15 +47,13 @@ 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 }

/// 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;
Expand All @@ -69,12 +68,17 @@ const double _kChannelLabelInset =
/// sections while the labels stay on [_kChannelLabelInset].
const double _kDmAvatarSize = _kChannelIconSize;

const double _kTopSectionAvatarSize = 32.0;

/// The top section's avatars are 32dp 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
/// on the channel-icon column (12 + 16 = 28dp against the glyph's ~29dp).
/// on the channel-icon column (12 + 16 = 28dp against the glyph's ~29dp). Its
/// label gap is derived separately so both labels land on the same 50dp column.
const double _kTopSectionInset = Grid.twelve;
const double _kTopSectionLabelGap =
_kChannelLabelInset - _kTopSectionInset - _kTopSectionAvatarSize;
const Duration _kSectionExpandDuration = Duration(milliseconds: 220);
const Duration _kSectionCollapseDuration = Duration(milliseconds: 170);
const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1);
Expand Down Expand Up @@ -202,21 +206,20 @@ class ChannelsPage extends HookConsumerWidget {
return timer.cancel;
}, [canSurfaceError]);

// Match desktop's degraded-state debounce: cached content remains steady
// through brief socket flaps, and the banner appears only for a sustained
// reconnect.
final showConnectionBanner = useState(false);
// 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) {
showConnectionBanner.value = false;
showConnectionSkeleton.value = false;
return null;
}
final timer = Timer(const Duration(seconds: 2), () {
showConnectionBanner.value = true;
showConnectionSkeleton.value = true;
});
return timer.cancel;
}, [isReconnectingWithContent]);
Expand Down Expand Up @@ -252,7 +255,7 @@ class ChannelsPage extends HookConsumerWidget {
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
showConnectionBanner: showConnectionBanner.value,
showConnectionSkeleton: showConnectionSkeleton.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
Expand Down
Loading
Loading