-
Notifications
You must be signed in to change notification settings - Fork 306
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RecentDmConversationsPage: Add #249
RecentDmConversationsPage: Add #249
Conversation
To add to the exclusions mentioned in #119 (e.g., that we don't show presence data), I should mention that this also doesn't yet show the number of unreads in each conversation, since we don't have a data structure for that yet. 🙂 |
f02d4e3
to
257af55
Compare
Just updated with a revision that's meant to follow Vlad's design for the new screen's content area (so, excluding the app bar, for now), instead of Material Design. |
257af55
to
bc361d8
Compare
(And another update with some small tweaks.) It's meant to follow Vlad's Figma, so tagging @terpimost; feedback is appreciated. 🙂 Design things excluded here, to be done later:
Screenshot from the "Zulip test device" account: ![]() EDIT: An earlier version of this PR and the screenshot had some of the users' avatars missing. |
Just to expand on this part: the reason this version leaves out the design's top and bottom app bars and "Message" box is because the various UI elements there lead to a variety of screens that aren't yet implemented. When we've implemented more of the screens, we can circle back and build a good way to navigate among them. And the reason it leaves out the presence indicators, unread counts,
|
bc361d8
to
4b5889c
Compare
(Updated to fill in the missing avatars.) |
New screenshot from this same PR revision (ending in 4b5889c), this time with:
(These are real examples from CZO; both conversations include two users with the same name, "Chris Bobbe (Test Account)".) ![]() |
4b5889c
to
caeca7e
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for building this! Comments below.
lib/api/route/events.dart
Outdated
'client_gravatar': false, // TODO 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 turn on |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's include references to the issues (that I filed yesterday) in these TODO comments.
lib/widgets/content.dart
Outdated
final user = store.users[userId]!; | ||
|
||
final resolvedUrl = switch (user.avatarUrl) { | ||
null => null, // TODO handle computing gravatars |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can also add an issue reference to this comment, while we're here.
lib/widgets/content.dart
Outdated
/// To set the size and clip the corners to be round, pass [size]. | ||
/// If [size] is not passed, the caller takes responsibility | ||
/// for doing that with its own square wrapper. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like the caller that's leaving out size
is doing the same thing as this would, except with a different border radius. I think it's probably cleanest for this widget to keep responsibility for applying a size and shape, and just add a double borderRadius
field that callers have to specify.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I can do that.
On the other hand, that'll mean reciting the same size and border radius again for the group-DM icon/avatar, so there are two places to update if we want to change them. Or maybe I can put them in variables. 🤔
final recipientsSansSelf = allRecipientIds | ||
.whereNot((id) => id == selfUser.userId) | ||
.map((id) => store.users[id]!) | ||
.toList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is [DmNarrow.otherRecipientIds], right?
Ah, plus I guess it's mapping the store.users
lookup. But better to push that down inside the cases, I think — that way we don't need to allocate a list in the 1:1 case.
break; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no need for braces, nor break
:
https://dart.dev/language/branches#switch-statements
case 1: { | ||
final otherUser = recipientsSansSelf.single; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can switch on recipientsSansSelf
itself rather than its length, and then this can say
case [var otherUser]:
(Or combined with my other comment above, it'd say var otherUserId
and then look that up in store.users
.)
final selfUser = store.users[store.account.userId]!; | ||
final recipientsSansSelf = allRecipientIds | ||
.whereNot((id) => id == selfUser.userId) | ||
.map((id) => store.users[id]!) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This !
— so that we crash if a user appears which we don't know about — reflects how we'd write this sort of logic in zulip-mobile, but I'd prefer to soften these failures while we're rewriting things here.
In particular since we're planning a product feature where guests (for example) might not know about all the users in the realm, it'll start becoming a lot more likely that the server could have a bug, or even perhaps an edge case that's hard to eliminate even when we fully understand it, where we end up with a user in a list like this that isn't a user we know about. We'll want to try to avoid those situations, but it'd be good to avoid crashing in them.
See [MessageListAppBarTitle] for an example of handling that. Basically we'll end up with things like ?? '(unknown user)'
sprinkled around.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, okay.
child: DefaultTextStyle( | ||
style: const TextStyle( | ||
fontFamily: 'Source Sans 3', | ||
fontSize: 17, | ||
height: (20 / 17), | ||
color: Color(0xFF222222), | ||
).merge(weightVariableTextStyle(context)), | ||
maxLines: 2, | ||
overflow: TextOverflow.ellipsis, | ||
child: title), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This title
widget is always a Text
, and so the DefaultTextStyle
is just applying to that, right?
In that case it seems more straightforward to just have the Text
widget here. The switch above can compute a final String title
instead of a Widget.
child: title), | ||
)), | ||
const SizedBox(width: 8), | ||
// TODO: Unread count |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can include the issue reference
}); | ||
} | ||
|
||
Widget _buildItem(BuildContext context, DmNarrow narrow) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's probably a bit more idiomatic to break this out as its own widget.
There's some discussion of this in the StatelessWidget docs:
https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html
complete with a video "Widgets vs Helper Methods". I think the reasons there why one sometimes really strongly wants separate widgets don't apply here, but it's still an indication of the direction in which to lean.
caeca7e
to
4de84ae
Compare
Thanks for the review! Revision pushed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Comments below.
Let's also get some unit tests for this before merge. Partly that's useful because, as we learned at #219 (comment) , having some widget tests for a screen can let us know when we introduce something that causes Flex overflows or other layout errors on that screen.
Some good things to test would be:
- for each of a self-1:1, another 1:1, and a group thread, test the page builds, that it has the relevant users' names, and that tapping the name navigates to the message list
- have a group thread where names are long, and check the height is right and it's ellipsized (or if ellipsized is hard to check, then just make the names super long so that if it got the right height it must have cut off the text somehow, and didn't throw)
- have a 1:1 thread and a group thread with unknown users, and check the phrase "unknown user" appears but mostly that it didn't throw
- have 30 items in the list so it can't all fit on screen (and check the bottom item doesn't exist, just to make sure); drag to fling-scroll; check the bottom item is now visible
final _avatarSize = 32.0; | ||
final _avatarBorderRadius = 3.0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because these are the same for every widget of this type, let's make them static and const.
// 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).join(', '); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
another user-!
avatar = SizedBox.square( | ||
dimension: _avatarSize, | ||
child: ClipRRect( | ||
borderRadius: BorderRadius.all(Radius.circular(_avatarBorderRadius)), | ||
clipBehavior: Clip.antiAlias, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm yeah, I see, this is a bit annoying.
Still I think this is better than having Avatar
sometimes take responsibility for its shape, and sometimes not. That feels like having that widget live at two different conceptual layers depending on the arguments it's constructed with.
I think the Flutter-upstream-style solution here would probably be to decompose Avatar
into two widgets:
- one named like
AvatarImage
that just builds the RealmContentNetworkImage (consulting the store in order to do so); - one named like
AvatarShape
that is very much like the_AvatarWrapper
in your previous revision, but taking size and borderRadius as parameters; - and then also perhaps one named
Avatar
that is just a convenience composing those two.
Then this switch would compute a Widget avatar
that's either an AvatarImage or an Icon, and then in a single spot afterward this build method would enclose that in an AvatarShape.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense! I can go for that in my next revision.
child: Text( | ||
title, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think the natural analogue of putting a widget's child
last is to put a Text
widget's positional String argument last.
This only became possible with a relatively recent version of Dart, circa 2022, so there may be a lot of code even in the Flutter tree that doesn't. But I think it's the natural thing to do now that one can.
lib/widgets/content.dart
Outdated
super.key, | ||
required this.userId, | ||
required this.size, | ||
this.borderRadius = 4, // TODO vary default value with [size]? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think probably actually cleanest to make this argument required too. That way the value chosen for it is always evident in the source right next to the chosen size.
lib/widgets/content.dart
Outdated
super.key, | ||
required this.userId, | ||
required this.size, | ||
this.borderRadius = 4, // TODO vary default value with [size]? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think probably actually cleanest to make this argument required too. That way the value chosen for it is always evident in the source right next to the chosen size.
4de84ae
to
c79cd7c
Compare
Thanks for the review! Revision pushed, with a first stab at all those unit tests you mentioned. Some things to flag as maybe non-optimal:
|
c79cd7c
to
0b71040
Compare
(Just rebased.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Generally looks great; a few comments below.
- Setting the test device size doesn't seem to do anything when running with
flutter run
vs.flutter test
:
(See reply in an inline comment below.)
- Possible over-reliance on implementation details, like checking specifically for
RecentDmConversationsItem
,AvatarShape
,AvatarImage
, andText
. (?)
I think these are fine, except the inspection of Text
can be made more flexible using find.text
instead as described in a comment below.
In particular RecentDmConversationsItem
reflects the fact that this screen fundamentally is going to be some kind of list — its job is to show a list of the recent DM conversations — and so we're naturally going to have some kind of widget for an item in the list. The name of that widget type, or its exact interface, could change, so there's a bit of exposure here to having to change the tests in the event of a refactor, but I don't think it's too bad.
- To check for correct navigation on tapping an item, I changed application code, to add an identifying
RouteSettings
to the message-list route:
Yeah, I think editing that application code (to make the target page more inspectable, where currently it's expressed only in the form of a builder function) is quite reasonable. I don't love the details of the RouteSettings solution, but can experiment with alternatives post-merge as discussed below.
- As discussed, I didn't find an ideal way to check truncation of the items' titles, for example by checking for the position of a "…" character. Here, I just make the title super-long and assume that it must have been truncated properly if the text doesn't exceed two lines.
👍
lib/widgets/content.dart
Outdated
if (user == null) { | ||
// TODO(log) log? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (user == null) { | |
// TODO(log) log? | |
if (user == null) { // TODO(log) |
Makes it a bit easier to see what the TODO is about when doing a grep.
import 'content_checks.dart'; | ||
|
||
|
||
Future<void> setupPage(WidgetTester tester, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
import 'content_checks.dart'; | |
Future<void> setupPage(WidgetTester tester, { | |
import 'content_checks.dart'; | |
Future<void> setupPage(WidgetTester tester, { |
super.key, | ||
required this.userId, | ||
required this.size, | ||
required this.borderRadius, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The required borderRadius can be squashed into the "Pull out Avatar widget" commit, resolving the TODO in the latter.
check(shape) | ||
..size.equals(32) | ||
..borderRadius.equals(3); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm lukewarm on checking this level of detail of the layout. It's the sort of test that's liable to end up acting just as another place we have to update when we make changes.
But because it's nicely centralized in this test helper, it is at least just one additional place to update, so it's a lot cheaper than the situation people often get into where tons of tests need to be updated for a small change in the code under test. So it's fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. I'm not attached to it; I just saw it as an opportunity to be thorough. 🙂 I see what you mean about how this could get annoying if it weren't centralized in this helper.
await setupPage(tester, users: [], dmMessages: [message]); | ||
|
||
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); | ||
check(getTitle(tester).data).equals(eg.selfUser.fullName); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of find.byType(Text)
and this check on Text.data
, this can use find.text
.
That's a bit more flexible because it will also match if there's inline formatting on the text, or even if there isn't but the code uses Text.rich
anyway (perhaps as part of some refactoring where the same code sometimes does apply inline formatting).
void checkTitleHeight(WidgetTester tester, int expectedLines) { | ||
final renderObject = tester.renderObject<RenderParagraph>(find.byWidget(getTitle(tester))); | ||
check(renderObject.size.height).equals( | ||
20.0 // line height | ||
* expectedLines); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, sorry, I was ambiguous when I said "check the height is right" above. I really just meant checking that the height of the whole item is right — that it's still 48, confirming that it hasn't been blown up taller to make room for lots of names.
This is fine now that it's done; it's just more work than I intended.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No problem 🙂—in fact I think this way makes it simpler to reason about what might be causing the height to blow up, whether it's that the truncation failed, or the system font size was increased (or possibly something else I'm not thinking of?). With a check on the whole item, we'd also have to think about the 4dp of vertical padding.
return MaterialAccountPageRoute(context: context, | ||
settings: RouteSettings(name: 'message_list', arguments: narrow), // for testing | ||
builder: (context) => MessageListPage(narrow: narrow)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I see.
This RouteSettings and the builder feel like they're expressing the same information twice. I'll try experimenting with ways to deduplicate that, and also to avoid us having to make up names like message_list
for each of the pages we might want to test something navigating to. But that can be after merging this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting, yeah. Thanks!
final pushedRoutes = <Route<dynamic>>[]; | ||
final testNavObserver = TestNavigatorObserver() | ||
..onPushed = (route, prevRoute) => pushedRoutes.add(route); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handy!
// 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, good find.
|
||
// N.B. this doesn't seem to work when running the tests with `flutter run` | ||
// instead of `flutter test`, resulting in test failures. Upstream issue: | ||
// https://github.com/flutter/flutter/issues/124071 | ||
tester.view.physicalSize = const Size(390, 844) * 3; // iPhone 13 Pro |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That upstream issue is equally applicable if we don't set physicalSize
ourselves, right? It does seem like an annoying issue. (I see in fact I'd already thumbs-upped it.)
I think it's a bit cleaner if we can avoid setting the physical size when there isn't a reason the tests specifically need to interact with it, and write the tests so that they work on the default flutter_test
screen size. In particular, if all the tests use the same screen size except when the test case specifically mentions a different one, that makes it easier to learn a mental model of the size of it so that one can look at a given test and mentally picture how it's likely to lay out.
That default size is 800x600 logical pixels, so rather wider and a bit shorter than this. (I believe this line is written with the assumption of a 3x device pixel ratio, so that 390x844 is the size it gives in logical pixels.) For these tests, I think that means using longer names in the "long names" tests, and possibly no other changes.
One takeaway from that upstream issue is that it's another reason to keep the app working reasonably on desktop. As the upstream report shows, on desktop the issue has no effect on the reported size — it can affect the reported DPR and reported size in physical pixels, but those are something that far less code interacts with and so will have no effect at all on most tests. So that makes desktop a good target when using flutter run
to investigate a test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I also left a comment on the upstream issue, because I think the original report didn't capture the full scope of the impact.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As the upstream report shows, on desktop the issue has no effect on the reported size
This doesn't seem to hold in my environment: flutter/flutter#124071 (comment) and with this size-setting line removed, the "long name" tests are still giving different results for flutter test
and flutter run
. 😵
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awkward.
The thing is that this size-setting line isn't a great solution either — it's only helpful if one has an iPhone 13 Pro, or something with the same screen size.
(Or I guess if one is working on a Mac and can run a simulated iPhone 13 Pro. Though even then one might have wanted to run it on a physical device.)
I think if you make the name lists in the "very long name" tests sufficiently long, then they should work on a wide range of sizes including the default and any likely devices.
The intermediate-length "long name" tests are trickier, if they're trying to target reaching two lines and no longer. But as it is, it seems like they don't have a way to check that anyway — their checks are exactly the same as the "very long name" tests. So I think the "short name" and "very long name" versions together give us essentially all the same useful coverage.
0b71040
to
63f695f
Compare
Thanks for the review! Revision pushed. |
Thanks for the revision! This all LGTM. I think the intermediate-length "long name" tests may not be adding anything for us over the "short name" and "very long name" tests: |
63f695f
to
80c725e
Compare
Yeah, agreed. Thanks for the many reviews! Merging, with those specific tests removed. |
Made by Vlad for mobile. This is the SVG file Vlad shared on CZO: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20DM-conversation.20list/near/1594671 Related: zulip#119
…ration (And, since the linter complains of sized_box_for_whitespace <https://dart.dev/tools/linter-rules/sized_box_for_whitespace> , swap out the Container for a SizedBox.)
Assets and license downloaded from https://github.com/adobe-fonts/source-sans/tree/ed1808970 , so it's version 3.052.
We should implement these optimizations soon, like zulip-mobile does, but the new "Direct messages" screen (coming next) would otherwise be missing lots of avatars, which looks awkward.
…ting This way, the Route object itself is distinguishable from other Route objects. This will come in handy in widget tests for the upcoming recent-DMs screen (zulip#119), where we'll want to assert that tapping on a conversation brings up the appropriate message list. While we're at it, add the usual `extension FooChecks on Subject<Foo>` boilerplate that we'll use in those tests.
The screen's content area (so, the list of conversations, but not the app bar at the top) is built against Vlad's design: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20DM-conversation.20list/near/1594654 except that some features that appear in that design are left unimplemented for now, since we don't have data structures for them yet: - unread counts - user presence Fixes: zulip#119
80c725e
to
aa905d7
Compare
As discussed when we added this first test of this kind: zulip#249 (comment)
As discussed when we added this first test of this kind: #249 (comment)
I missed this in zulip#249 and made the rows transparent, oops. So they were taking on the scaffold background color, 0xfffafafa a.k.a. ThemeData.canvasColor. (If we were using Material 3, the scaffold background color would be 0xfffefbff a.k.a. ThemeData.colorScheme.background. Neither color is correct for what the Figma actually specifies for the surface underneath this screen's scrollable content. That's 0xfff6f6f6, and we'll start using it soon.) The ink effect on touch was being enabled by the scaffold's underlying Material. To preserve the ink effect, use a Material here instead of e.g. a ColoredBox. See the design in Figma: https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=341%3A12378&mode=dev
I missed this in zulip#249 and made the rows transparent, oops. So they were taking on the scaffold background color, 0xfffafafa a.k.a. ThemeData.canvasColor. (If we were using Material 3, the scaffold background color would be 0xfffefbff a.k.a. ThemeData.colorScheme.background. Neither color is correct for what the Figma actually specifies for the surface underneath this screen's scrollable content. That's 0xfff6f6f6, and we'll start using it soon.) The ink effect on touch was being enabled by the scaffold's underlying Material. To preserve the ink effect, use a Material here instead of e.g. a ColoredBox. See the design in Figma: https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=341%3A12378&mode=dev
I missed this in zulip#249 and made the rows transparent, oops. So they were taking on the scaffold background color, 0xfffafafa a.k.a. ThemeData.canvasColor. (If we were using Material 3, the scaffold background color would be 0xfffefbff a.k.a. ThemeData.colorScheme.background. Neither color is correct for what the Figma actually specifies for the surface underneath this screen's scrollable content. That's 0xfff6f6f6, and we'll start using it soon.) The ink effect on touch was being enabled by the scaffold's underlying Material. To preserve the ink effect, use a Material here instead of e.g. a ColoredBox. See the design in Figma: https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=341%3A12378&mode=dev
Fixes: #119
This includes my current revisions of #246, #247, and #248.