Skip to content

compose: Prototype upload-file UI #63

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

Merged
merged 11 commits into from
Apr 18, 2023
Merged

Conversation

chrisbobbe
Copy link
Collaborator

Fixes: #57

@chrisbobbe chrisbobbe requested a review from gnprice April 13, 2023 01:01
@chrisbobbe
Copy link
Collaborator Author

I found Dart's new Record feature helpful in a few places!

final placeholder = '[Uploading $filename...]()\n\n'; // TODO(i18n)
_uploads[tag] = (filename: filename, placeholder: placeholder);
notifyListeners(); // _uploads change could affect validationErrors
value = value.replaced(_cursorPositionOrEnd(), placeholder);
Copy link
Collaborator Author

@chrisbobbe chrisbobbe Apr 13, 2023

Choose a reason for hiding this comment

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

This differs from zulip-mobile in the case where the selection is not collapsed, i.e., instead of a blinking cursor, there's a highlight on some of the text.

We still don't replace the selected text; that's unchanged here. But:

  • In zulip-mobile, selection-end is taken to be the cursor position, and the placeholder is inserted there.
  • In this branch, the placeholder is inserted at the end of the whole content-input text.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah. What's the reasoning for that difference?

At first thought it seems a bit irregular that

  • if you put the cursor somewhere in the middle of the text and add a file, the placeholder / upload goes there;
  • but if you instead long-press so that a whole word or whatever is selected, then add a file, the placeholder / upload goes over at the end of the text.

Copy link
Collaborator Author

@chrisbobbe chrisbobbe Apr 14, 2023

Choose a reason for hiding this comment

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

I wouldn't mind following zulip-mobile here, but here's an attempt to describe why I was drawn to this new approach:

I think users are unlikely to see text selection as one of the steps to take to attach a file. The zulip-mobile design follows from that judgment too: there, instead of (e.g.) replacing the selected text, we ignore the fact that some text was selected and we insert the markdown at selection-end.

Then I thought, if some text is selected, it's probably from some previous interaction, like looking up a word in the dictionary. Or it's just an accident. Anyway, the user probably doesn't know, or has forgotten, that the UI wants a hint for a single index where the user will be happy to see the markdown inserted. So I figured nothing in selection (like the start or end) will predict that index well, and we might as well do what we do when the input is unfocused, and what "attach" normally means, which is to put it at the end.

Copy link
Member

Choose a reason for hiding this comment

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

We discussed briefly in the office just now. We'll go and do the insertion where the selection is, not because that's a spot the user was probably asking for it to go exactly (if they did have an exact spot in mind that they were trying to convey, they'd likely have placed a cursor and not a whole selection range), but because we want it to be in a place they're paying attention to, and the selection range will be that.

In particular, if the draft content is long, the end of the content may not even be visible on screen — if we insert there, the user may perceive that nothing happened. The selection area will be on screen, and it has a conspicuous pair of selection handles and conspicuous highlighting.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

Thanks! Comments below.

@override
void close() {
_client.close();
_isOpen = false;
Copy link
Member

Choose a reason for hiding this comment

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

nit: this line should appear in the commit that adds close and _isOpen, rather than the next commit

Comment on lines -297 to +299
// TODO abort long-poll on [dispose]
// TODO abort long-poll and close LiveApiConnection on [dispose]
Copy link
Member

Choose a reason for hiding this comment

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

nit: this too

@@ -17,6 +17,8 @@ class InitialSnapshot {

final List<CustomProfileField> customProfileFields;

final int maxFileUploadSizeMib;
Copy link
Member

Choose a reason for hiding this comment

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

Let's put this after subscriptions, following the order in the docs.

I think for this type we'll probably want to follow the docs' order exactly, because it's such a very long list (in the docs, and eventually in our type as we fill out more of it) that it'd otherwise be unmanageable to compare the one list to the other.

Comment on lines 56 to 58
final http.Client _client = http.Client();

Map<String, String> _headers() => authHeader(auth);

bool _isOpen = true;
Copy link
Member

Choose a reason for hiding this comment

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

nit: don't split up the fields with an unrelated method

Specifically I think putting _headers after close, before get, would be good. It's basically a shared private helper for get and post and friends.

Comment on lines +103 to +106
http.MultipartRequest request = http.MultipartRequest('POST', Uri.parse("${auth.realmUrl}/api/v1/$route"))
..files.add(http.MultipartFile('file', content, length, filename: filename))
..headers.addAll(_headers());
Copy link
Member

Choose a reason for hiding this comment

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

Neat. Where did you find this as the way to make this sort of request?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I noticed that zulip-mobile was using the header Content-Type: multipart/form-data for all and only the requests to send a file to the server, so I figured we wanted that header too. This MultipartRequest class seemed like a convenient way to make a request with that header—

A multipart/form-data request.

—and then I was also happy to see the convenient interface for uploading files, with ..files.add, etc.

For how to represent the file, I chose Stream<List<int>> content instead of a path, hoping that would push any file-access issues (like with permissions) out to the caller to deal with, and letting our /api/ code continue to be just a channel for data going to and from the server.

Comment on lines 378 to 380
final readStream = file.readStream;
final size = file.size;
final name = file.name;
Copy link
Member

Choose a reason for hiding this comment

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

Can use a pattern here too:

Suggested change
final readStream = file.readStream;
final size = file.size;
final name = file.name;
final PlatformFile(:readStream, :size, :name) = file;

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh, nice! Great to have all that on one line.

Comment on lines 381 to 383

// Will fail if we didn't pass `withReadStream: true` to pickFiles
assert(readStream != null, 'readStream missing');
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Will fail if we didn't pass `withReadStream: true` to pickFiles
assert(readStream != null, 'readStream missing');
assert(readStream != null); // We passed `withReadStream: true` to pickFiles.

The description string isn't needed because it's redundant with the expression.

One of the nice things about assert being a language feature (vs. the invariant function we've used in JS in zulip-mobile) is that it can, and does, automatically include the source text of the failed expression in the failure message.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm, interesting!

Comment on lines 386 to 387
final result = await uploadFile(store.connection, content: readStream!, length: size, filename: name);
url = realmUrl.resolve(result.uri);
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to use resolve? I think the relative URL should work fine. Indeed I think that's what web uses (which would be why that's what the upload-file API route returns.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh OK, sure I think so. Will test to make sure.

Comment on lines 390 to 391
// TODO: Specifically handle `413 Payload Too Large`
// TODO: On API errors, quote `msg` from server, with "The server said:"
Copy link
Member

Choose a reason for hiding this comment

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

Can help make these findable when we do the underlying work to make them possible:

Suggested change
// TODO: Specifically handle `413 Payload Too Large`
// TODO: On API errors, quote `msg` from server, with "The server said:"
// TODO(#37): Specifically handle `413 Payload Too Large`
// TODO(#37): On API errors, quote `msg` from server, with "The server said:"

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh right; I'd forgotten we had that issue #37, thanks.

Comment on lines 480 to 481
],
))));
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
],
))));
]))));

chrisbobbe added a commit to chrisbobbe/zulip-flutter that referenced this pull request Apr 14, 2023
As we did for ContentTextEditingController's listener in the
previous commit.

As Greg pointed out in reviewing the previous commit:
  zulip#63 (comment)

> Perhaps apply the same rename to the topic controller too. We
> won't currently be using that extra generality, but in principle
> there's no reason we wouldn't.
@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed, and please see my response here: #63 (comment)

…get`

Much like the `meta` package a few weeks ago in 0b89ce9, I don't
understand why this causes an update, but it does.
Soon we'll give LiveApiConnection a nontrivial implementation of
this method.
As Alex points out in discussion:
  https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20http.20.60Client.60/near/1545511

> Looks like doing so will allow connection reuse, which is a great
> performance boost, particularly with HTTP/2.

So that seems good. This will also help with file uploads (zulip#56, zulip#57,
and zulip#61) because http.Client has a `send` method --
  https://pub.dev/documentation/http/latest/http/Client/send.html
-- that we can use for requests we want more control over (in
particular, a file-upload request), and a counterpart toplevel
convenience function like `http.send` isn't offered.

See doc:
  https://pub.dev/documentation/http/latest/http/Client-class.html
Toward zulip#57, "Attach files from compose box, with file picker".
And, from there, to PerAccountStore.
chrisbobbe added a commit to chrisbobbe/zulip-flutter that referenced this pull request Apr 17, 2023
As we did for ContentTextEditingController's listener in the
previous commit.

As Greg pointed out in reviewing the previous commit:
  zulip#63 (comment)

> Perhaps apply the same rename to the topic controller too. We
> won't currently be using that extra generality, but in principle
> there's no reason we wouldn't.
@chrisbobbe
Copy link
Collaborator Author

Revision pushed, fixing #63 (comment) and some indentation things.

Also in this revision, I've made it so the content input gets focused when you select files, if it wasn't focused before.

if (response.statusCode != 200) {
throw Exception("error on file-upload POST $route: status ${response.statusCode}");
}

Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change

(the diff at #63 (comment) had this but was pretty subtle to look at)

TextRange _cursorPositionOrEnd() {
final TextRange selection = value.selection;
final String text = value.text;
final int index = (selection.isValid && selection.end < text.length && selection.isCollapsed)
Copy link
Member

Choose a reason for hiding this comment

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

I see. Yeah, there should be an invariant that selection.end <= text.length (but I think equal to the length is fine and normal — that's what you have when the caret is at the end.) And then there's no need for us to re-check invariants that the framework should be providing.

The TextSelection API is unusual in that it has this special value it calls "invalid", namely (-1,-1), and it actually uses that "invalid" value. That should really be represented as null instead, so that an actual TextSelection value would always mean a valid selection. Everyone involved understands that the current API is unfortunate, but it's been a pain to try to migrate off of:
flutter/flutter#122480 (comment)
flutter/flutter#95978 (comment)
flutter/flutter#79615

So when dealing with that isValid getter, one should think of it as having a meaning similar to "!= null". It fortunately doesn't open up a need to start checking other "validity" conditions of that API where one otherwise wouldn't.

Copy link
Member

@gnprice gnprice left a 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! All looks good with just tiny nits (here and above.)

I also tried out the new UI (on an emulated Android 13 device) and it seems fine.

Also in this revision, I've made it so the content input gets focused when you select files, if it wasn't focused before.

Sure, seems reasonable.


for (final (tag, file) in uploadsInProgress) {
final PlatformFile(:readStream, :size, :name) = file;

Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change

TopicTextEditingController and ContentTextEditingController inherit
from ValueNotifier, so they have a `value` property, and their
listeners are notified when that property changes. They don't
currently notify their listeners in any other cases, so the
listeners are reasonable to have "value" in their names.

But for ContentTextEditingController, soon we'll want to call
notifyListeners for something other than a change in `value`: in
particular, it'll have a stateful property to track in-progress file
uploads, and that property will affect `validationErrors`, so we'll
call `notifyListeners` when a file upload starts or ends. So, we
give its listener this more general name.
As we did for ContentTextEditingController's listener in the
previous commit.

As Greg pointed out in reviewing the previous commit:
  zulip#63 (comment)

> Perhaps apply the same rename to the topic controller too. We
> won't currently be using that extra generality, but in principle
> there's no reason we wouldn't.
This will let us query and request focus on the content input, which
we'd like to do soon. (When inserting upload-file markdown, we'd
like to focus the content input if it wasn't already focused.)
@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed.

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.

Attach files from compose box, with file picker
3 participants