diff --git a/app/assets/javascripts/livestamp.min.js b/app/assets/javascripts/livestamp.min.js deleted file mode 100644 index 88583a8f3..000000000 --- a/app/assets/javascripts/livestamp.min.js +++ /dev/null @@ -1,4 +0,0 @@ -// Livestamp.js / v1.1.2 / (c) 2012 Matt Bradley / MIT License -(function(d,g){var h=1E3,i=!1,e=d([]),j=function(b,a){var c=b.data("livestampdata");"number"==typeof a&&(a*=1E3);b.removeAttr("data-livestamp").removeData("livestamp");a=g(a);g.isMoment(a)&&!isNaN(+a)&&(c=d.extend({},{original:b.contents()},c),c.moment=g(a),b.data("livestampdata",c).empty(),e.push(b[0]))},k=function(){i||(f.update(),setTimeout(k,h))},f={update:function(){d("[data-livestamp]").each(function(){var a=d(this);j(a,a.data("livestamp"))});var b=[];e.each(function(){var a=d(this),c=a.data("livestampdata"); - if(void 0===c)b.push(this);else if(g.isMoment(c.moment)){var e=a.html(),c=c.moment.fromNow();if(e!=c){var f=d.Event("change.livestamp");a.trigger(f,[e,c]);f.isDefaultPrevented()||a.html(c)}}});e=e.not(b)},pause:function(){i=!0},resume:function(){i=!1;k()},interval:function(b){if(void 0===b)return h;h=b}},l={add:function(b,a){"number"==typeof a&&(a*=1E3);a=g(a);g.isMoment(a)&&!isNaN(+a)&&(b.each(function(){j(d(this),a)}),f.update());return b},destroy:function(b){e=e.not(b);b.each(function(){var a= - d(this),c=a.data("livestampdata");if(void 0===c)return b;a.html(c.original?c.original:"").removeData("livestampdata")});return b},isLivestamp:function(b){return void 0!==b.data("livestampdata")}};d.livestamp=f;d(function(){f.resume()});d.fn.livestamp=function(b,a){l[b]||(a=b,b="add");return l[b](this,a)}})(jQuery,moment); diff --git a/app/assets/javascripts/notifications.js b/app/assets/javascripts/notifications.js index 021635266..4799e2c54 100644 --- a/app/assets/javascripts/notifications.js +++ b/app/assets/javascripts/notifications.js @@ -1,28 +1,39 @@ $(() => { /** - * @param {QPixelNotification} notification + * @param {QPixelNotification} notification */ const makeNotification = (notification) => { const template = `
${notification.community_name} · - ${notification.is_read ? 'read' : `unread`} · - ${notification.created_at} + + ${QPixel.DOM.formatTimestamp(notification.created_at)} +
-

${notification.content}

-

+

+ + ${notification.content} + +

+

+ mark ${notification.is_read ? 'unread' : 'read'} -

+ +

`; return template; }; /** - * @param {number} change + * @param {number} change */ const changeInboxCount = (change) => { const counter = $('.inbox-count'); @@ -51,20 +62,20 @@ $(() => { $('.inbox-toggle').on('click', async (ev) => { ev.preventDefault(); const $inbox = $('.inbox'); - if($inbox.hasClass("is-active")) { + if ($inbox.hasClass('is-active')) { const data = await QPixel.getNotifications(); - const $inboxContainer = $inbox.find(".inbox--container"); + const $inboxContainer = $inbox.find('.inbox--container'); $inboxContainer.html(''); - + const unread = data.filter((n) => !n.is_read); const read = data.filter((n) => n.is_read); - + unread.forEach((notification) => { const item = $(makeNotification(notification)); $inboxContainer.append(item); }); - + $inboxContainer.append(``); read.forEach((notification) => { const item = $(makeNotification(notification)); @@ -76,14 +87,17 @@ $(() => { $('.js-read-all-notifs').on('click', async (ev) => { ev.preventDefault(); - await QPixel.fetchJSON('/notifications/read_all', {}, { - headers: { 'Accept': 'application/json' } - }); + await QPixel.fetchJSON( + '/notifications/read_all', + {}, + { + headers: { Accept: 'application/json' }, + }, + ); $('.inbox-count').remove(); $('.js-notification').removeClass('is-teal').addClass('read'); - $('.js-notif-state').text('read'); $('.js-notification-toggle').html(` mark unread`); }); @@ -91,9 +105,13 @@ $(() => { const $tgt = $(evt.target); const id = $tgt.data('id'); - const resp = await QPixel.fetchJSON(`/notifications/${id}/read`, {}, { - headers: { 'Accept': 'application/json' } - }); + const resp = await QPixel.fetchJSON( + `/notifications/${id}/read`, + {}, + { + headers: { Accept: 'application/json' }, + }, + ); const data = await resp.json(); $tgt.parents('.js-notification')[0].outerHTML = makeNotification(data.notification); @@ -106,9 +124,13 @@ $(() => { const $tgt = $(ev.target).is('a') ? $(ev.target) : $(ev.target).parents('a'); const id = $tgt.attr('data-notif-id'); - const resp = await QPixel.fetchJSON(`/notifications/${id}/read`, {}, { - headers: { 'Accept': 'application/json' } - }); + const resp = await QPixel.fetchJSON( + `/notifications/${id}/read`, + {}, + { + headers: { Accept: 'application/json' }, + }, + ); const data = await resp.json(); if (data.status !== 'success') { diff --git a/app/assets/javascripts/posts.js b/app/assets/javascripts/posts.js index 44aad0f44..e0098d5e7 100644 --- a/app/assets/javascripts/posts.js +++ b/app/assets/javascripts/posts.js @@ -50,15 +50,19 @@ $(() => { const files = /** @type {HTMLInputElement} */ ($fileInput[0]).files; const form = /** @type {HTMLFormElement} */ ($tgt[0]); - // TODO: MaxUploadSize is a site setting and can be changed - if (files.length > 0 && files[0].size >= 2000000) { + const maxUploadSize = QPixel.MAX_UPLOAD_SIZE ?? 2 * 1024 * 1024; + + if (files.length > 0 && files[0].size >= maxUploadSize) { const isUploadModalOpened = $('#markdown-image-upload').hasClass('is-active'); const postField = $('.js-post-field'); postField.val(postField.val()?.toString().replace(placeholder, '')); if (!isUploadModalOpened) { - QPixel.createNotification('danger', `Can't upload files with size more than 2MB`); + QPixel.createNotification( + 'danger', + `Can't upload files with size more than ${QPixel.numberToHumanSize(maxUploadSize)}`, + ); } else { $tgt.find('.js-max-size').addClass('has-color-red-700 error-shake'); diff --git a/app/assets/javascripts/profile.js b/app/assets/javascripts/profile.js new file mode 100644 index 000000000..96341813d --- /dev/null +++ b/app/assets/javascripts/profile.js @@ -0,0 +1,32 @@ +document.addEventListener('DOMContentLoaded', () => { + document.querySelector('.js-submit-profile-edit')?.addEventListener('click', (ev) => { + const { target } = ev; + + if (!QPixel.DOM?.isHTMLElement(target)) { + return; + } + + const profileForm = target.closest('form'); + + profileForm?.querySelectorAll('input[type=file]').forEach((el) => { + const files = /** @type {HTMLInputElement} */ (el).files; + + const maxUploadSize = QPixel.MAX_UPLOAD_SIZE ?? 2 * 1024 * 1024; + + if (files.length > 0 && files[0].size >= maxUploadSize) { + if (!ev.defaultPrevented) { + ev.preventDefault(); + } + + const maxSizeCaption = profileForm?.querySelector(`.js-max-size[for='${el.id}']`); + + if (!maxSizeCaption) { + return; + } + + maxSizeCaption.classList.add('has-color-red-700', 'error-shake'); + setTimeout(() => maxSizeCaption?.classList.remove('error-shake'), 1000); + } + }); + }); +}); diff --git a/app/assets/javascripts/qpixel_api.js b/app/assets/javascripts/qpixel_api.js index acbf02447..b66ffb351 100644 --- a/app/assets/javascripts/qpixel_api.js +++ b/app/assets/javascripts/qpixel_api.js @@ -52,6 +52,36 @@ window.QPixel = { popped_modals_ct += 1; }, + supportedNumberLocales: () => { + try { + return Intl.NumberFormat.supportedLocalesOf( + Intl.getCanonicalLocales(QPixel.LOCALE ?? 'en') + ); + } catch { + return ['en'] + } + }, + + numberToHumanSize: (value) => { + /** @type {[number, string][]} */ + const unitMap = [ + [1024 ** 4, 'terabyte'], + [1024 ** 3, 'gigabyte'], + [1024 ** 2, 'megabyte'], + [1024 ** 1, 'kilobyte'], + [0, 'byte'] + ]; + + const [size, unit] = unitMap.find(([size]) => value >= size) ?? [0, 'byte']; + + return new Intl.NumberFormat(QPixel.supportedNumberLocales(), { + notation: 'compact', + style: 'unit', + unit, + unitDisplay: 'narrow', + }).format(size ? value / size : value).toUpperCase(); + }, + offset: function (el) { const topLeft = $(el).offset(); return { @@ -99,7 +129,7 @@ window.QPixel = { }, /** - * @type {QPixelFilter[]|null} + * @type {Record|null} */ _filters: null, @@ -203,16 +233,16 @@ window.QPixel = { }, filters: async () => { - if (this._filters == null) { + if (QPixel._filters == null) { // If they're still absent after loading from storage, load from the API. const resp = await QPixel.getJSON('/users/me/filters'); const data = await resp.json(); QPixel.Storage?.set('user_filters', data); - this._filters = data; + QPixel._filters = data; } - return this._filters; + return QPixel._filters; }, defaultFilter: async (categoryId) => { @@ -234,12 +264,12 @@ window.QPixel = { headers: { 'Accept': 'application/json' } }); - /** @type {QPixelResponseJSON<{ filters: QPixelFilter[] }>} */ + /** @type {QPixelResponseJSON<{ filters: Record }>} */ const data = await QPixel.parseJSONResponse(resp, 'Failed to save filter'); QPixel.handleJSONResponse(data, (data) => { - this._filters = data.filters; - QPixel.Storage?.set('user_filters', this._filters); + QPixel._filters = data.filters; + QPixel.Storage?.set('user_filters', QPixel._filters); }); }, @@ -249,12 +279,12 @@ window.QPixel = { method: 'DELETE' }); - /** @type {QPixelResponseJSON<{ filters: QPixelFilter[] }>} */ + /** @type {QPixelResponseJSON<{ filters: Record }>} */ const data = await QPixel.parseJSONResponse(resp, 'Failed to delete filter'); QPixel.handleJSONResponse(data, (data) => { - this._filters = data.filters; - QPixel.Storage?.set('user_filters', this._filters); + QPixel._filters = data.filters; + QPixel.Storage?.set('user_filters', QPixel._filters); }); }, diff --git a/app/assets/javascripts/qpixel_dom.js b/app/assets/javascripts/qpixel_dom.js index 46ab661dd..091bdf30b 100644 --- a/app/assets/javascripts/qpixel_dom.js +++ b/app/assets/javascripts/qpixel_dom.js @@ -56,6 +56,10 @@ window.QPixel ||= {}; }, duration); }, + formatTimestamp: (timestamp) => { + return moment(timestamp).format('YYYY-MM-DD hh:mm:ss UTC'); + }, + getModifierState: (e) => { return !!e.altKey || !!e.ctrlKey || !!e.metaKey || !!e.shiftKey; }, diff --git a/app/assets/javascripts/relative_time.js b/app/assets/javascripts/relative_time.js new file mode 100644 index 000000000..69d126eec --- /dev/null +++ b/app/assets/javascripts/relative_time.js @@ -0,0 +1,47 @@ +document.addEventListener('DOMContentLoaded', () => { + const updateInterval = 6e4; // updates relative time once a minute + let lastRunAt = -1; + + /** + * @param {Node} el element to process + */ + const processNode = (el) => { + if (!QPixel.DOM?.isHTMLElement(el)) { + return; + } + + const { relstamp } = el.dataset; + + if (!relstamp) { + return; + } + + el.textContent = `${QPixel.DOM.formatTimestamp(relstamp)} (${moment(relstamp).fromNow()})`; + }; + + /** + * @type {FrameRequestCallback} + */ + const updateRelativeTime = (timestamp) => { + const elapsed = timestamp - lastRunAt; + + if (elapsed < updateInterval && lastRunAt !== -1) { + requestAnimationFrame(updateRelativeTime); + return; + } + + document.querySelectorAll('[data-relstamp]').forEach(processNode); + + lastRunAt = timestamp; + requestAnimationFrame(updateRelativeTime); + }; + + requestAnimationFrame(updateRelativeTime); + + new MutationObserver(() => { + document.querySelectorAll('[data-relstamp]').forEach(processNode); + }).observe(document, { + attributes: true, + subtree: true, + }); +}); diff --git a/app/assets/stylesheets/search.scss b/app/assets/stylesheets/search.scss index 8cb54160e..c40b12804 100644 --- a/app/assets/stylesheets/search.scss +++ b/app/assets/stylesheets/search.scss @@ -1,5 +1,28 @@ @import 'variables'; +.search-widget { + gap: 0.5em; + + .search-widget-input { + flex-grow: 1; + margin: 0; + } + + @media screen and (max-width: $screen-md) { + flex-direction: row; + } + + @media screen and (max-width: $screen-sm) { + align-items: start; + flex-direction: column; + gap: 0.15em; + + .search-widget-input { + width: 100%; + } + } +} + .search-filters:not([open]) { border-top: 1px solid $muted-graphic; } diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 99e3af8ac..8bddbed17 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -323,7 +323,7 @@ def post @post = Post.find(params[:post_id]) @comment_threads = CommentThread.accessible_to(current_user, @post) .where(post: @post) - .order(deleted: :asc, archived: :asc, reply_count: :desc) + .priority_order respond_to do |format| format.html { render layout: false } format.json { render json: @comment_threads } diff --git a/app/helpers/site_settings_helper.rb b/app/helpers/site_settings_helper.rb index 9a8aec92a..9dd38945f 100644 --- a/app/helpers/site_settings_helper.rb +++ b/app/helpers/site_settings_helper.rb @@ -6,4 +6,10 @@ def rendered_description(setting) raw_description = setting.description || '' sanitize(render_markdown(raw_description)) end + + # Formats max upload size (in bytes) into a human-friendly representation + # @return [String] formatted value + def human_max_upload_size + number_to_human_size(SiteSetting['MaxRequestBodySize']) + end end diff --git a/app/models/comment_thread.rb b/app/models/comment_thread.rb index 68d40af67..ca3a8924e 100644 --- a/app/models/comment_thread.rb +++ b/app/models/comment_thread.rb @@ -7,6 +7,7 @@ class CommentThread < ApplicationRecord has_many :thread_follower belongs_to :archived_by, class_name: 'User', optional: true + scope :priority_order, -> { order(deleted: :asc, archived: :asc, updated_at: :desc, reply_count: :desc) } scope :initially_visible, -> { where(deleted: false, archived: false).where('reply_count > 0') } scope :publicly_available, -> { where(deleted: false).where('reply_count > 0') } scope :archived, -> { where(archived: true) } diff --git a/app/models/notification.rb b/app/models/notification.rb index 5e1db7ff6..6c3ff4f0f 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -16,9 +16,4 @@ def read? def unread? !read? end - - def rendered_timestamp - formatted = created_at.strftime('%b %-d, %Y at %H:%M') - CGI.unescape_html(formatted).html_safe - end end diff --git a/app/views/comments/_new_thread_modal.html.erb b/app/views/comments/_new_thread_modal.html.erb index 992933b07..3d0618745 100644 --- a/app/views/comments/_new_thread_modal.html.erb +++ b/app/views/comments/_new_thread_modal.html.erb @@ -13,16 +13,16 @@ Start a new comment thread
- created:<1w created < 1 week ago + created:<1w created < 1 week ago
post_type:xxxx type of post diff --git a/app/views/notifications/_notification.html.erb b/app/views/notifications/_notification.html.erb index ee6ab8a8f..1f0f06b49 100644 --- a/app/views/notifications/_notification.html.erb +++ b/app/views/notifications/_notification.html.erb @@ -19,7 +19,10 @@ src="<%= logo_path %>" alt="<% notification.community_name %> logo" /> <% end %> - <%= notification.community_name %> · <%= notification.rendered_timestamp %> + <%= notification.community_name %> · + <%= notification.created_at.utc %> +
<%= render_pings_text(notification.content) %> diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb index 05843506b..8bee21c4b 100644 --- a/app/views/posts/_expanded.html.erb +++ b/app/views/posts/_expanded.html.erb @@ -247,7 +247,7 @@ <% end %>
- <% comment_threads = post.comment_threads.initially_visible.order(updated_at: :desc) %> + <% comment_threads = post.comment_threads.initially_visible.priority_order %> <% public_count = comment_threads.count %> <% available_count = current_user&.post_privilege?('flag_curate', post) ? post.comment_threads.count : post.comment_threads.publicly_available.count %> diff --git a/app/views/posts/_image_upload.html.erb b/app/views/posts/_image_upload.html.erb index 171308373..b9b58e230 100644 --- a/app/views/posts/_image_upload.html.erb +++ b/app/views/posts/_image_upload.html.erb @@ -9,7 +9,9 @@ <%= form_tag upload_path, multipart: true, class: 'form-inline upload-form js-upload-form' do %>
<%= label_tag :file, 'Insert an image', class: 'form-element' %> -
Max file size <%= SiteSetting['MaxUploadSize'] %>.
+
+ Max file size is <%= human_max_upload_size %>. +
<%= file_field_tag :file, class: 'form-element' %>
<% end %> diff --git a/app/views/search/_widget.html.erb b/app/views/search/_widget.html.erb index 335dc1adb..0571f6bc1 100644 --- a/app/views/search/_widget.html.erb +++ b/app/views/search/_widget.html.erb @@ -1,9 +1,9 @@ -
-
+
+
<%= label_tag :search, 'Search term', class: "form-element" %> <%= search_field_tag :search, params[:search], class: 'form-element' %>
-
+
<%= submit_tag 'Search', class: 'button is-medium is-outlined', name: nil %>
diff --git a/app/views/users/edit_profile.html.erb b/app/views/users/edit_profile.html.erb index 65319b03f..1ac84040c 100644 --- a/app/views/users/edit_profile.html.erb +++ b/app/views/users/edit_profile.html.erb @@ -35,8 +35,8 @@ height="64" width="64" /> <%= f.label :avatar, class: "form-element" %> -
- An optional profile picture. Max file size <%= SiteSetting['MaxUploadSize'] %>. +
+ An optional profile picture. Max file size is <%= human_max_upload_size %>.
<%= f.file_field :avatar, class: "form-element" %>
@@ -67,23 +67,23 @@

Extra fields -- your web site, GitHub profile, social-media usernames, whatever you want.
- Only values that begin with "http" are rendered as links.

+ Only values that begin with "http" are rendered as links.

<%= f.fields_for :user_websites do |w| %> -
-
-
<%= w.text_field :label, - class: 'form-element', - autocomplete: 'off', - placeholder: 'label' %>
+
+
+
<%= w.text_field :label, + class: 'form-element', + autocomplete: 'off', + placeholder: 'label' %>
+
+
+
<%= w.text_field :url, + class: 'form-element', + autocomplete: 'off', + placeholder: 'https://...' %>
+
-
-
<%= w.text_field :url, - class: 'form-element', - autocomplete: 'off', - placeholder: 'https://...' %>
-
-
<% end %>
@@ -93,9 +93,8 @@ Your Discord user tag, username or username#1234. <%= f.text_field :discord, class: 'form-element', autocomplete: 'off', placeholder: 'username#1234' %>
- - - <%= f.submit 'Save', class: 'button is-filled' %> + + <%= f.submit 'Save', class: 'button is-filled js-submit-profile-edit' %> <%= link_to 'Cancel', users_me_path, class: 'button is-muted is-outlined js-cancel-edit', diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 64a2b9cf8..74a8ae1b4 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -18,35 +18,35 @@
- <% effective_profile = raw(sanitize(@user.profile&.strip || '', scrubber: scrubber)) %> - <% if effective_profile.blank? %> -

A quiet enigma. We don't know anything about <%= rtl_safe_username(@user) %> yet.

- <% elsif !user_signed_in? && !@user.community_user.privilege?('unrestricted') %> - <%= sanitize(effective_profile, attributes: %w()) %> - <% else %> - <%= effective_profile %> - <% end %> + <% effective_profile = raw(sanitize(@user.profile&.strip || '', scrubber: scrubber)) %> + <% if effective_profile.blank? %> +

A quiet enigma. We don't know anything about <%= rtl_safe_username(@user) %> yet.

+ <% elsif !user_signed_in? && !@user.community_user.privilege?('unrestricted') %> + <%= sanitize(effective_profile, attributes: %w()) %> + <% else %> + <%= effective_profile %> + <% end %>
<% unless !user_signed_in? && !@user.community_user.privilege?('unrestricted') %> <% if @user.valid_websites_for.size.positive? %> -
-

Extra fields

- - <% @user.valid_websites_for.each do |w| %> - - -
<%= w.label %> - <% if w.url[0,4] == 'http' %> - <%= link_to w.url, w.url, rel: 'nofollow' %> - <% else %> - <%= w.url %> +
+

Extra fields

+ + <% @user.valid_websites_for.each do |w| %> + + + + <% end %> - - - <% end %> -
<%= w.label %> + <% if w.url[0,4] == 'http' %> + <%= link_to w.url, w.url, rel: 'nofollow' %> + <% else %> + <%= w.url %> + <% end %> +
-
+
+
<% end %> <% end %> @@ -73,11 +73,11 @@ annotations on user privileges warnings and suspensions sent to user - <% if @user.community_user.suspended? %> - (includes lifting the suspension) - <% elsif @user.community_user.mod_warnings&.size.positive? %> - (latest <%= time_ago_in_words(@user.community_user.latest_warning) %> ago) - <% end %> + <% if @user.community_user.suspended? %> + (includes lifting the suspension) + <% elsif @user.community_user.mod_warnings&.size.positive? %> + (latest <%= time_ago_in_words(@user.community_user.latest_warning) %> ago) + <% end %> warn or suspend user
@@ -120,23 +120,23 @@ user avatar
<% if @user.staff? %> -
- Staff -
+
+ Staff +
<% elsif @user.admin? %> -
- Administrator -
+
+ Administrator +
<% elsif @user.at_least_moderator? %> -
- Moderator -
+
+ Moderator +
<% end %>
- + @@ -145,11 +145,11 @@ @@ -160,7 +160,8 @@ is_me ? 'View your received votes' : "View received votes for #{rtl_safe_username(@user)}" do %> Received votes <% end %> -
(up minus down) +
+ (up minus down) @@ -169,7 +170,9 @@ <% if is_me || current_user&.at_least_moderator? %> - + + + <% end %>
Reputation Reputation <%= @user.reputation %>
- - <%= link_to search_path(search: "user:#{@user.id} post_type:2"), + + <%= link_to search_path(search: "user:#{@user.id} post_type:2"), 'aria-label': is_me ? 'View your answers' : "View answers from #{rtl_safe_username(@user)}" do %> - Answers - <% end %> + Answers + <% end %> <%= @user.metric '2' %>
<%= @user.metric 's' %>
<%= @user.metric 'E' %>
Joined <%= @user.community_user.created_at %>
Joined <%= @user.community_user.created_at %>
@@ -178,14 +181,14 @@
<% @abilities.each do |a| %> - <% if @user.privilege?(a.internal_id) %> - - <% end %> + <% if @user.privilege?(a.internal_id) %> + + <% end %> <% end %>