diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css
index 69331b8..2fa0979 100644
--- a/public/s/css/f0ckm.css
+++ b/public/s/css/f0ckm.css
@@ -6140,6 +6140,7 @@ video.emoji {
height: auto;
/* vertical-align: middle; */
display: inline-block;
+ max-width: 200px;
}
/* video in emoji autocomplete dropdown */
diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js
index 71f6d9b..662ce88 100644
--- a/public/s/js/f0ckm.js
+++ b/public/s/js/f0ckm.js
@@ -7343,11 +7343,20 @@ class NotificationSystem {
}
}
} else if (data.type === 'profile_update') {
- const { display_name, user, banner_file, banner_position, banner_size } = data.data;
+ const { display_name, user, banner_file, banner_position, banner_size, avatar_file, username_color, description } = data.data;
if (display_name !== undefined) this.applyDisplayNameUpdate(display_name, user);
if (banner_file !== undefined || banner_position !== undefined || banner_size !== undefined) {
this.applyBannerUpdate(banner_file, banner_position, banner_size, user);
}
+ if (avatar_file !== undefined) {
+ this.applyAvatarUpdate(avatar_file, user);
+ }
+ if (username_color !== undefined) {
+ this.applyUsernameColorUpdate(username_color, user);
+ }
+ if (description !== undefined) {
+ this.applyDescriptionUpdate(description, user);
+ }
// Sync preferences to global session object for client-side gating (like DND)
if (window.f0ckSession && data.data.user_id === window.f0ckSession.id) {
for (const key in data.data) {
@@ -7377,14 +7386,28 @@ class NotificationSystem {
// Capture old value before overwriting — needed for thumb matching below
const oldDisplayName = window.f0ckSession?.display_name || null;
if (window.f0ckSession) window.f0ckSession.display_name = display_name || null;
- const navBtn = document.getElementById('nav-user-toggle');
- if (navBtn) {
- const nameSpan = document.getElementById('nav-display-name');
- if (nameSpan) {
- nameSpan.textContent = display_name || window.f0ckSession?.user || '';
- }
- }
+
const userName = user || window.f0ckSession?.user;
+
+ // Update ALL nav-display-name instances (handles the profile page having a duplicate ID)
+ document.querySelectorAll('#nav-display-name').forEach(nameSpan => {
+ // preserve the badges (Admin, Mod)
+ for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
+ let node = nameSpan.childNodes[i];
+ if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
+ // Match and preserve any non-breaking spaces at the start (from admin/mod badges)
+ const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
+ node.nodeValue = (match ? match[1] : '') + (display_name || userName || '');
+ break;
+ }
+ }
+ });
+
+ const bracket = document.getElementById('username-bracket');
+ if (bracket) {
+ bracket.style.display = display_name ? 'inline' : 'none';
+ }
+
if (userName) {
const userHref = `/user/${userName}`;
// Sidebar / thread comment author links
@@ -7406,6 +7429,105 @@ class NotificationSystem {
}
};
+ // Helper: apply a username color change to relevant DOM nodes
+ this.applyUsernameColorUpdate = (username_color, user) => {
+ const userName = user || window.f0ckSession?.user;
+ if (!userName) return;
+
+ const userHref = `/user/${userName}`;
+ document.querySelectorAll(`a.comment-author[href="${userHref}"]`).forEach(el => {
+ if (username_color) {
+ el.style.color = username_color;
+ } else {
+ el.style.removeProperty('color');
+ }
+ });
+
+ // Handle the item view username link
+ const aUsername = document.getElementById('a_username');
+ if (aUsername && aUsername.getAttribute('href') === userHref) {
+ if (username_color) {
+ aUsername.style.color = username_color;
+ } else {
+ aUsername.style.removeProperty('color');
+ }
+ }
+
+ // Handle favs avatar border color
+ document.querySelectorAll('#favs img').forEach(img => {
+ const link = img.closest('a');
+ if (link && (link.href.includes(`/user/${userName.toLowerCase()}`) || link.href.includes(`/user/${userName}`))) {
+ if (username_color) {
+ img.style.borderColor = username_color;
+ } else {
+ img.style.removeProperty('border-color');
+ }
+ }
+ });
+
+ // Update the infobox block variables for color accents
+ const safeUserName = userName.replace(/"/g, '\\"');
+ document.querySelectorAll(`.user-infobox-block[data-banner-user="${safeUserName}"]`).forEach(block => {
+ if (username_color) {
+ block.style.setProperty('--author-accent', username_color);
+ block.style.setProperty('--author-border', username_color);
+ } else {
+ block.style.setProperty('--author-accent', 'var(--accent)');
+ block.style.setProperty('--author-border', 'var(--accent)');
+ }
+ });
+
+ // Special case for settings page preview block
+ if (window.f0ckSession && userName === window.f0ckSession.user) {
+ const previewBlock = document.getElementById('live-profile-preview-box');
+ if (previewBlock) {
+ if (username_color) {
+ previewBlock.style.setProperty('--author-accent', username_color);
+ previewBlock.style.setProperty('--author-border', username_color);
+ } else {
+ previewBlock.style.setProperty('--author-accent', 'var(--accent)');
+ previewBlock.style.setProperty('--author-border', 'var(--accent)');
+ }
+ }
+ }
+
+ // Handle the nav-display-name span elements (profile header and top navbar)
+ if (window.f0ckSession && userName === window.f0ckSession.user) {
+ document.querySelectorAll('#nav-display-name').forEach(el => {
+ if (username_color) {
+ el.style.color = username_color;
+ } else {
+ el.style.removeProperty('color');
+ }
+ });
+ }
+ };
+
+ // Helper: apply an avatar update to relevant DOM nodes
+ this.applyAvatarUpdate = (avatar_file, user) => {
+ const userName = user || window.f0ckSession?.user;
+ if (!userName) return;
+
+ const newSrc = avatar_file ? `/a/${avatar_file}?t=${Date.now()}` : '/a/default.png';
+
+ // Update Nav Avatar & profile header Avatar (only for current user)
+ if (window.f0ckSession && userName === window.f0ckSession.user) {
+ document.querySelectorAll('.nav-avatar-img, .profile_head_avatar img').forEach(img => img.src = newSrc);
+ }
+
+ // Update globally (comments, sidebar, polls, rank, infoboxes, favs)
+ // We broadly target a[href^="/user/"] img to catch basically any avatar linked to the profile
+ const lowerHref = `/user/${userName.toLowerCase()}`;
+ const exactHref = `/user/${userName}`;
+
+ document.querySelectorAll('.comment-avatar img, .sidebar-avatar, .poll-voter-avatar, .rank-avatar, .user-infobox-avatar img, #favs img, a[href^="/user/"] img').forEach(img => {
+ const link = img.closest('a');
+ if (link && (link.href.includes(lowerHref) || link.href.includes(exactHref))) {
+ img.src = newSrc;
+ }
+ });
+ };
+
// Helper: apply banner update to relevant DOM nodes
this.applyBannerUpdate = (banner_file, banner_position, banner_size, user) => {
const userName = user || window.f0ckSession?.user;
@@ -7439,6 +7561,73 @@ class NotificationSystem {
}
};
+ // Helper: apply description update to relevant DOM nodes
+ this.applyDescriptionUpdate = (description, user) => {
+ const userName = user || window.f0ckSession?.user;
+ console.log('[SSE] applyDescriptionUpdate called for user:', userName, 'description:', description);
+ if (!userName) return;
+
+ // Escape quotes for selector
+ const safeUserName = userName.replace(/"/g, '\\"');
+ // Find the profile header for this user
+ const profileHead = document.querySelector(`.profile_head[data-banner-user="${safeUserName}"]`);
+ console.log('[SSE] applyDescriptionUpdate profileHead found?', !!profileHead);
+
+ if (profileHead) {
+ const descDisplay = profileHead.querySelector('#profile-desc-display div');
+ console.log('[SSE] applyDescriptionUpdate descDisplay found?', !!descDisplay);
+
+ if (descDisplay) {
+ if (!description || description.trim() === '') {
+ descDisplay.innerHTML = 'No description';
+ } else {
+ const tempDiv = document.createElement('div');
+ tempDiv.textContent = description;
+ descDisplay.innerHTML = tempDiv.innerHTML.replace(/\n/g, '
');
+ }
+ }
+ // Also update the edit input if it exists (for the logged in user)
+ if (window.f0ckSession && userName === window.f0ckSession.user) {
+ const input = profileHead.querySelector('#inline-desc-input');
+ if (input) {
+ input.value = description || '';
+ console.log('[SSE] applyDescriptionUpdate input updated');
+ }
+ }
+ }
+
+ // Update any item page infoboxes
+ document.querySelectorAll(`.user-infobox-block[data-banner-user="${safeUserName}"]`).forEach(block => {
+ const infoboxDesc = block.querySelector('.user-infobox-description');
+ if (infoboxDesc) {
+ if (!description || description.trim() === '') {
+ infoboxDesc.innerHTML = '';
+ } else {
+ const tempDiv = document.createElement('div');
+ tempDiv.textContent = description;
+ infoboxDesc.innerHTML = tempDiv.innerHTML.replace(/\n/g, '
');
+ }
+ }
+ });
+
+ // Special case for settings page preview block
+ if (window.f0ckSession && userName === window.f0ckSession.user) {
+ const previewBlock = document.getElementById('live-profile-preview-box');
+ if (previewBlock) {
+ const previewDesc = previewBlock.querySelector('.user-infobox-description');
+ if (previewDesc) {
+ if (!description || description.trim() === '') {
+ previewDesc.innerHTML = 'Welcome to my profile! This is a live preview.';
+ } else {
+ const tempDiv = document.createElement('div');
+ tempDiv.textContent = description;
+ previewDesc.innerHTML = tempDiv.innerHTML.replace(/\n/g, '
');
+ }
+ }
+ }
+ }
+ };
+
// Re-apply own banner after every PJAX navigation and bfcache restore.
// Fixes the case where SSE fired on the settings page before the item page DOM was loaded.
const reapplyOwnBanner = () => {
diff --git a/src/avatar_handler.mjs b/src/avatar_handler.mjs
index 3068d3a..2c1e389 100644
--- a/src/avatar_handler.mjs
+++ b/src/avatar_handler.mjs
@@ -162,6 +162,14 @@ export const handleAvatarUpload = async (req, res) => {
where user_id = ${+req.session.id}
`;
+ const payloadStr = JSON.stringify({
+ user_id: req.session.id,
+ user: req.session.user,
+ avatar_file: finalFilename,
+ avatar: null
+ });
+ await db`select pg_notify('profile_update', ${payloadStr})`;
+
console.log('[AVATAR HANDLER] Upload complete:', finalFilename);
return sendJson(res, {
success: true,
@@ -225,6 +233,13 @@ export const handleAvatarDelete = async (req, res) => {
where user_id = ${+req.session.id}
`;
+ const payloadStr = JSON.stringify({
+ user_id: req.session.id,
+ user: req.session.user,
+ avatar_file: null
+ });
+ await db`select pg_notify('profile_update', ${payloadStr})`;
+
console.log('[AVATAR HANDLER] Delete complete');
return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200);
} catch (err) {
diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs
index bf6d5b0..1a1445d 100644
--- a/src/inc/routes/apiv2/settings.mjs
+++ b/src/inc/routes/apiv2/settings.mjs
@@ -331,6 +331,14 @@ export default router => {
`;
// Sync session immediately
if (req.session) req.session.username_color = color;
+
+ const payloadStr = JSON.stringify({
+ user_id: req.session.id,
+ user: req.session.user,
+ username_color: color
+ });
+ await db`select pg_notify('profile_update', ${payloadStr})`;
+
return res.json({ success: true, color }, 200);
} catch (e) {
console.error('Update Username Color error:', e);
@@ -420,6 +428,14 @@ export default router => {
`;
// Sync session immediately
if (req.session) req.session.display_name = cleanDisplayName;
+
+ const payloadStr = JSON.stringify({
+ user_id: req.session.id,
+ user: req.session.user,
+ display_name: cleanDisplayName
+ });
+ await db`select pg_notify('profile_update', ${payloadStr})`;
+
return res.json({ success: true, display_name: cleanDisplayName, msg: 'Display name updated successfully' }, 200);
} catch (e) {
console.error('Update Display Name error:', e);
@@ -448,6 +464,14 @@ export default router => {
`;
// Sync session immediately
if (req.session) req.session.description = cleanDescription;
+
+ const payloadStr = JSON.stringify({
+ user_id: req.session.id,
+ user: req.session.user,
+ description: cleanDescription
+ });
+ await db`select pg_notify('profile_update', ${payloadStr})`;
+
return res.json({ success: true, description: cleanDescription }, 200);
} catch (e) {
console.error('Update Description error:', e);
diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs
index 9dd8e4b..cd4fa15 100644
--- a/src/inc/routes/notifications.mjs
+++ b/src/inc/routes/notifications.mjs
@@ -102,7 +102,10 @@ db.listen('profile_update', (payload) => {
display_name: data.display_name,
banner_file: data.banner_file,
banner_position: data.banner_position,
- banner_size: data.banner_size
+ banner_size: data.banner_size,
+ avatar_file: data.avatar_file,
+ username_color: data.username_color,
+ description: data.description
};
client.send({ type: 'profile_update', data: publicData });
}
diff --git a/views/user-partial.html b/views/user-partial.html
index 6090c25..8637155 100644
--- a/views/user-partial.html
+++ b/views/user-partial.html
@@ -422,7 +422,37 @@
});
const data = await res.json();
if (data.success) {
- location.reload();
+ let objectUrl = '/a/' + (data.avatar_file || data.file) + '?t=' + Date.now();
+ let wrapper = document.querySelector('.avatar-image-wrapper');
+ if (wrapper) {
+ let placeholder = wrapper.querySelector('.avatar-placeholder');
+ if (placeholder) {
+ let img = document.createElement('img');
+ img.className = 'profile-avatar-img';
+ img.src = objectUrl;
+ wrapper.insertBefore(img, placeholder);
+ placeholder.remove();
+ } else {
+ let img = wrapper.querySelector('.profile-avatar-img');
+ if (img) img.src = objectUrl;
+ }
+ }
+
+ document.querySelectorAll('.nav-avatar-img').forEach(img => img.src = objectUrl);
+ document.querySelectorAll('.comment-avatar img, .sidebar-avatar, .poll-voter-avatar, .rank-avatar').forEach(img => {
+ // Only replace if it matches the user's old avatar file, or if they didn't have one, just match if it links to their profile
+ const oldFile = '{{ user.avatar_file }}';
+ const oldId = '{{ user.avatar }}';
+ const isOldImage = (oldFile && img.src.includes('/a/' + oldFile)) || (oldId && img.src.includes('/t/' + oldId + '.webp'));
+ const link = img.closest('a');
+ const isUserLink = link && link.href.includes('/user/{{ user.user }}');
+
+ if (isOldImage || isUserLink) {
+ img.src = objectUrl;
+ }
+ });
+ avatarEditBtn.innerHTML = '';
+ avatarEditBtn.disabled = false;
} else {
alert(data.msg || 'Failed to upload avatar');
avatarEditBtn.innerHTML = '';
@@ -471,7 +501,27 @@
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify(cPayload)
});
- location.reload();
+ nameSaveBtn.disabled = false;
+ nameContainer.style.display = 'flex';
+ nameEditContainer.style.display = 'none';
+
+ const nameSpan = document.getElementById('nav-display-name');
+ if (nameSpan) {
+ nameSpan.style.color = color;
+ for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
+ let node = nameSpan.childNodes[i];
+ if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
+ // Match and preserve any non-breaking spaces at the start (from admin/mod badges)
+ const match = node.nodeValue.match(new RegExp('^(\\\\s*)(.*)'));
+ node.nodeValue = (match ? match[1] : '') + (displayName ? displayName : '{{ user.user }}');
+ break;
+ }
+ }
+ }
+ const bracket = document.getElementById('username-bracket');
+ if (bracket) {
+ bracket.style.display = displayName ? 'inline' : 'none';
+ }
} catch (err) {
alert('Error saving name/color');
nameSaveBtn.disabled = false;
@@ -507,7 +557,17 @@
});
const data = await res.json();
if (data.success) {
- location.reload();
+ descSaveBtn.disabled = false;
+ descDisplay.style.display = 'block';
+ descEditContainer.style.display = 'none';
+ const descTextDiv = descDisplay.querySelector('div');
+ if (description.trim() === '') {
+ descTextDiv.innerHTML = 'No description';
+ } else {
+ const tempDiv = document.createElement('div');
+ tempDiv.textContent = description;
+ descTextDiv.innerHTML = tempDiv.innerHTML.replace(new RegExp('\\\\n', 'g'), '
');
+ }
} else {
alert(data.msg || 'Failed to update description');
descSaveBtn.disabled = false;