Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 12 additions & 2 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import 'agent_activity/working_bots_provider.dart';
import 'channel_management_provider.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'channel_typing_indicator.dart';
import 'channels_provider.dart';
import 'compose_bar.dart';
import 'date_formatters.dart';
Expand Down Expand Up @@ -368,8 +369,17 @@ class ChannelDetailPage extends HookConsumerWidget {
),
),
),
if (!resolvedChannel.isForum && typingEntries.isNotEmpty)
_TypingIndicator(entries: typingEntries),
if (!resolvedChannel.isForum)
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.isForum &&
resolvedChannel.isMember &&
!resolvedChannel.isArchived)
Expand Down
64 changes: 0 additions & 64 deletions mobile/lib/features/channels/channel_detail_page/app_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,70 +19,6 @@ double _dmAppBarTitleContentHeight(BuildContext context) {
return textHeight > 30 ? textHeight : 30;
}

class _TypingIndicator extends ConsumerWidget {
final List<TypingEntry> entries;

const _TypingIndicator({required this.entries});

@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
final names = entries.map((e) {
final profile =
userCache[e.pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase());
return profile?.label ?? shortPubkey(e.pubkey);
}).toList();
final text = switch (names.length) {
1 => '${names[0]} is typing…',
2 => '${names[0]} and ${names[1]} are typing…',
_ => '${names[0]} and ${names.length - 1} others are typing…',
};

final visibleEntries = entries.take(3).toList();
final avatarCount = visibleEntries.length;

return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.quarter + 2,
),
child: Row(
children: [
SizedBox(
width: 20.0 + (avatarCount - 1) * 12.0,
height: 20,
child: Stack(
children: [
for (var i = 0; i < avatarCount; i++)
Positioned(
left: i * 12.0,
child: SmallAvatar(
pubkey: visibleEntries[i].pubkey,
userCache: userCache,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
text,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontStyle: FontStyle.italic,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

class _MembersButton extends ConsumerWidget {
final String channelId;
final Channel channel;
Expand Down
89 changes: 89 additions & 0 deletions mobile/lib/features/channels/channel_typing_indicator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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 => '${names[0]} is typing…',
2 => '${names[0]} and ${names[1]} are typing…',
_ => '${names[0]} and ${names.length - 1} others are typing…',
};
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: Text(
text,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.primary,
fontStyle: FontStyle.italic,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}
77 changes: 11 additions & 66 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'channel_link_navigation.dart';
import 'channel_typing_provider.dart';
import 'channel_typing_indicator.dart';
import 'thread_replies_provider.dart';
import 'channels_provider.dart';
import 'compose_bar.dart';
Expand Down Expand Up @@ -280,8 +281,16 @@ class ThreadDetailPage extends HookConsumerWidget {
},
),
),
if (threadTyping.isNotEmpty)
_ThreadTypingIndicator(entries: threadTyping),
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)
ComposeBar(
channelId: channelId,
Expand Down Expand Up @@ -663,70 +672,6 @@ class _ThreadMessage extends ConsumerWidget {
}
}

class _ThreadTypingIndicator extends ConsumerWidget {
final List<TypingEntry> entries;

const _ThreadTypingIndicator({required this.entries});

@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
final names = entries.map((e) {
final profile =
userCache[e.pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase());
return profile?.label ?? shortPubkey(e.pubkey);
}).toList();
final text = switch (names.length) {
1 => '${names[0]} is typing...',
2 => '${names[0]} and ${names[1]} are typing...',
_ => '${names[0]} and ${names.length - 1} others are typing...',
};

final visibleEntries = entries.take(3).toList();
final avatarCount = visibleEntries.length;

return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.quarter + 2,
),
child: Row(
children: [
SizedBox(
width: 20.0 + (avatarCount - 1) * 12.0,
height: 20,
child: Stack(
children: [
for (var i = 0; i < avatarCount; i++)
Positioned(
left: i * 12.0,
child: SmallAvatar(
pubkey: visibleEntries[i].pubkey,
userCache: userCache,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
text,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.outline,
fontStyle: FontStyle.italic,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

class _Avatar extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
Expand Down
19 changes: 19 additions & 0 deletions mobile/test/features/channels/channel_detail_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1808,6 +1808,25 @@ void main() {
await tester.pumpAndSettle();

expect(find.text('Alice is typing…'), findsOneWidget);

final indicator = tester.widget<Container>(
find.byKey(const ValueKey('channel-typing-indicator')),
);
final decoration = indicator.decoration! as BoxDecoration;
expect(
indicator.padding,
const EdgeInsets.symmetric(horizontal: Grid.xxs, vertical: Grid.xxs),
);
expect(
decoration.color,
AppTheme.light().colorScheme.surfaceContainerHighest,
);
expect(decoration.border, isA<Border>());
expect(
tester.widget<Text>(find.text('Alice is typing…')).style?.color,
AppTheme.light().colorScheme.primary,
);
expect(tester.widget<SmallAvatar>(find.byType(SmallAvatar)).size, 24);
});

testWidgets('shows two typers', (tester) async {
Expand Down
Loading