Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cf5461d
fixed comment input label inconsistency between partials
Oaphi Feb 3, 2026
7db0e8c
fixed search widget's input alignment
Oaphi Feb 3, 2026
e8aa832
added priority_order comment thrread scope & aligned expanded/collaps…
Oaphi Feb 3, 2026
ebbc912
fixed user profile sidebar reputation icon misalignment
Oaphi Feb 3, 2026
a9a8b14
QPixel JS API should not use 'this' in arrow function 'methods' - it …
Oaphi Feb 3, 2026
9b0487a
added moment dev dependency for types + enabled proper module resolution
Oaphi Feb 5, 2026
c85e351
removed livestamp.js & added shared timestamp formatting
Oaphi Feb 5, 2026
c8a1797
aligned rendered_timestamp format with the client-side version
Oaphi Feb 5, 2026
9a72ede
added full notification timestamp in ISO8601 format on hover
Oaphi Feb 5, 2026
3173976
settled on timestamp format 'YYYY-MM-DD hh:mm:ss UTC'
Oaphi Feb 5, 2026
91f3c4d
restored relative time functionality
Oaphi Feb 5, 2026
5bb449c
added relative time to full inbox notifications
Oaphi Feb 5, 2026
26c8333
aded relative time to dropdown inbox notifications
Oaphi Feb 5, 2026
97939d6
relative time should also be updated on mutations
Oaphi Feb 5, 2026
924e5dd
'less than' sign should be escaped when used in HTML markup
Oaphi Feb 5, 2026
26338fa
expose MaxUploadSize site setting to the client as QPixel.MAX_UPLOAD_…
Oaphi Feb 5, 2026
062fc57
post image upload should display correct max file size limit in the e…
Oaphi Feb 5, 2026
aa35c22
trying to submit user avatar with size > MaxUploadSize should prevent…
Oaphi Feb 5, 2026
d640db3
made new comment thread captions configurable
Oaphi Feb 5, 2026
f80c92f
exposed current locale to the QPixel global object
Oaphi Feb 7, 2026
5d0968f
added QPixel#numberToHumanSize and QPixel#supportedNumberLocales helpers
Oaphi Feb 7, 2026
03db40f
number_to_human_size in en locale shouldn't add spaces between values…
Oaphi Feb 7, 2026
3a954dc
added MaxRequestBodySize site setting & ensured it's used instead of …
Oaphi Feb 7, 2026
68dc07e
switched server-side provided max upload size strings to human_max_up…
Oaphi Feb 7, 2026
b085aea
allowed seeds to autoremove site settings that are no longer present
Oaphi Feb 7, 2026
3a1a937
MaxUploadSize site setting can be safely dropped now
Oaphi Feb 7, 2026
b72d7e3
added a note that MaxRequestBodySize's default value is equal to 2MB
Oaphi Feb 7, 2026
b72d3a4
don't cleanup site settings in tests as there are fixtures
Oaphi Feb 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions app/assets/javascripts/livestamp.min.js

This file was deleted.

68 changes: 45 additions & 23 deletions app/assets/javascripts/notifications.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,39 @@
$(() => {
/**
* @param {QPixelNotification} notification
* @param {QPixelNotification} notification
*/
const makeNotification = (notification) => {
const template = `<div class="js-notification widget h-m-0 h-m-b-2 ${notification.is_read ? 'read' : 'is-teal'}">
<div class="widget--body h-p-2">
<div class="h-c-tertiary-600 h-fs-caption">
${notification.community_name} &middot;
<span class="js-notif-state">${notification.is_read ? 'read' : `<strong>unread</strong>`}</span> &middot;
<span data-livestamp="${notification.created_at}">${notification.created_at}</span>
<span data-relstamp="${notification.created_at}"
title="${notification.created_at}">
${QPixel.DOM.formatTimestamp(notification.created_at)}
</span>
</div>
<p><a href="${notification.link}" data-id="${notification.id}"
class="h-fw-bold is-not-underlined ${notification.is_read ? 'read' : ''} notification-link">${notification.content}</a></p>
<p class="has-font-size-caption"><a href="javascript:void(0)" data-notif-id="${notification.id}" class="js-notification-toggle">
<p>
<a href="${notification.link}"
data-id="${notification.id}"
class="h-fw-bold is-not-underlined ${notification.is_read ? 'read' : ''} notification-link">
${notification.content}
</a>
</p>
<p class="has-font-size-caption has-margin-bottom-0">
<a href="javascript:void(0)"
data-notif-id="${notification.id}"
class="js-notification-toggle">
<i class="fas fa-${notification.is_read ? 'envelope' : 'envelope-open'}"></i>
mark ${notification.is_read ? 'unread' : 'read'}
</a></p>
</a>
</p>
</div>
</div>`;
return template;
};

/**
* @param {number} change
* @param {number} change
*/
const changeInboxCount = (change) => {
const counter = $('.inbox-count');
Expand Down Expand Up @@ -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(`<div role="separator" class="header-slide--separator"></div>`);
read.forEach((notification) => {
const item = $(makeNotification(notification));
Expand All @@ -76,24 +87,31 @@ $(() => {
$('.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(`<i class="fas fa-envelope"></i> mark unread`);
});

$(document).on('click', '.inbox a:not(.no-unread):not(.read):not(.js-notification-toggle)', async (evt) => {
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);
Expand All @@ -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') {
Expand Down
10 changes: 7 additions & 3 deletions app/assets/javascripts/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
32 changes: 32 additions & 0 deletions app/assets/javascripts/profile.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
});
50 changes: 40 additions & 10 deletions app/assets/javascripts/qpixel_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -99,7 +129,7 @@ window.QPixel = {
},

/**
* @type {QPixelFilter[]|null}
* @type {Record<string, QPixelFilter>|null}
*/
_filters: null,

Expand Down Expand Up @@ -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) => {
Expand All @@ -234,12 +264,12 @@ window.QPixel = {
headers: { 'Accept': 'application/json' }
});

/** @type {QPixelResponseJSON<{ filters: QPixelFilter[] }>} */
/** @type {QPixelResponseJSON<{ filters: Record<string, QPixelFilter> }>} */
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);
});
},

Expand All @@ -249,12 +279,12 @@ window.QPixel = {
method: 'DELETE'
});

/** @type {QPixelResponseJSON<{ filters: QPixelFilter[] }>} */
/** @type {QPixelResponseJSON<{ filters: Record<string, QPixelFilter> }>} */
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);
});
},

Expand Down
4 changes: 4 additions & 0 deletions app/assets/javascripts/qpixel_dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down
47 changes: 47 additions & 0 deletions app/assets/javascripts/relative_time.js
Original file line number Diff line number Diff line change
@@ -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,
});
});
23 changes: 23 additions & 0 deletions app/assets/stylesheets/search.scss
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/comments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
6 changes: 6 additions & 0 deletions app/helpers/site_settings_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading