Skip to content
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

Touchable links in messages #204

Merged
merged 10 commits into from
Jul 6, 2023
Merged

Touchable links in messages #204

merged 10 commits into from
Jul 6, 2023

Conversation

gnprice
Copy link
Member

@gnprice gnprice commented Jun 21, 2023

Fixes #71.
Stacked atop #203.

This is a draft. The branch needs some cleanup, but more fundamentally:

  • I want to write some tests for this code, because the content parsing in particular gains some complexity of a kind it didn't need before. That turns out to be a bit of a yak shave.
  • The way I handle making the links respond both to a tap (to follow the link) and a long-press (to copy the URL) feels like a bit of a hack. I may make some further attempts to clean it up, and should probably also find or start an upstream Flutter discussion of how to do this (i.e. how to have a TextSpan respond to two different gestures). I might end up separating the long-press into a separate PR from the main functionality of tapping to follow the link.

But from a user perspective, this version already works nicely. And between the two points above, plus just iterating on refactorings to get this code to be as clean as it is (at the end of the branch) in this version, it's been a bit over a week since I sat down and wrote up a version that works nicely from a user perspective. So I figured it'd be good to share this branch as a demo, and a preview of what the resulting code will look like.

I also have a draft branch that does much of the yak shave to be able to write tests for content parsing. I might send a PR first that lays that groundwork, and also adds tests for some of the existing content-parsing code, and then return to this after that.

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this direction looks good! One small comment below.

try {
url = store.account.realmUrl.resolve(urlString);
} on FormatException {
debugPrint('link in content failed to parse: $urlString'); // TODO(log)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would also be good to give user feedback here, I think, and perhaps also in the case (below this) where parsing succeeds but launching fails for whatever reason.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, good thought.

Thanks for the review!

@gnprice
Copy link
Member Author

gnprice commented Jul 3, 2023

Just pushed an updated version. Still a draft, but:

The main remaining yak to shave is testing. This requires some sort of mock for the part where we go and invoke url_launcher to try to open the URL the link points to. I'll probably take the approach recommended upstream here:
https://docs.flutter.dev/packages-and-plugins/plugins-in-tests
by wrapping url_launcher. Specifically I'll probably put that in a "binding" class akin to DataBinding (if not shoehorned into DataBinding itself).

Also open is giving the user feedback when parsing or launching fails (as discussed above at #204 (comment) ), and finding or filing an upstream issue about getting the normal open-a-browser interaction on Android, where the system gives the user a choice of browser.

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, looking forward to this!

child: InlineContent(
// If an @-mention is inside a link, let the @-mention override it.
recognizer: null, // TODO make @-mentions tappable, for info on user
// An @-mention can't have an embedded link, can it? // TODO fact-check that.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// An @-mention can't have an embedded link, can it?  // TODO fact-check that.

Wouldn't this require a user's full_name to contain Markdown inline-link syntax? I'm not sure if servers would parse such syntax in an @-mention, but if they do, I think we shouldn't give it an open-URL handler. An @-mention span shouldn't have a behavior where 0.0001% of the time it takes you to some website of a user's choosing, instead of opening the user-profile view in the app.

@gnprice
Copy link
Member Author

gnprice commented Jul 4, 2023

OK, and here it is with tests. Came out pretty nicely, I think, using the "binding" pattern.

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@@ -172,12 +171,13 @@ class _MessageListState extends State<MessageList> {
// TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or
// similar) if that is ever offered:
// https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849
keyboardDismissBehavior: Platform.isIOS
keyboardDismissBehavior: switch (Theme.of(context).platform) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

platform: Use Flutter "target" platform, not actual Platform

Neat!

Comment on lines +101 to +128

/// The value that `ZulipBinding.instance.launchUrl()` should return.
///
/// See also [takeLaunchUrlCalls].
bool launchUrlResult = true;

/// Consume the log of calls made to `ZulipBinding.instance.launchUrl()`.
///
/// This returns a list of the arguments to all calls made
/// to `ZulipBinding.instance.launchUrl()` since the last call to
/// either this method or [reset].
///
/// See also [launchUrlResult].
List<({Uri url, url_launcher.LaunchMode mode})> takeLaunchUrlCalls() {
final result = _launchUrlCalls;
_launchUrlCalls = null;
return result ?? [];
}
List<({Uri url, url_launcher.LaunchMode mode})>? _launchUrlCalls;

@override
Future<bool> launchUrl(
Uri url, {
url_launcher.LaunchMode mode = url_launcher.LaunchMode.platformDefault,
}) async {
(_launchUrlCalls ??= []).add((url: url, mode: mode));
return launchUrlResult;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool! One obstacle that's been in the way of testing the compose box's upload-and-attach buttons is that they use Flutter plugins. This pattern seems helpful for addressing that, too.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, definitely.

If we did end up splitting the bindings into several mixin classes for code organization, I'm imagining the split would look like

  • data
  • notifications
  • miscellaneous plugins that have one or two relevant methods each, where the plugins behind those compose-box buttons would be a prime example (and url_launcher is another prime example).

@gnprice
Copy link
Member Author

gnprice commented Jul 6, 2023

OK, now ready! PTAL.

As anticipated, I dropped the last few commits:
66973f1 wip long-press on links too, to copy; TODO discuss upstream
d53d775 WIP pattern-match on BlockContentNode; hold off until IDE stops missing references
58ab2f6 Revert "WIP pattern-match on BlockContentNode; hold off until IDE stops missing references", mostly

@gnprice gnprice marked this pull request as ready for review July 6, 2023 00:51
@chrisbobbe
Copy link
Collaborator

Thanks, this looks good!

Here are two things I noticed in testing. Not that they need to block merging though, but what do you think:

  • If you have a short paragraph ending in a link, the space between the link and the end margin is also tappable. For example, if I added this check in the 'multiple links in paragraph' test, it would pass:

          await tester.tapAt(base.translate(15*fontSize, 0)); // "foo bar baz"    X
          check(TestZulipBinding.instance.takeLaunchUrlCalls())
            .single.equals((url: Uri.parse('https://b/'), mode: expectedModeAndroid));
  • When you have linkified text and a corresponding image preview below it, like you get with an image-upload interaction, tapping the image opens the lightbox, but tapping the text instead calls launchUrl. I think that parallels the web app, actually, but I noticed it as a difference with zulip-mobile, which looks for clues in the a element that you might want to open the lightbox. (Specifically, it checks the href against a small handful of filename extensions, like jpg.)

@gnprice
Copy link
Member Author

gnprice commented Jul 6, 2023

Thanks for the review! I'll merge.

  • If you have a short paragraph ending in a link, the space between the link and the end margin is also tappable.

Hmm, wacky.

Please go ahead and file that as a bug after this is merged.

A variation on that scenario would be when the paragraph wraps over multiple lines, and the last line is short and ends with a link. I'd guess the same bug would reproduce there. It sounds like the last TextSpan's recognizer is inappropriately being applied through the end of the line when the TextSpan visually doesn't extend that far, so this may be an upstream Flutter bug.

  • When you have linkified text and a corresponding image preview below it, like you get with an image-upload interaction, tapping the image opens the lightbox, but tapping the text instead calls launchUrl. I think that parallels the web app, actually, but I noticed it as a difference with zulip-mobile, which looks for clues in the a element that you might want to open the lightbox. (Specifically, it checks the href against a small handful of filename extensions, like jpg.)

Interesting.

That zulip-mobile behavior seems nicer for the user, when it works. That logic is pretty bad, though; for example it would match image-looking URLs on external sites (for which the lightbox seems inappropriate), and conversely it's not clear where the list of extensions comes from and whether it might miss some perfectly good uploaded images for which the server cheerfully embeds a preview and the lightbox would work fine.

I think that can be a follow-up issue too.

gnprice added 10 commits July 6, 2023 12:48
This makes it possible to test this sort of logic.

See docs for [defaultTargetPlatform]:
  https://api.flutter.dev/flutter/foundation/defaultTargetPlatform.html
We'll need some more bindings in this spirit.  It's not clear there'll
be enough of them that we need to split them into mixins across
multiple files, like Flutter upstream does, so we'll try just putting
them all in a single bindings class.  Start by giving that one class a
more generic name to fit.
This will give us a convenient way to manage "context" information
down through the recursion.
At this stage the recognizer is always null and so this doesn't
do anything.  But it provides us the infrastructure with which to
start recognizing taps on links, zulip#71.
This is a lot better than the default, though still not ideal.
Comments describe a couple of upstream bugs that we should file
or comment on.
@chrisbobbe
Copy link
Collaborator

Makes sense; I've just filed those issues:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

msglist: Make links touchable, opening in browser
2 participants