-
Notifications
You must be signed in to change notification settings - Fork 308
content: Handle thumbnails #820
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
Conversation
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 @rajveermalviya! Taking this straight to integration review because it interacts with the server's 9.0 release timeline.
Here's a partial review before I go AFK for dinner. I've read just the model code so far (lib/model/content.dart).
lib/model/content.dart
Outdated
properties.add(FlagProperty('loading', value: loading, | ||
ifTrue: "is loading", ifFalse: "not loading")); |
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: hide property when it has its usual boring value
properties.add(FlagProperty('loading', value: loading, | |
ifTrue: "is loading", ifFalse: "not loading")); | |
properties.add(FlagProperty('loading', value: loading, ifTrue: "is loading")); |
const ImageNode({ | ||
super.debugHtmlNode, | ||
required this.srcUrl, | ||
required this.thumbnailUrl, | ||
required this.loading, | ||
}); |
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 split this into two constructors:
const ImageNode({ | |
super.debugHtmlNode, | |
required this.srcUrl, | |
required this.thumbnailUrl, | |
required this.loading, | |
}); | |
const ImageNode({ | |
super.debugHtmlNode, | |
required String this.srcUrl, | |
required String this.thumbnailUrl, | |
}) : loading = false; |
and then an ImageNode.loading
that makes those other fields null.
That way the constructors enforce that there are these two mutually exclusive configurations.
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.
… Actually, though, scratch that — because I think we want to model this a bit differently.
Even if the thumbnail is still pending, that's no reason to stop the user from opening the lightbox. So let's always have the original URL, which is the one from the a
element.
lib/model/content.dart
Outdated
return ImageNode( | ||
srcUrl: null, | ||
thumbnailUrl: null, | ||
loading: true, | ||
debugHtmlNode: debugHtmlNode); |
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.
so in particular:
return ImageNode( | |
srcUrl: null, | |
thumbnailUrl: null, | |
loading: true, | |
debugHtmlNode: debugHtmlNode); | |
return ImageNode.loading(debugHtmlNode: debugHtmlNode); |
return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode); | ||
final String srcUrl; | ||
final String? thumbnailUrl; | ||
if (src.startsWith('/user_uploads/thumbnail/')) { |
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.
When this condition is false, let's have a check that src == href
, and return unimplemented if they're unequal.
I believe those were always the same when thumbnailing isn't involved; if there are other cases that exist, I'd like to learn about them, either in manual testing as we use the app or as part of #190.
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.
There seems to be multiple cases where src != href
, for example src urls starting with /external_content/
and https://uploads.zulipusercontent.net/
.
Should they be added one by one explicitly (using startsWith
)?
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.
they be added one by one explicitly
Did this for now.
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.
Yep, that sounds good.
Oh, it looks like we don't have a test case that exercises the /external_content/
case, though. Do you have an example? That'd be good to add.
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 again @rajveermalviya for building this! Done with a review — comments below. Generally it looks great.
The comments are mostly small, but there are a couple that I think might mean glitches a user could spot:
- on _buildAppBarFooter and the height of the app bar
- on addPostFrameCallback vs. scheduleFrameCallback
lib/widgets/content.dart
Outdated
final Uri? resolvedSrcUrl; | ||
final Uri? resolvedThumbnailUrl; | ||
if (thumbnailUrl == null) { | ||
resolvedSrcUrl = store.tryResolveUrl(srcUrl); |
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 gets initialized the same way in both cases, right? So that can be pulled out of the conditional to simplify.
lib/widgets/content.dart
Outdated
Navigator.of(context).push(getLightboxRoute( | ||
context: context, | ||
message: message, | ||
src: resolvedSrc, | ||
src: resolvedSrcUrl!, | ||
thumbnailUrl: thumbnailUrl == null ? null : resolvedThumbnailUrl, |
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 line (and the similar one just below) is a bit puzzling to read. If thumbnailUrl
is null, there's a fallback above that will fill in resolvedThumbnailUrl
… but this line seems to be suggesting that that fallback shouldn't entirely be accepted as filling the role of a thumbnail URL.
In that case, it's probably cleanest if the name resolvedThumbnailUrl
doesn't refer to the fallback, so that it's just store.tryResolveUrl(thumbnailUrl)
or null. It looks like the fallback only ends up getting used in one place below, so the fallback can go there, something like resolvedThumbnailUrl ?? resolvedSrcUrl
.
lib/widgets/content.dart
Outdated
src: resolvedSrc, | ||
mediaType: MediaType.video)); | ||
mediaType: MediaType.video, | ||
thumbnailUrl: null)); |
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: keep order the same between this and the other getLightboxRoute call above — helps keep it easy to compare
lib/widgets/lightbox.dart
Outdated
final PreferredSizeWidget Function(BuildContext context)? buildAppBarFooter; | ||
final Widget? Function( | ||
BuildContext context, Color color, double elevation) buildBottomAppBar; |
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, this is a tricky naming problem. 🙂 I don't have a totally non-confusing name to offer, but I think this would be less confusing:
final PreferredSizeWidget Function(BuildContext context)? buildAppBarFooter; | |
final Widget? Function( | |
BuildContext context, Color color, double elevation) buildBottomAppBar; | |
/// For [AppBar.bottom]. | |
final PreferredSizeWidget Function(BuildContext context)? buildAppBarBottom; | |
final Widget? Function( | |
BuildContext context, Color color, double elevation) buildBottomAppBar; |
The thing is that, at least for me, "footer" really wants to mean the bottom of the whole page or screen.
lib/widgets/lightbox.dart
Outdated
frameBuilder: thumbnailUrl == null ? null | ||
: (context, child, frame, wasSynchronouslyLoaded) { |
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.
These two callbacks (frameBuilder and loadingBuilder) have some nontrivial logic in them, so let's pull them out separately to give them a bit more room to breathe and make them easier to read.
Probably cleanest is to make them private methods on this class, and say frameBuilder: _frameBuilder
. The thumbnail == null
checks can move inside the methods — to get the same effect as the callback having been null, the method can just return its child
argument.
lib/widgets/lightbox.dart
Outdated
_loadingProgress = progress; | ||
// This function is called in a build method and setState | ||
// can't be called in a build method, so delay it. | ||
SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); }); |
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.
Should this be:
SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); }); | |
SchedulerBinding.instance.scheduleFrameCallback((_) { if (mounted) setState(() {}); }); |
The addPostFrameCallback doc seems pretty stern about saying that it doesn't cause a new frame to actually happen, and as a result the callback might not get called for a while if ever.
Even the scheduleFrameCallback version seems not quite optimal in that it means the indicator will lag one frame behind the progress reflected in the actual image. But that's tolerable.
lib/widgets/lightbox.dart
Outdated
@@ -19,20 +20,22 @@ import 'store.dart'; | |||
// fly to an image preview with a different URL, following a message edit | |||
// while the lightbox was open. | |||
class _LightboxHeroTag { | |||
_LightboxHeroTag({required this.messageId, required this.src}); | |||
_LightboxHeroTag({required this.messageId, required this.src, required this.thumbnailUrl}); |
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 we don't need the thumbnail URL in the tag for the hero. The role of the tag is only for equality checks to match up a hero from one frame to the next (and in particular from one page/screen to the next); and src
should be enough to disambiguate, as there shouldn't be multiple images with the same src
but different thumbnailUrl
(let alone within the same message).
test/model/content_test.dart
Outdated
static const imageSingleWithThumbnail = ContentExample( | ||
'single image with thumbnail', |
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.
Generally let's give the nice short simple names to things that represent the current way of doing things, and let the old legacy way have a longer name to distinguish itself. So:
static const imageSingleWithThumbnail = ContentExample( | |
'single image with thumbnail', | |
static const imageSingle = ContentExample( | |
'single image', |
and the old imageSingle
can be imageSingleNoThumbnail
.
I guess that principle could also point toward putting thumbnails on all the other image-related examples, but let's skip that. In fact most of them are already a bit artificial, with the use of /user_avatars/2/realm/icon.png
— I chose that after some experimentation as a value that kept the HTML shorter than it would usually be (while still working in real life).
test/model/content_test.dart
Outdated
|
||
static const imageClusterWithThumbnails = ContentExample( | ||
'multiple images with thumbnails', | ||
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4", |
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 doesn't look like it matches the HTML below 🙂
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.
Relatedly, let's include a link to the example message that had this Markdown source and produced this HTML. That will help us toward automatically validating in the future that the Markdown source in these examples actually corresponds to the given HTML. See this issue for eventually adding that validation:
and this previous PR subthread for background: #603 (comment)
So in this example I believe it would look like:
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4", | |
https://chat.zulip.org/#narrow/stream/7-test-here/near/1893154 | |
"https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3\nhttps://chat.zulip.org/user_avatars/2/realm/icon.png?version=4", |
except with the corrected Markdown.
(After having that idea back in that subthread on #603 in April, it looks like I forgot about it while we were iterating on your videos PR in the weeks after that, oops. Anyway, let's start doing it for new content examples from this point on — it's generally pretty easy to do when you're making the examples in the first place and they're fresh, and will be more of a pain to go back and fill in later.)
test/model/content_test.dart
Outdated
static const imageClusterWithThumbnails = ContentExample( | ||
'multiple images with thumbnails', |
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 can get the same "NoThumbnails" treatment as the single-image case above.
c61a99b
to
016ce48
Compare
resolvedSrc, | ||
filterQuality: FilterQuality.medium))); | ||
child: node.loading | ||
? const CupertinoActivityIndicator() |
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.
Also forgot to flag this earlier, the reason for not using the loader-{black,white}.svg
asset here is that, currently animated icons can't be included in ZulipIcons font (don't know if animated fonts are even possible), I tried and it just displayed a single (first) frame.
So, for now used the CupertinoActivityIndicator
widget which conveniently is similar to the svg.
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 that that's there!
Yeah, I suspect animated fonts are not possible. If we were determined to use the icons from those SVGs, we could find a way (if nothing else, we could rasterize them to high-enough-resolution PNGs). But using this widget instead is a lot simpler and SGTM.
Would you post a screenshot and a video showing what this placeholder looks like? That doesn't require any fiddling on the server — the quick hack I used just now is:
--- lib/model/content.dart
+++ lib/model/content.dart
@@ -1064,7 +1064,7 @@ class _ZulipContentParser {
if (href == null) {
return UnimplementedBlockContentNode(htmlNode: divElement);
}
- if (imgElement.className == 'image-loading-placeholder') {
+ if (true || imgElement.className == 'image-loading-placeholder') {
return ImageNode(
srcUrl: href,
thumbnailUrl: null,
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.
Added a video showing placeholders in the PR description.
Thanks for the review @gnprice, pushed a new revision PTAL :) |
45fc61d
to
e7c434a
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 @rajveermalviya for the revision! Just a few nits in this round, below; plus please post a screenshot and video as per #820 (comment) above.
lib/model/content.dart
Outdated
/// It may be null if Server didn't generate a thumbnail, or doesn't support | ||
/// it yet. It will also be null when [loading] is true. |
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 may be null if Server didn't generate a thumbnail, or doesn't support | |
/// it yet. It will also be null when [loading] is true. | |
/// | |
/// This will be null if the server hasn't yet generated a thumbnail, | |
/// or is a version that doesn't offer thumbnails. | |
/// It will also be null when [loading] is true. |
(in particular I found the meaning of "yet" a bit hard to parse in the current revision)
PreferredSizeWidget? _buildAppBarBottom(BuildContext context) { | ||
if (_loadingProgress == null) { | ||
return null; |
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, moving this here (from the call site) is a nice simplification too.
lib/widgets/lightbox.dart
Outdated
_loadingProgress = progress; | ||
// This function is called in a build method and setState | ||
// can't be called in a build method, so delay it. | ||
SchedulerBinding.instance.scheduleFrameCallback((_) { if (mounted) setState(() {}); }); |
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: lots going on on this line (lots of control flow, with two function literals and a conditional); split it up
In fact I think it benefits from being expanded slightly more, to turn the if-mounted check into an early return to match the pattern it usually appears as:
SchedulerBinding.instance.scheduleFrameCallback((_) { if (mounted) setState(() {}); }); | |
SchedulerBinding.instance.scheduleFrameCallback((_) { | |
if (!mounted) return; | |
setState(() {}); | |
}); |
lib/widgets/lightbox.dart
Outdated
// This function is called in a build method and setState | ||
// can't be called in a build method, so delay it. |
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.
Given the discussion in #mobile-team about how these delayed callbacks are generally to be avoided, let's include in the comment an indication of why in this case it's the best available option. That'll help the reader not wonder if we just didn't think it through; if they want to try to find a solution we missed, it'll let them see the information we'd found so they can start from there.
So:
// This function is called in a build method and setState | |
// can't be called in a build method, so delay it. | |
// The [Image.network] API lets us learn progress information only at | |
// its build time. That's too late for updating the progress indicator, | |
// so delay that update to the next frame. For discussion, see: | |
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/addPostFrameCallback/near/1893539 | |
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/addPostFrameCallback/near/1894124 |
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.
Oh and two more comments — I'd missed some of the changes in my reading a minute ago. One is a still-smaller nit; the other asks for one more test case.
lib/model/content.dart
Outdated
} else if (src.startsWith('/external_content/') | ||
|| src.startsWith('https://uploads.zulipusercontent.net/')) { | ||
srcUrl = src; |
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: indent if
condition's continuation to not look like part of the body:
} else if (src.startsWith('/external_content/') | |
|| src.startsWith('https://uploads.zulipusercontent.net/')) { | |
srcUrl = src; | |
} else if (src.startsWith('/external_content/') | |
|| src.startsWith('https://uploads.zulipusercontent.net/')) { | |
srcUrl = src; |
return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode); | ||
final String srcUrl; | ||
final String? thumbnailUrl; | ||
if (src.startsWith('/user_uploads/thumbnail/')) { |
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.
Yep, that sounds good.
Oh, it looks like we don't have a test case that exercises the /external_content/
case, though. Do you have an example? That'd be good to add.
e7c434a
to
c704eaf
Compare
Thanks for the review @gnprice, pushed a new revision PTAL. |
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 the revision! Everything looks good, modulo one question below.
@alya see the placeholders demonstrated above (and any frame of that video works as a screenshot). They're slightly different from the design we have in web, because this is easiest for reasons discussed at #820 (comment) . Given the release timeline (and because this version seems fine) I won't block merging this to wait for feedback on the design, but we can always adjust it later if desired.
test/model/content_test.dart
Outdated
'single image external', | ||
"https://upload.wikimedia.org/wikipedia/commons/7/78/Verregende_bloem_van_een_Helenium_%27El_Dorado%27._22-07-2023._%28d.j.b%29.jpg", |
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.
Is there an example message for this on chat.zulip.org which you can link to?
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.
Ha, yeah I'd just noticed I forgot to add that and pushed a new revision with that.
c704eaf
to
beb25fa
Compare
Merged! Thanks again @rajveermalviya for the fast work building this and the swift revisions. |
flutter-thumbnails-edit.mp4
flutter-thumbnails-placeholder.mp4
Fixes: #799