From 6eefeafcbfa5f336627a8abdbc7fa4016c9acb02 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 11:40:43 -0400 Subject: [PATCH] Fix avatar color drift between PHP and JS (#31) The JS side claimed to "mirror the PHP crc32 % 4 logic" but actually implemented a different rolling hash (classic String.hashCode()-style), so the same display name could get different avatar colors depending on whether a comment was server-rendered or client-rendered (new comment, reply, watcher avatars, "Load more" pagination). Added a real CRC-32 (IEEE 802.3/zlib polynomial, UTF-8 byte sequence) to ticket.js and switched all three JS call sites (avatarColorClass, watcher avatars, and buildCommentEl in TicketView.php) to use it, verified to produce identical output to PHP's crc32() including for non-ASCII names. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- assets/js/ticket.js | 32 ++++++++++++++++++++++++++------ views/TicketView.php | 12 +++--------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/assets/js/ticket.js b/assets/js/ticket.js index 316ee34..a174d9e 100644 --- a/assets/js/ticket.js +++ b/assets/js/ticket.js @@ -183,15 +183,35 @@ function toggleEditMode() { } /** - * Compute avatar color class from display name (mirrors PHP crc32 % 4 logic) + * CRC-32 (IEEE 802.3 / zlib polynomial), matching PHP's crc32(). Operates on + * the UTF-8 byte sequence, same as PHP, so results agree for non-ASCII names. + */ +function crc32(str) { + var bytes = unescape(encodeURIComponent(str)); + var table = crc32._table || (crc32._table = (function () { + var t = []; + for (var n = 0; n < 256; n++) { + var c = n; + for (var k = 0; k < 8; k++) { + c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + } + t[n] = c; + } + return t; + })()); + var crc = -1; + for (var i = 0; i < bytes.length; i++) { + crc = (crc >>> 8) ^ table[(crc ^ bytes.charCodeAt(i)) & 0xFF]; + } + return (crc ^ -1) >>> 0; +} + +/** + * Compute avatar color class from display name (mirrors PHP's crc32 % 4 logic) */ function avatarColorClass(displayName) { var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', '']; - var h = 0; - for (var i = 0; i < displayName.length; i++) { - h = ((h << 5) - h + displayName.charCodeAt(i)) | 0; - } - return colors[Math.abs(h) % 4]; + return colors[crc32(displayName) % 4]; } /** diff --git a/views/TicketView.php b/views/TicketView.php index 58ecf95..905cb80 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -1006,9 +1006,7 @@ document.addEventListener('DOMContentLoaded', function () { shown.forEach(function (w) { var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean); var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join(''); - var hash = 0; - for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0; - var color = avatarColors[Math.abs(hash) % 4]; + var color = avatarColors[crc32(w.display_name || '') % 4]; html += '
' + '' + '' + lt.escHtml(initials) + '' + @@ -1252,13 +1250,9 @@ document.addEventListener('DOMContentLoaded', function () { var words = displayName.trim().split(/\s+/).filter(Boolean); var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join(''); - // Avatar color (same modulo logic as PHP: crc32 mod 4) + // Avatar color (real crc32, matching PHP's crc32 % 4 exactly) var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', '']; - var hash = 0; - for (var i = 0; i < displayName.length; i++) { - hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0; - } - var avatarColor = avatarColors[Math.abs(hash) % 4]; + var avatarColor = avatarColors[crc32(displayName) % 4]; // Format date var dateStr = c.created_at || '';