diff --git a/assets/Source_Sans_3/LICENSE.md b/assets/Source_Sans_3/LICENSE.md new file mode 100644 index 0000000000..88c07f795d --- /dev/null +++ b/assets/Source_Sans_3/LICENSE.md @@ -0,0 +1,93 @@ +Copyright 2010-2022 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/Source_Sans_3/SourceSans3VF-Italic.otf b/assets/Source_Sans_3/SourceSans3VF-Italic.otf new file mode 100644 index 0000000000..1c7053d198 Binary files /dev/null and b/assets/Source_Sans_3/SourceSans3VF-Italic.otf differ diff --git a/assets/Source_Sans_3/SourceSans3VF-Upright.otf b/assets/Source_Sans_3/SourceSans3VF-Upright.otf new file mode 100644 index 0000000000..8ee0150af1 Binary files /dev/null and b/assets/Source_Sans_3/SourceSans3VF-Upright.otf differ diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 7882e359fb..6bf9215cd3 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/group_dm.svg b/assets/icons/group_dm.svg new file mode 100644 index 0000000000..0d80bd5dc1 --- /dev/null +++ b/assets/icons/group_dm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart index 7846831f32..68ea815086 100644 --- a/lib/api/route/events.dart +++ b/lib/api/route/events.dart @@ -11,10 +11,11 @@ Future registerQueue(ApiConnection connection) { return connection.post('registerQueue', InitialSnapshot.fromJson, 'register', { 'apply_markdown': true, 'slim_presence': true, + 'client_gravatar': false, // TODO(#255): turn on 'client_capabilities': { 'notification_settings_null': true, 'bulk_message_deletion': true, - 'user_avatar_url_field_optional': true, + 'user_avatar_url_field_optional': false, // TODO(#254): turn on 'stream_typing_notifications': false, // TODO implement 'user_settings_object': true, }, diff --git a/lib/licenses.dart b/lib/licenses.dart index 1a5933f5d1..5a1a3707da 100644 --- a/lib/licenses.dart +++ b/lib/licenses.dart @@ -13,4 +13,7 @@ Stream additionalLicenses() async* { yield LicenseEntryWithLineBreaks( ['Source Code Pro'], await rootBundle.loadString('assets/Source_Code_Pro/LICENSE.md')); + yield LicenseEntryWithLineBreaks( + ['Source Sans 3'], + await rootBundle.loadString('assets/Source_Sans_3/LICENSE.md')); } diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 1333614bc1..209fefbd9b 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -4,6 +4,7 @@ import '../model/narrow.dart'; import 'about_zulip.dart'; import 'login.dart'; import 'message_list.dart'; +import 'recent_dm_conversations.dart'; import 'store.dart'; class ZulipApp extends StatelessWidget { @@ -152,6 +153,11 @@ class HomePage extends StatelessWidget { MessageListPage.buildRoute(context: context, narrow: const AllMessagesNarrow())), child: const Text("All messages")), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => Navigator.push(context, + RecentDmConversationsPage.buildRoute(context: context)), + child: const Text("Direct messages")), if (testStreamId != null) ...[ const SizedBox(height: 16), ElevatedButton( diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index f8df933967..f81da51d36 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -812,6 +812,84 @@ class RealmContentNetworkImage extends StatelessWidget { } } +/// A rounded square with size [size] showing a user's avatar. +class Avatar extends StatelessWidget { + const Avatar({ + super.key, + required this.userId, + required this.size, + required this.borderRadius, + }); + + final int userId; + final double size; + final double borderRadius; + + @override + Widget build(BuildContext context) { + return AvatarShape( + size: size, + borderRadius: borderRadius, + child: AvatarImage(userId: userId)); + } +} + +/// The appropriate avatar image for a user ID. +/// +/// If the user isn't found, gives a [SizedBox.shrink]. +/// +/// Wrap this with [AvatarShape]. +class AvatarImage extends StatelessWidget { + const AvatarImage({ + super.key, + required this.userId, + }); + + final int userId; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final user = store.users[userId]; + + if (user == null) { // TODO(log) + return const SizedBox.shrink(); + } + + final resolvedUrl = switch (user.avatarUrl) { + null => null, // TODO(#255): handle computing gravatars + var avatarUrl => resolveUrl(avatarUrl, store.account), + }; + return (resolvedUrl == null) + ? const SizedBox.shrink() + : RealmContentNetworkImage(resolvedUrl, filterQuality: FilterQuality.medium); + } +} + +/// A rounded square shape, to wrap an [AvatarImage] or similar. +class AvatarShape extends StatelessWidget { + const AvatarShape({ + super.key, + required this.size, + required this.borderRadius, + required this.child, + }); + + final double size; + final double borderRadius; + final Widget child; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: size, + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + clipBehavior: Clip.antiAlias, + child: child)); + } +} + // // Small helpers. // diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index 508d6144aa..1d599bfb91 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -14,6 +14,7 @@ abstract final class ZulipIcons { // // * Add an SVG file in `assets/icons/`, // or otherwise edit the SVG files there. + // The files' names (before ".svg") should be valid Dart identifiers. // // * Then run the command `scripts/icons/build-icon-font`. // That will update this file and the generated icon font, @@ -27,29 +28,32 @@ abstract final class ZulipIcons { /// The Zulip custom icon "globe". static const IconData globe = IconData(0xf102, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "group_dm". + static const IconData group_dm = IconData(0xf103, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf103, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf104, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf104, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf105, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf105, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf106, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf106, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf107, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf107, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf108, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf108, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf109, fontFamily: "Zulip Icons"); /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf109, fontFamily: "Zulip Icons"); + static const IconData unmute = IconData(0xf10a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "user". - static const IconData user = IconData(0xf10a, fontFamily: "Zulip Icons"); + static const IconData user = IconData(0xf10b, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index e38bc382e8..2779e26d22 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -21,6 +21,7 @@ class MessageListPage extends StatefulWidget { static Route buildRoute({required BuildContext context, required Narrow narrow}) { return MaterialAccountPageRoute(context: context, + settings: RouteSettings(name: 'message_list', arguments: narrow), // for testing builder: (context) => MessageListPage(narrow: narrow)); } @@ -474,16 +475,6 @@ class MessageWithSender extends StatelessWidget { @override Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); - final author = store.users[message.senderId]!; - - final avatarUrl = author.avatarUrl == null - ? null // TODO handle computing gravatars - : resolveUrl(author.avatarUrl!, store.account); - final avatar = (avatarUrl == null) - ? const SizedBox.shrink() - : RealmContentNetworkImage(avatarUrl, filterQuality: FilterQuality.medium); - final time = _kMessageTimestampFormat .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); @@ -496,13 +487,7 @@ class MessageWithSender extends StatelessWidget { child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(3, 6, 11, 0), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(4))), - width: 35, - height: 35, - child: avatar)), + child: Avatar(userId: message.senderId, size: 35, borderRadius: 4)), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart new file mode 100644 index 0000000000..3b38e7f3a2 --- /dev/null +++ b/lib/widgets/recent_dm_conversations.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import '../model/narrow.dart'; +import '../model/recent_dm_conversations.dart'; +import 'content.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; + +class RecentDmConversationsPage extends StatefulWidget { + const RecentDmConversationsPage({super.key}); + + static Route buildRoute({required BuildContext context}) { + return MaterialAccountPageRoute(context: context, + builder: (context) => const RecentDmConversationsPage()); + } + + @override + State createState() => _RecentDmConversationsPageState(); +} + +class _RecentDmConversationsPageState extends State with PerAccountStoreAwareStateMixin { + RecentDmConversationsView? model; + + @override + void onNewStore() { + model?.removeListener(_modelChanged); + model = PerAccountStoreWidget.of(context).recentDmConversationsView + ..addListener(_modelChanged); + } + + void _modelChanged() { + setState(() { + // The actual state lives in [model]. + // This method was called because that just changed. + }); + } + + @override + Widget build(BuildContext context) { + final sorted = model!.sorted; + return Scaffold( + appBar: AppBar(title: const Text('Direct messages')), + body: ListView.builder( + itemCount: sorted.length, + itemBuilder: (context, index) => RecentDmConversationsItem(narrow: sorted[index]))); + } +} + +class RecentDmConversationsItem extends StatelessWidget { + const RecentDmConversationsItem({super.key, required this.narrow}); + + final DmNarrow narrow; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final selfUser = store.users[store.account.userId]!; + + final String title; + final Widget avatar; + switch (narrow.otherRecipientIds) { + case []: + title = selfUser.fullName; + avatar = AvatarImage(userId: selfUser.userId); + case [var otherUserId]: + final otherUser = store.users[otherUserId]; + title = otherUser?.fullName ?? '(unknown user)'; + avatar = AvatarImage(userId: otherUserId); + default: + // TODO(i18n): List formatting, like you can do in JavaScript: + // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) + // // 'Chris、Greg、Alya' + title = narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', '); + avatar = ColoredBox(color: const Color(0x33808080), + child: Center( + child: Icon(ZulipIcons.group_dm, color: Colors.black.withOpacity(0.5)))); + } + + return InkWell( + onTap: () { + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding(padding: const EdgeInsets.fromLTRB(12, 8, 0, 8), + child: AvatarShape(size: 32, borderRadius: 3, child: avatar)), + const SizedBox(width: 8), + Expanded(child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + style: const TextStyle( + fontFamily: 'Source Sans 3', + fontSize: 17, + height: (20 / 17), + color: Color(0xFF222222), + ).merge(weightVariableTextStyle(context)), + maxLines: 2, + overflow: TextOverflow.ellipsis, + title))), + const SizedBox(width: 8), + // TODO(#253): Unread count + ]))); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e3ac9b94c7..35eddf061b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -89,6 +89,7 @@ flutter: assets: - assets/Source_Code_Pro/LICENSE.md + - assets/Source_Sans_3/LICENSE.md fonts: # Zulip's custom icons. To use or edit, see class ZulipIcons. @@ -102,4 +103,10 @@ flutter: - asset: assets/Source_Code_Pro/SourceCodeVF-Italic.otf style: italic + - family: Source Sans 3 + fonts: + - asset: assets/Source_Sans_3/SourceSans3VF-Upright.otf + - asset: assets/Source_Sans_3/SourceSans3VF-Italic.otf + style: italic + # If adding a font, remember to account for its license in lib/licenses.dart. diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index c693fe831d..4eae9b2bc2 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -15,6 +15,15 @@ extension GlobalKeyChecks> on Subject get currentState => has((k) => k.currentState, 'currentState'); } +extension RouteChecks on Subject> { + Subject get settings => has((r) => r.settings, 'settings'); +} + +extension RouteSettingsChecks on Subject { + Subject get name => has((s) => s.name, 'name'); + Subject get arguments => has((s) => s.arguments, 'arguments'); +} + extension ValueNotifierChecks on Subject> { Subject get value => has((c) => c.value, 'value'); } diff --git a/test/test_navigation.dart b/test/test_navigation.dart new file mode 100644 index 0000000000..02d7b48f6e --- /dev/null +++ b/test/test_navigation.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; + +// Inspired by test code in the Flutter tree: +// https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/observer_tester.dart +// https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/navigator_test.dart + +/// A trivial observer for testing the navigator. +class TestNavigatorObserver extends NavigatorObserver { + void Function(Route route, Route? previousRoute)? onPushed; + void Function(Route route, Route? previousRoute)? onPopped; + void Function(Route route, Route? previousRoute)? onRemoved; + void Function(Route? route, Route? previousRoute)? onReplaced; + void Function(Route route, Route? previousRoute)? onStartUserGesture; + + @override + void didPush(Route route, Route? previousRoute) { + onPushed?.call(route, previousRoute); + } + + @override + void didPop(Route route, Route? previousRoute) { + onPopped?.call(route, previousRoute); + } + + @override + void didRemove(Route route, Route? previousRoute) { + onRemoved?.call(route, previousRoute); + } + + @override + void didReplace({ Route? oldRoute, Route? newRoute }) { + onReplaced?.call(newRoute, oldRoute); + } + + @override + void didStartUserGesture(Route route, Route? previousRoute) { + onStartUserGesture?.call(route, previousRoute); + } +} diff --git a/test/widgets/content_checks.dart b/test/widgets/content_checks.dart index 51ffbdf0d4..1faf0e2d62 100644 --- a/test/widgets/content_checks.dart +++ b/test/widgets/content_checks.dart @@ -1,7 +1,19 @@ import 'package:checks/checks.dart'; +import 'package:flutter/widgets.dart'; + import 'package:zulip/widgets/content.dart'; extension RealmContentNetworkImageChecks on Subject { Subject get src => has((i) => i.src, 'src'); // TODO others } + +extension AvatarImageChecks on Subject { + Subject get userId => has((i) => i.userId, 'userId'); +} + +extension AvatarShapeChecks on Subject { + Subject get size => has((i) => i.size, 'size'); + Subject get borderRadius => has((i) => i.borderRadius, 'borderRadius'); + Subject get child => has((i) => i.child, 'child'); +} diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart new file mode 100644 index 0000000000..e59b7b4f57 --- /dev/null +++ b/test/widgets/recent_dm_conversations_test.dart @@ -0,0 +1,302 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/recent_dm_conversations.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../example_data.dart' as eg; +import '../flutter_checks.dart'; +import '../model/binding.dart'; +import '../model/test_store.dart'; +import '../test_navigation.dart'; +import 'content_checks.dart'; + +Future setupPage(WidgetTester tester, { + required List dmMessages, + required List users, + NavigatorObserver? navigatorObserver, + String? newNameForSelfUser, +}) async { + addTearDown(TestZulipBinding.instance.reset); + + await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); + + store.addUser(eg.selfUser); + for (final user in users) { + store.addUser(user); + } + + for (final dmMessage in dmMessages) { + store.handleEvent(MessageEvent(id: 1, message: dmMessage)); + } + + if (newNameForSelfUser != null) { + store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, + fullName: newNameForSelfUser)); + } + + await tester.pumpWidget( + GlobalStoreWidget( + child: MaterialApp( + navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], + home: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: const RecentDmConversationsPage())))); + + // global store, per-account store, and page get loaded + await tester.pumpAndSettle(); +} + +void main() { + TestZulipBinding.ensureInitialized(); + + group('RecentDmConversationsPage', () { + Finder findConversationItem(Narrow narrow) { + return find.byWidgetPredicate( + (widget) => + widget is RecentDmConversationsItem && widget.narrow == narrow, + ); + } + + testWidgets('page builds; conversations appear in order', (WidgetTester tester) async { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + + final message1 = eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]); // 1:1 + final message2 = eg.dmMessage(id: 2, from: eg.selfUser, to: []); // self-1:1 + final message3 = eg.dmMessage(id: 3, from: eg.selfUser, to: [user1, user2]); // group + + await setupPage(tester, users: [user1, user2], + dmMessages: [message1, message2, message3]); + + final items = tester.widgetList(find.byType(RecentDmConversationsItem)).toList(); + check(items).length.equals(3); + check(items[0].narrow).equals(DmNarrow.ofMessage(message3, selfUserId: eg.selfUser.userId)); + check(items[1].narrow).equals(DmNarrow.ofMessage(message2, selfUserId: eg.selfUser.userId)); + check(items[2].narrow).equals(DmNarrow.ofMessage(message1, selfUserId: eg.selfUser.userId)); + }); + + testWidgets('fling to scroll down', (WidgetTester tester) async { + final List users = []; + final List messages = []; + for (int i = 0; i < 30; i++) { + final user = eg.user(userId: i, fullName: 'User ${i.toString()}'); + users.add(user); + messages.add(eg.dmMessage(id: i, from: eg.selfUser, to: [user])); + } + + await setupPage(tester, users: users, dmMessages: messages); + + final oldestConversationFinder = findConversationItem( + DmNarrow.ofMessage(messages.first, selfUserId: eg.selfUser.userId)); + + check(tester.any(oldestConversationFinder)).isFalse(); // not onscreen + await tester.fling(find.byType(RecentDmConversationsPage), + const Offset(0, -200), 4000); + await tester.pumpAndSettle(); + check(tester.any(oldestConversationFinder)).isTrue(); // onscreen + }); + }); + + group('RecentDmConversationsItem', () { + group('appearance', () { + void checkAvatar(WidgetTester tester, DmNarrow narrow) { + final shape = tester.widget( + find.descendant( + of: find.byType(RecentDmConversationsItem), + matching: find.byType(AvatarShape), + )); + check(shape) + ..size.equals(32) + ..borderRadius.equals(3); + + switch (narrow.otherRecipientIds) { + case []: // self-1:1 + check(shape).child.isA().userId.equals(eg.selfUser.userId); + case [var otherUserId]: // 1:1 + check(shape).child.isA().userId.equals(otherUserId); + default: // group + // TODO(#232): syntax like `check(find(…), findsOneWidget)` + tester.widget(find.descendant( + of: find.byWidget(shape.child), + matching: find.byIcon(ZulipIcons.group_dm), + )); + } + } + + void checkTitle(WidgetTester tester, String expectedText, [int? expectedLines]) { + // TODO(#232): syntax like `check(find(…), findsOneWidget)` + final widget = tester.widget(find.descendant( + of: find.byType(RecentDmConversationsItem), + matching: find.text(expectedText), + )); + if (expectedLines != null) { + final renderObject = tester.renderObject(find.byWidget(widget)); + check(renderObject.size.height).equals( + 20.0 // line height + * expectedLines); + } + } + + group('self-1:1', () { + testWidgets('has right content', (WidgetTester tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, users: [], dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, eg.selfUser.fullName); + }); + + testWidgets('short name takes one line', (WidgetTester tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + const name = 'Short name'; + await setupPage(tester, users: [], dmMessages: [message], + newNameForSelfUser: name); + checkTitle(tester, name, 1); + }); + + testWidgets('very long name takes two lines (must be ellipsized)', (WidgetTester tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + const name = 'Long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name'; + await setupPage(tester, users: [], dmMessages: [message], + newNameForSelfUser: name); + checkTitle(tester, name, 2); + }); + }); + + group('1:1', () { + testWidgets('has right content', (WidgetTester tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, user.fullName); + }); + + testWidgets('no error when user somehow missing from store.users', (WidgetTester tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, + users: [], // exclude user + dmMessages: [message], + ); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, '(unknown user)'); + }); + + testWidgets('short name takes one line', (WidgetTester tester) async { + final user = eg.user(userId: 1, fullName: 'Short name'); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + checkTitle(tester, user.fullName, 1); + }); + + testWidgets('very long name takes two lines (must be ellipsized)', (WidgetTester tester) async { + final user = eg.user(userId: 1, fullName: 'Long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name long name'); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + checkTitle(tester, user.fullName, 2); + }); + }); + + group('group', () { + List usersList(int count) { + final result = []; + for (int i = 0; i < count; i++) { + result.add(eg.user(userId: i, fullName: 'User ${i.toString()}')); + } + return result; + } + + testWidgets('has right content', (WidgetTester tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, users: users, dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + }); + + testWidgets('no error when one user somehow missing from store.users', (WidgetTester tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, + users: [user0], // exclude user1 + dmMessages: [message], + ); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, '${user0.fullName}, (unknown user)'); + }); + + testWidgets('few names takes one line', (WidgetTester tester) async { + final users = usersList(2); + final message = eg.dmMessage(from: eg.selfUser, to: users); + await setupPage(tester, users: users, dmMessages: [message]); + checkTitle(tester, users.map((u) => u.fullName).join(', '), 1); + }); + + testWidgets('very many names takes two lines (must be ellipsized)', (WidgetTester tester) async { + final users = usersList(40); + final message = eg.dmMessage(from: eg.selfUser, to: users); + await setupPage(tester, users: users, dmMessages: [message]); + checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); + }); + }); + }); + + group('on tap, navigates to message list', () { + Future runAndCheck(WidgetTester tester, { + required DmMessage message, + required List users + }) async { + final expectedNarrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + + await setupPage(tester, users: users, + dmMessages: [message], + navigatorObserver: testNavObserver); + + await tester.tap(find.byType(RecentDmConversationsItem)); + // no `tester.pump`, to avoid having to mock API response for [MessageListPage] + + check(pushedRoutes).last.settings + ..name.equals('message_list') + ..arguments.equals(expectedNarrow); + } + + testWidgets('1:1', (WidgetTester tester) async { + final user = eg.user(userId: 1, fullName: 'User 1'); + await runAndCheck(tester, users: [user], + message: eg.dmMessage(id: 1, from: eg.selfUser, to: [user])); + }); + + testWidgets('self-1:1', (WidgetTester tester) async { + await runAndCheck(tester, users: [], + message: eg.dmMessage(id: 1, from: eg.selfUser, to: [])); + }); + + testWidgets('group', (WidgetTester tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await runAndCheck(tester, users: [user1, user2], + message: eg.dmMessage(id: 1, from: eg.selfUser, to: [user1, user2])); + }); + }); + }); +}