-
-
Notifications
You must be signed in to change notification settings - Fork 674
Update the behavior of attach file functionality. #4590
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
Thanks @AkashDhiman! This is great -- these are improvements we've wanted for a while, and I quite appreciate the screencaps and the clear description of caveats. A couple of high-level comments: First, I think we probably do want to keep the "image" flow, even though it's possible to do all the same things via the "file"/"attachment" flow. Two reasons:
Is it possible to get multi-selection in the image picker, too? If not, I'll still be very glad to get the part of this PR merged that makes us stop immediately sending the message (and instead inserts a link in the edit box), and then we can figure out what to do about the image picker after that. Second, the main commit here is really doing two mostly independent things:
Those would best be done as two separate commits: first switch to putting a link in the edit box instead of immediately sending the message, and second switch from one file to several files. I think the second of those commits will be fairly straightforward: a couple of small changes in I have some smaller code-level comments too, which I'll make as a separate review. |
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.
OK, detailed comments below. Thanks again!
src/compose/ComposeBox.js
Outdated
const response = await api.uploadFile(auth, attachments[i].uri, fileName); | ||
[beg, end] = this.replaceMessageTextAtRange(`[${fileName}](${response.uri})\n\n`, beg, end); |
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.
Some time may pass while we're at this await
, and the user might make some edits in the input box. It looks like if they add or remove text before the start of what we inserted, the effect will be that we replace the wrong range of text, because beg
and end
no longer point to where it is. That'll likely leave the markup somewhat garbled, and it may also overwrite what they've typed.
I think with the native APIs it would be possible to attach something like a "span" to the relevant chunk of text, so that we can refer back to it even after the user has made edits that change its offset. But it looks like RN doesn't expose that functionality.
An alternative solution would be to look for the placeholder string we inserted -- i.e. [Uploading ${fileName}...]()
-- and just replace that at the (first) place we find it. Hmm, and if we don't find it (because e.g. the user edited in the middle of it), then I guess insert it at the end. That seems like it'd be pretty robust.
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.
yeah this seems much better, I am doing the find and replace implementation you suggested, I also checked out what RN provides just in case and they don't expose the "span" functionality just as you mentioned, I believe these were the right documentation to look at: https://reactnative.dev/docs/textinput
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, I think that's right!
src/compose/ComposeBox.js
Outdated
for (let i = 0; i < attachments.length; i++) { | ||
const fileName = attachments[i].name ?? `attachment-${i + 1}`; | ||
[beg, end] = this.replaceMessageTextAtRange(`[Uploading ${fileName}...]()\n`, end, end); | ||
try { | ||
const response = await api.uploadFile(auth, attachments[i].uri, fileName); | ||
[beg, end] = this.replaceMessageTextAtRange(`[${fileName}](${response.uri})\n\n`, beg, end); |
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 the overall sequence of what this code does, if there are several files to upload/attach, is going to be:
- We insert the first placeholder, and start uploading.
- Eventually the upload finishes. We edit that placeholder, insert the next one, and start uploading.
- Etc., until we edit the final placeholder.
And I guess that agrees with what I see in your helpful screencaps.
That seems like not quite the ideal sequence -- having the placeholders come in one by one may feel kind of glitchy.
I think a better sequence would be:
- We insert all the placeholders immediately. We start uploading the first file.
- Eventually the upload finishes. We edit that placeholder, and start uploading the next file.
- Etc., until we edit the final placeholder.
(I think this code already makes exactly the right choice in that it only attempts one upload at a time. Uploads are probably often big files, and we may often be on a slow network connection -- no sense hammering it with a lot of uploads at once.)
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 now I am doing it as you suggested (with a simple addition on failure):
- inserting all placeholders immediately.
- Search placeholders one by one and replace them with
linkText
. - If any search fails, append the text at the very end of the message.
- If upload itself fails I am now replacing the placeholder with
[Uploading ${fileName} failed.]()
.
The last point is important to note since it differs from the web app and the previous implementation. I took this decision based on the fact that replacing it with nothing won't be an ideal choice, since it would be very easy to miss out that some uploads have failed (even with a toast message). a permanent replacement with [Uploading ${fileName} failed.]()
. would be able to indicate a user (or the recipients) that there was more to send but it failed.
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 reasoning makes sense to me!
If the webapp's behavior is that when the upload fails, you just have the [Uploading ${fileName}...]()
placeholder stick around indefinitely, then I think this [Uploading ${fileName} failed.]()
behavior would be an improvement there, too. Perhaps mention it in #frontend on chat.zulip.org?
src/compose/ComposeBox.js
Outdated
[beg, end] = this.replaceMessageTextAtRange(`[${fileName}](${response.uri})\n\n`, beg, end); | ||
} catch (e) { | ||
[beg, end] = this.replaceMessageTextAtRange('', beg, end); | ||
showToast(_(`Uploading ${fileName} unsuccessful`)); |
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.
We'll want the string being translated to be a constant string, with a placeholder in it for the filename. See docs/howto/translations.md, and search for "interpolating".
We'll also want to add the string to static/translations/messages_en.json
; from there after the change is merged we'll sync it over to Transifex so that contributors there can translate it. See the next few paragraphs in the same doc.
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.
Just a concern regarding this, docs/howto/translations.md says -
Translators will translate the constant string 'Hello, {name}', including the placeholder.
does that mean we also converts the filename variable to another language and if so shouldn't that remain preserved since it indicates the name of the file in user's device?
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, good question -- that explanation was ambiguous. Does this answer the question?
be39f56
src/compose/ComposeBox.js
Outdated
let [beg, end] = [undefined, this.state.selection.start]; | ||
for (let i = 0; i < attachments.length; i++) { | ||
const fileName = attachments[i].name ?? `attachment-${i + 1}`; | ||
[beg, end] = this.replaceMessageTextAtRange(`[Uploading ${fileName}...]()\n`, end, end); |
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.
We'll also want this to be translated. See previous comment for link to our docs on how.
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.
same concern as above
Sorry for the delay, @gnprice this PR is ready for another review Reply to your comments:
Unfortunately react-native-image-picker doesn't provide this functionality (here is a comment by its maintainer regarding this). The way to move forward with this then would be to use another library like react-native-image-crop-picker. For now I have dropped the commit that removed image-picker functionality in android as suggested, but only added the functionality to send attachment to compose box as well as multiple attachment to the attach files option, i.e. image attachment currently works in this PR just like in master.
Since shifting modules can take time with cleanups etc, there are 2 workarounds for the time being -
Or perhaps we can just ignore and settle it after migrating to another module. There are some translation concerns -
|
src/compose/ComposeBox.js
Outdated
let placeholders = ''; | ||
for (let i = 0; i < attachments.length; i++) { | ||
const fileName = attachments[i].name ?? `attachment-${i + 1}`; | ||
placeholders += `${_('[Uploading {fileName}...]()', { fileName })}\n\n`; |
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 counts as a string concatenation which is denied in /docs/howto/translations.md
Never try to concatenate translated strings together, or do other string manipulation on them.
is this reasonable code? I kept it here since it only involves concatenation with newlines, which shouldn't create a problem according to me.
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 asking. Yeah, this is fine as far as that's concerned -- what that's really meant to be about is attempting to put together a sentence with translated text, because the structure of the sentence will be different in different languages. I guess I should try to clarify that bit in the doc.
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 to know. Though actually -- that comment is several years old, and I believe the set of maintainers more or less turned over last year, along with a new major version. So the comment doesn't necessarily represent the current maintainers' view. But if it doesn't have the feature, then that's that.
Yeah, that may be something to experiment with, then. Definitely out of scope for this PR, though -- #4540 will already be great to fix. |
src/compose/ComposeBox.js
Outdated
let placeholders = ''; | ||
for (let i = 0; i < attachments.length; i++) { | ||
const fileName = attachments[i].name ?? `attachment-${i + 1}`; | ||
placeholders += `${_('[Uploading {fileName}...]()', { fileName })}\n\n`; |
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 asking. Yeah, this is fine as far as that's concerned -- what that's really meant to be about is attempting to put together a sentence with translated text, because the structure of the sentence will be different in different languages. I guess I should try to clarify that bit in the doc.
src/compose/ComposeBox.js
Outdated
|
||
for (let i = 0; i < attachments.length; i++) { | ||
const fileName = attachments[i].name ?? `attachment ${i + 1}`; | ||
const placeholder = _('[Uploading {fileName}...]()', { fileName }); |
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 computing this a second time, how about having an array placeholders
, so that here we can just say placeholders[i]
?
Then we can still concatenate them for the insertMessageTextAtCursorPosition
call, say with placeholders.join('\n\n')
.
The main advantage of computing them just once is that when we're doing it twice, it makes me worry a bit about whether they'll definitely come out the same both times. I'm pretty sure they always do with the code in this PR, but it's the sort of thing where it'd be very easy for them for a future change to the code to accidentally get them out of sync -- so, better to write the code so that structurally it makes sure they're the same.
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.
made array of placeholders
as well as fileNames
. I also accidentally got the "replacement fileName" (attachment ${i+1}
) out of sync in this very commit 😅 (you can see it in the code highlighted here) so this makes it a lot more relevant.
btw I fixed that as well and added a corresponding translation string for 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.
Ha, indeed! And I didn't notice that either 😁 So that's a nice live demonstration of the point.
src/compose/ComposeBox.js
Outdated
try { | ||
const response = await api.uploadFile(auth, attachments[i].uri, fileName); | ||
const linkText = `[${fileName}](${response.uri})`; | ||
if (this.state.message.indexOf(placeholder) !== -1) { | ||
this.setMessageInputValue(this.state.message.replace(placeholder, linkText)); | ||
} else { | ||
this.setMessageInputValue(`${this.state.message} ${linkText}`); | ||
} | ||
} catch (e) { | ||
showToast(_('Uploading {fileName} unsuccessful.', { fileName })); | ||
this.setMessageInputValue( | ||
this.state.message.replace( | ||
placeholder, | ||
_('[Uploading {fileName} failed.]()', { fileName }), | ||
), | ||
); | ||
} | ||
} |
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 make this try/catch be tightly scoped around just the await api.uploadFile
line. That way the error handling in the catch
block gets to be focused on a very specific way things can fail, which is the API call.
The fundamental difference between an exception from the API call and an exception from any of the rest of this code is that an exception from any of the rest would represent a bug in our code. But with an API call it's part of its interface that it might throw an exception for reasons that aren't a bug in anything, just the way the network is at that moment. Generally when throwing an exception is a normal part of the interface of something, we want to handle it explicitly right there, but for exceptions that would represent a bug we want to let them propagate up to very general handlers at the outer layers of the app.
One way in particular that having this larger try
block would be bad, if a situation ever arises where it makes a difference, is that it would mean we had a bug somewhere in the code that's trying to replace the placeholder with the link text -- and we'd be suppressing the error here, so that we didn't get a report of it in Sentry to help us find and fix 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.
I see, I was wondering why this specific code pattern --
try {
mayThrowError()
} catch (e) {
// handle error
return;
}
// handle success
-- was present at some places, instead of just handling success inside the try block after mayThrowError
. Infact I changed this in e6bcb0e (in #4514) thinking it was not necessary, which you also addressed in #4514 (comment). This makes sense.
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, exactly.
I've also now gone and expanded this to an entry in our style guide: d99ad3a
src/compose/ComposeBox.js
Outdated
for (let i = 0; i < attachments.length; i++) { | ||
const fileName = attachments[i].name ?? `attachment-${i + 1}`; | ||
[beg, end] = this.replaceMessageTextAtRange(`[Uploading ${fileName}...]()\n`, end, end); | ||
try { | ||
const response = await api.uploadFile(auth, attachments[i].uri, fileName); | ||
[beg, end] = this.replaceMessageTextAtRange(`[${fileName}](${response.uri})\n\n`, beg, end); |
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 reasoning makes sense to me!
If the webapp's behavior is that when the upload fails, you just have the [Uploading ${fileName}...]()
placeholder stick around indefinitely, then I think this [Uploading ${fileName} failed.]()
behavior would be an improvement there, too. Perhaps mention it in #frontend on chat.zulip.org?
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 @AkashDhiman for the revision -- this looks great! A few comments, above and below.
Let's also have these commits not say "Fixes" yet for those two issues #4540 and #2366, because they don't yet apply to the "add an image" flow, and that's the one that I think most users are interested in most often. We'll say "Fixes" in a later commit that fixes each issue for that flow too.
- I can use
DocumentPicker
to handle images attachment instead ofreact-native-image-picker
, It can be configured to only ask for images (in this mode it only displays file type of images), advantage would be that we can pick multiple images. Disadvantage would be that image previews would be as large as the screen captures above (personallyreact-native-image-picker
previews aren't that big either so I prefer this approach).
Yeah, I'm perfectly happy with the size of previews that are in your screen captures above. When I tried it on my phone, I got much smaller thumbnails (like maybe 48px -- the size of our avatars in the message list), in more of a "list of files" view than a "gallery" view. It looks like there's an icon in the upper right that switches back and forth.
The other issue in the UX seen in those screen captures is that it goes straight to "Downloads", which isn't going to be very helpful if what someone's trying to do is to upload a photo they just took, or a screenshot.
If we can have DocumentPicker
go directly to a view that's like "Images" instead of "Downloads", then that sounds like a good solution.
@@ -179,10 +180,10 @@ class ComposeMenu extends PureComponent<Props> { | |||
}} | |||
/> | |||
{Platform.OS === 'android' && ( | |||
<IconFile | |||
<IconAttach |
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, in the commit message:
ComposeMenu: Refactor `IconFile` to `IconAttach`.
let's use a different verb instead of "refactor" -- like "Replace IconFile
with IconAttach
."
(Or perhaps better yet: "Use IconAttach
instead of IconFile
." I like having the summary line say first what the new code is doing, in preference to what the old code did -- usually that's the more important thing to know when going back through the history.)
The reason is that "refactor" to me means a change that's mainly about how the code is, rather than how it behaves to the user -- the paradigmatic case is a change that gives the code some very different structure, while having no visible effect at all on how the code behaves for the user. Whereas the point of this change is 100% about what the user sees.
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.
src/compose/ComposeBox.js
Outdated
let placeholders = ''; | ||
for (let i = 0; i < attachments.length; i++) { | ||
const fileName = attachments[i].name ?? `attachment-${i + 1}`; | ||
placeholders += `${_('[Uploading {fileName}...]()', { fileName })}\n\n`; |
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, but a different translation-related remark:
Let's have the translated string here be just Uploading {fileName}...
, and interpolate the result inside the [${…}]()
markup syntax.
That way what the translators have to deal with is simpler -- no extra markup on the outside, which we'd be counting on them to keep verbatim and we don't have a great way to make that clear to them.
And on the other hand that's perfectly fine from the perspective of the question in the https://github.com/zulip/zulip-mobile/pull/4590/files#r622602182 thread, because the extra stuff we're adding is purely syntax from the markup language, not anything that's part of English or the user's natural language.
(Conversely, we do want to keep the ...
inside the translated string, because that's punctuation and it might be expressed differently in another language.)
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, did these changes.
Changed to "Related"
I think it goes to the last opened window, which in my screen capture was Downloads. At the very first attempt it opens at recent screen which shows me "recent images on phone" for images, this seems reasonable I think. One concern I forgot to mention before suggesting to use @gnprice besides this I have addressed other changes that you requested and you may check them out. |
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 @AkashDhiman for the revision! Comments below -- just one substantive issue and a few nits. Then this will be ready to merge.
src/compose/ComposeBox.js
Outdated
|
||
this.setState(({ numUploading }) => ({ | ||
numUploading: numUploading - 1, | ||
})); |
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 new return
in that catch
block above causes me to notice a pitfall here: if anything in the function above this point exits the function early, this numUploading
decrement won't run and we'll keep thinking there's an ongoing upload forever (until the user navigates out of the narrow so that this component gets discarded.)
In particular that will happen if the new return
fires, but also in the previous revision it could happen if for some reason something inside that catch
block (like the this.setMessageInputValue
call, for example) were to throw an exception. (At the end of the branch, there's no longer the return
but there's even more code in the function, so there are more opportunities for something to throw an exception.)
The way to fix this, in a way where it's easy to tell by looking at the code that it's definitely taken care of, is to put this decrement in a finally
block. So the function might look like this:
insertAttachment = async (attachments: DocumentPickerResponse[]) => {
this.setState(({ numUploading }) => ({
numUploading: numUploading + 1,
}));
try {
// …
} finally {
this.setState(({ numUploading }) => ({
numUploading: numUploading - 1,
}));
}
};
It's a bit annoying because it causes the whole rest of the function to be indented -- really the ideal pattern here would be RAII, like in C++ or Rust. But this is probably the best approximation to that that we have in JS.
(Here we're discussing followup changes for after this PR.)
OK, I guess some more playing with this is in order, then (and probably a chat thread in #mobile is the right venue.) If it opens the first time to something with a useful gallery view of photos, and stays that way unless you've chosen something else, then that's good.
Hmm, indeed, thanks for filing that. I've replied on that thread. |
Expanded from a couple of PR comments I made recently: #4514 (comment) #4590 (comment)
284595b
to
537cde3
Compare
@gnprice made the changes you requested. A good thing is that we no longer would need to replace |
The functionality provided by this option is to attach file and hence IconAttach (paperclip icon) is a much better placeholder for it.
Previous syntax corresponded to returning a Promise of array of length 1, where the element has a type of `DocumentPickerResponse`. Now it is changed to - a Promise of array of `DocumentPickerResponse`. This is the desired return type for `pickMultiple`.
On pressing attach file from compose menu, attachment was directly dispatched to outbox, they should go through compose box so that a user may add more text if desired. This commit creates a new function `insertAttachment` that is utilized in `ComposeMenu`. It performs the task of uploading the attachment as well as updating the `ComposeBox` message accordingly. Fixes part of zulip#4540 because behaviour of image picker option is unchanged. Fixes-part-of: zulip#4540
This makes it straightforward to look at this code and see that the increment (with `numUploading + 1`) is always paired with a decrement (with `numUploading - 1`), so that there isn't a case where it gets stuck at a higher value due to an exception somewhere.
Previously we could only attach single file using the attach file option in ComposeMenu. This code updates `handleFilesPicker` (previously `handleFilePicker`) to pick multiple files, its response is an array of type `DocumentPickerResponse` which is passed to `insertAttachment` to upload and insert data to `ComposeBox`. `insertAttachment` is modified to handle this array. We first insert the `[Uploading ${filename}...]` placeholder for all attachments, then we upload files one by one and keep replacing their placeholder with `linkText` on success or `[Uploading ${fileName} failed.]` on failure. Fixes part of zulip#2366 because behaviour of image picker option is unchanged. Fixes-part-of: zulip#2366
Thanks @AkashDhiman for the revision! This all looks good, except for the try/finally for decrementing I've gone and merged the PR, with an extra commit added to make that change. Take a look: e202c27. |
Previous functionality:
dispatched to outbox, they should go through compose box so that a user
may add more text if desired.
This commit modifies
handleFilesPicker
(previouslyhandleFilePicker
) tosupport picking multiple files. After picking multiple files the
corresponding data is passed to
insertAttachment
method ofComposeBox
where the content is uploaded one by one and the
ComposeBox
is updatedaccordingly.
Note that dispatch to
uploadFile
no longer takes place now.Fixes-part-of: #4540
Fixes-part-of: #2366
Demonstration of Changes:
Simple Attachment:
Attachment in between text:
Attachment without Internet:
Caveats / Not addressed in this PR:
simple press
press and hold
The error text in toast when upload fails, is not translated.