FEATURE: Humanize file size error messages (#14398)

FEATURE: Humanize file size error messages (#14398)

The file size error messages for max_image_size_kb and max_attachment_size_kb are shown to the user in the KB format, regardless of how large the limit is. Since we are going to support uploading much larger files soon, this KB-based limit soon becomes unfriendly to the end user.

For example, if the max attachment size is set to 512000 KB, this is what the user sees:

Sorry, the file you are trying to upload is too big (maximum size is 512000KB)

This makes the user do math. In almost all file explorers that a regular user would be familiar width, the file size is shown in a format based on the maximum increment (e.g. KB, MB, GB).

This commit changes the behaviour to output a humanized file size instead of the raw KB. For the above example, it would now say:

Sorry, the file you are trying to upload is too big (maximum size is 512 MB)

This humanization also handles decimals, e.g. 1536KB = 1.5 MB

diff --git a/app/assets/javascripts/discourse/app/lib/uploads.js b/app/assets/javascripts/discourse/app/lib/uploads.js
index 19b78ec..f84f737 100644
--- a/app/assets/javascripts/discourse/app/lib/uploads.js
+++ b/app/assets/javascripts/discourse/app/lib/uploads.js
@@ -328,7 +328,11 @@ function displayErrorByResponseStatus(status, body, fileName, siteSettings) {
     case 413:
       const type = uploadTypeFromFileName(fileName);
       const max_size_kb = siteSettings[`max_${type}_size_kb`];
-      bootbox.alert(I18n.t("post.errors.file_too_large", { max_size_kb }));
+      bootbox.alert(
+        I18n.t("post.errors.file_too_large_humanized", {
+          max_size: formatBytes(max_size_kb * 1024),
+        })
+      );
       return true;
 
     // the error message is provided by the server
@@ -354,3 +358,18 @@ export function bindFileInputChangeListener(element, fileCallbackFn) {
   element.addEventListener("change", changeListener);
   return changeListener;
 }
+
+export function formatBytes(bytes, decimals) {
+  if (bytes === 0) {
+    return "0 Bytes";
+  }
+  const kilobyte = 1024,
+    dm = decimals || 2,
+    sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
+    incr = Math.floor(Math.log(bytes) / Math.log(kilobyte));
+  return (
+    parseFloat((bytes / Math.pow(kilobyte, incr)).toFixed(dm)) +
+    " " +
+    sizes[incr]
+  );
+}
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index c1c124e..ff9e7db 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -219,7 +219,7 @@ class UploadsController < ApplicationController
 
     if file_size_too_big?(file_name, file_size)
       return render_json_error(
-        I18n.t("upload.attachments.too_large", max_size_kb: SiteSetting.max_attachment_size_kb),
+        I18n.t("upload.attachments.too_large_humanized", max_size: ActiveSupport::NumberHelper.number_to_human_size(SiteSetting.max_attachment_size_kb.kilobytes)),
         status: 422
       )
     end
@@ -296,7 +296,7 @@ class UploadsController < ApplicationController
 
     if file_size_too_big?(file_name, file_size)
       return render_json_error(
-        I18n.t("upload.attachments.too_large", max_size_kb: SiteSetting.max_attachment_size_kb),
+        I18n.t("upload.attachments.too_large_humanized", max_size: ActiveSupport::NumberHelper.number_to_human_size(SiteSetting.max_attachment_size_kb.kilobytes)),
         status: 422
       )
     end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index bd9cb65..131a203 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -3052,6 +3052,7 @@ en:
         edit: "Sorry, there was an error editing your post. Please try again."
         upload: "Sorry, there was an error uploading that file. Please try again."
         file_too_large: "Sorry, that file is too big (maximum size is %{max_size_kb}kb). Why not upload your large file to a cloud sharing service, then paste the link?"
+        file_too_large_humanized: "Sorry, that file is too big (maximum size is %{max_size}). Why not upload your large file to a cloud sharing service, then paste the link?"
         too_many_uploads: "Sorry, you can only upload one file at a time."
         too_many_dragged_and_dropped_files:
           one: "Sorry, you can only upload %{count} file at a time."
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index a665254..504b9de 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -4039,12 +4039,15 @@ en:
     cannot_promote_failure: "The upload cannot be completed, it may have already completed or previously failed."
     attachments:
       too_large: "Sorry, the file you are trying to upload is too big (maximum size is %{max_size_kb}KB)."
+      too_large_humanized: "Sorry, the file you are trying to upload is too big (maximum size is %{max_size})."
     images:
       too_large: "Sorry, the image you are trying to upload is too big (maximum size is %{max_size_kb}KB), please resize it and try again."
+      too_large_humanized: "Sorry, the image you are trying to upload is too big (maximum size is %{max_size}), please resize it and try again."
       larger_than_x_megapixels: "Sorry, the image you are trying to upload is too large (maximum dimension is %{max_image_megapixels}-megapixels), please resize it and try again."
       size_not_found: "Sorry, but we couldn't determine the size of the image. Maybe your image is corrupted?"
     placeholders:
       too_large: "(image larger than %{max_size_kb}KB)"
+      too_large_humanized: "(image larger than %{max_size})"
 
   avatar:
     missing: "Sorry, we can't find any avatar associated with that email address. Can you try uploading it again?"
diff --git a/lib/cooked_post_processor.rb b/lib/cooked_post_processor.rb
index 21292d9..9624662 100644
--- a/lib/cooked_post_processor.rb
+++ b/lib/cooked_post_processor.rb
@@ -142,7 +142,15 @@ class CookedPostProcessor
     span = create_span_node("url", url)
     a.add_child(span)
     span.add_previous_sibling(create_icon_node("far-image"))
-    span.add_next_sibling(create_span_node("help", I18n.t("upload.placeholders.too_large", max_size_kb: SiteSetting.max_image_size_kb)))
+    span.add_next_sibling(
+      create_span_node(
+        "help",
+        I18n.t(
+          "upload.placeholders.too_large_humanized",
+          max_size: ActiveSupport::NumberHelper.number_to_human_size(SiteSetting.max_image_size_kb.kilobytes)
+        )
+      )
+    )
 
     # Only if the image is already linked
     if is_hyperlinked
diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb
index affbe78..3f2d23b 100644
--- a/lib/upload_creator.rb
+++ b/lib/upload_creator.rb
@@ -386,7 +386,9 @@ class UploadCreator
       @upload.errors.add(:base, I18n.t("upload.images.larger_than_x_megapixels", max_image_megapixels: SiteSetting.max_image_megapixels))
       true
     elsif max_image_size > 0 && filesize >= max_image_size
-      @upload.errors.add(:base, I18n.t("upload.images.too_large", max_size_kb: SiteSetting.max_image_size_kb))
+      @upload.errors.add(:base, I18n.t(
+        "upload.images.too_large_humanized", max_size: ActiveSupport::NumberHelper.number_to_human_size(max_image_size)
+      ))
       true
     else
       false
diff --git a/lib/validators/upload_validator.rb b/lib/validators/upload_validator.rb
index 4238a77..99b9078 100644
--- a/lib/validators/upload_validator.rb
+++ b/lib/validators/upload_validator.rb
@@ -138,7 +138,10 @@ class UploadValidator < ActiveModel::Validator
     max_size_bytes = max_size_kb.kilobytes
 
     if upload.filesize > max_size_bytes
-      message = I18n.t("upload.#{type}s.too_large", max_size_kb: max_size_kb)
+      message = I18n.t(
+        "upload.#{type}s.too_large_humanized",
+        max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size_bytes)
+      )
       upload.errors.add(:filesize, message)
     end
   end
diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb
index 6f67214..61a5290 100644
--- a/spec/components/cooked_post_processor_spec.rb
+++ b/spec/components/cooked_post_processor_spec.rb
@@ -1120,6 +1120,7 @@ describe CookedPostProcessor do
     end
 
     it "replaces large image placeholder" do
+      SiteSetting.max_image_size_kb = 4096
       url = 'https://image.com/my-avatar'
       image_url = 'https://image.com/avatar.png'
 
@@ -1134,6 +1135,7 @@ describe CookedPostProcessor do
       cpp.post_process
 
       expect(cpp.doc.to_s).to match(/<div class="large-image-placeholder">/)
+      expect(cpp.doc.to_s).to include(I18n.t("upload.placeholders.too_large_humanized", max_size: "4 MB"))
     end
   end
 
diff --git a/spec/components/validators/upload_validator_spec.rb b/spec/components/validators/upload_validator_spec.rb
index 8982792..9477cff 100644
--- a/spec/components/validators/upload_validator_spec.rb

[... diff too long, it was truncated ...]

GitHub sha: dba6a5eabfba9e2ad98f62075eea5b9128e5f594

This commit appears in #14398 which was approved by eviltrout. It was merged by martin.