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