feat: messages.html WebUI for short messages#944
Conversation
Adds a standalone messages.html page hosted on the Cloudflare Pages site, allowing iOS/iPadOS users (and anyone without userscript support) to read and send XMOJ short messages via the existing API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reviewer's GuideAdds a new standalone messages.html WebUI for XMOJ short messages (including login, inbox, conversations, markdown rendering, image upload, theming, and auto-refresh) and wires it into the existing index.html navigation and feature description links. Sequence diagram for bookmarklet-based login and authenticationsequenceDiagram
actor User
participant Browser
participant XMOJSite as XMOJ_site
participant MessagesPage as Messages_html
participant Api as XMOJBBS_API
User->>Browser: Open XMOJ_site and log in
Browser->>XMOJSite: Request user pages
XMOJSite-->>Browser: HTML with profile and PHPSESSID cookie
User->>Browser: Click bookmarklet
Browser->>Browser: Bookmarklet reads PHPSESSID from cookie
Browser->>Browser: Bookmarklet reads username from element profile
Browser->>Browser: Build redirect URL to messages_html with session hash
Browser->>MessagesPage: Navigate to messages_html#session=username:phpsessid
MessagesPage->>MessagesPage: Init script runs
MessagesPage->>MessagesPage: checkSessionHash parses hash
MessagesPage->>Browser: Save username and phpsessid to localStorage
MessagesPage->>Browser: Replace URL hash with clean path
MessagesPage->>MessagesPage: onLoggedIn show screen_list and navbar user
MessagesPage->>Api: POST GetMailList(Authentication with username, phpsessid)
Api-->>MessagesPage: MailList data
MessagesPage->>Browser: Render inbox with threads and unread badges
Flow diagram for messages.html screen routing and auto-refreshflowchart TD
A_init["Init messages_html script"] --> B_checkHash["checkSessionHash"]
B_checkHash -->|Hash_valid| C_saveSession["Save session to localStorage and set currentUser"]
B_checkHash -->|No_or_invalid_hash| D_loadSession["loadSession from localStorage"]
C_saveSession --> E_onLoggedIn["onLoggedIn: update navbar and show screen_list"]
D_loadSession -->|Found_session| E_onLoggedIn
D_loadSession -->|No_session| F_showLogin["showScreen(screen_login)"]
E_onLoggedIn --> G_loadMailList["loadMailList via GetMailList"]
G_loadMailList --> H_inboxRendered["Render inbox table and click handlers"]
H_inboxRendered --> I_openThread["openThread(otherUser)"]
I_openThread --> J_setThreadState["Set currentThread and isFirstLoad"]
J_setThreadState --> K_showThread["showScreen(screen_thread)"]
K_showThread --> L_startRefresh["startRefresh setInterval(loadThread)"]
K_showThread --> M_loadThreadOnce["loadThread via GetMail"]
M_loadThreadOnce --> N_renderMessages["Render messages, bind image and link handlers"]
N_renderMessages --> O_scrollLogic["Scroll to bottom on first load or near bottom"]
subgraph Auto_refresh_and_visibility
L_startRefresh --> P_intervalTick["Interval: loadThread every 10s"]
P_intervalTick --> M_loadThreadOnce
Q_visibilityChange["document.visibilitychange"] -->|hidden| R_stopRefresh["stopRefresh clearInterval"]
Q_visibilityChange -->|visible_and_currentThread| S_resume["loadThread and startRefresh"]
S_resume --> M_loadThreadOnce
S_resume --> L_startRefresh
end
subgraph Navigation_and_logout
T_backButton["Back button from thread"] --> R_stopRefresh
T_backButton --> G_loadMailList
T_backButton --> H_inboxRendered
U_logoutButton["Logout button"] --> V_clearSession["Remove session from localStorage"]
V_clearSession --> R_stopRefresh
V_clearSession --> F_showLogin
end
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The API client currently only checks for HTTP-level failures; consider standardizing and handling logical errors returned in the JSON payload (e.g., a success flag or error field) so the UI can surface backend error messages consistently instead of assuming a successful shape for
result.Data. - PHPSESSID is stored in
localStorageand reused indefinitely; consider adding an explicit session expiry/refresh mechanism or an easy one-click way to clear credentials (besides full logout), and re-evaluate whether any additional XSS hardening around Markdown rendering is needed given that stealing this token compromises the user’s XMOJ session.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The API client currently only checks for HTTP-level failures; consider standardizing and handling logical errors returned in the JSON payload (e.g., a success flag or error field) so the UI can surface backend error messages consistently instead of assuming a successful shape for `result.Data`.
- PHPSESSID is stored in `localStorage` and reused indefinitely; consider adding an explicit session expiry/refresh mechanism or an easy one-click way to clear credentials (besides full logout), and re-evaluate whether any additional XSS hardening around Markdown rendering is needed given that stealing this token compromises the user’s XMOJ session.
## Individual Comments
### Comment 1
<location path="messages.html" line_range="532-541" />
<code_context>
+ document.getElementById('upload-indicator').textContent = msg;
+}
+
+async function uploadImageData(dataUrl) {
+ var textarea = document.getElementById('thread-compose');
+ var placeholder = '![上传中…]()';
+ textarea.value += '\n' + placeholder;
+ setUploadIndicator('图片上传中…');
+ try {
+ var result = await apiCall('UploadImage', { Image: dataUrl });
+ var imageId = result && result.Data && result.Data.ImageID;
+ if (!imageId) throw new Error('未获取到 ImageID');
+ var mdImg = '';
+ textarea.value = textarea.value.replace(placeholder, mdImg);
+ setUploadIndicator('');
+ showToast('图片上传成功', 'success');
+ } catch (err) {
+ textarea.value = textarea.value.replace('\n' + placeholder, '');
+ setUploadIndicator('');
+ showToast('图片上传失败:' + err.message, 'danger');
</code_context>
<issue_to_address>
**issue (bug_risk):** Handling the markdown placeholder via global string replacement can misbehave with multiple or edited uploads.
Because this relies on a fixed placeholder and a global `replace`, concurrent uploads or user edits before completion can cause the wrong occurrence to be replaced or extra text to be removed. Consider storing the insertion range for each upload (e.g., `selectionStart`/`selectionEnd`) and updating that slice, or generating a unique placeholder per upload and replacing only that token.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| async function uploadImageData(dataUrl) { | ||
| var textarea = document.getElementById('thread-compose'); | ||
| var placeholder = '![上传中…]()'; | ||
| textarea.value += '\n' + placeholder; | ||
| setUploadIndicator('图片上传中…'); | ||
| try { | ||
| var result = await apiCall('UploadImage', { Image: dataUrl }); | ||
| var imageId = result && result.Data && result.Data.ImageID; | ||
| if (!imageId) throw new Error('未获取到 ImageID'); | ||
| var mdImg = ''; |
There was a problem hiding this comment.
issue (bug_risk): Handling the markdown placeholder via global string replacement can misbehave with multiple or edited uploads.
Because this relies on a fixed placeholder and a global replace, concurrent uploads or user edits before completion can cause the wrong occurrence to be replaced or extra text to be removed. Consider storing the insertion range for each upload (e.g., selectionStart/selectionEnd) and updating that slice, or generating a unique placeholder per upload and replacing only that token.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21b5e4e643
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!res.ok) throw new Error('HTTP ' + res.status); | ||
| return res.json(); |
There was a problem hiding this comment.
Validate API-level failure responses before returning
The new apiCall only checks HTTP status and returns JSON directly, but this backend also reports errors as Success: false in a 200 response (the existing userscript checks ResponseData.Success for all mail actions). In this UI, that means expired PHPSESSID, invalid recipients, or other API rejections are treated as successful operations (for example SendMail clears the compose box and GetMailList can look like an empty inbox), which can silently drop user actions. Please convert Success: false into a thrown error using the API message so callers can handle failures.
Useful? React with 👍 / 👎.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…bility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Deploying xmoj-script-dev-channel with
|
| Latest commit: |
7dd281c
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e4eab461.xmoj-script-dev-channel.pages.dev |
| Branch Preview URL: | https://feature-messages-webui.xmoj-script-dev-channel.pages.dev |
| </div> | ||
| </div> | ||
|
|
||
| <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script> |
Check warning
Code scanning / CodeQL
Inclusion of functionality from an untrusted source Medium
| </div> | ||
|
|
||
| <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script> | ||
| <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script> |
Check warning
Code scanning / CodeQL
Inclusion of functionality from an untrusted source Medium
|
|
||
| <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script> | ||
| <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script> | ||
| <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script> |
Check warning
Code scanning / CodeQL
Inclusion of functionality from an untrusted source Medium
|
等等,我要看一下 |
… instructions Bookmarklets are not usable on mobile and confusing on desktop. Make the session (PHPSESSID) tab the default with an accordion of step-by-step instructions for Chrome, Firefox, Safari (macOS), and iOS/iPadOS. Bookmarklet tab kept as secondary option for desktop users. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
3 issues found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="messages.html">
<violation number="1" location="messages.html:384">
P1: Throw when the backend returns `Success: false`; callers currently treat API failures as successful responses.</violation>
<violation number="2" location="messages.html:444">
P2: Capture the selected thread before awaiting here; a stale response can overwrite the conversation the user is currently viewing.</violation>
<violation number="3" location="messages.html:534">
P2: Use a unique placeholder per upload; identical markers let concurrent uploads replace each other out of order.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| }) | ||
| }); | ||
| if (!res.ok) throw new Error('HTTP ' + res.status); | ||
| return res.json(); |
There was a problem hiding this comment.
P1: Throw when the backend returns Success: false; callers currently treat API failures as successful responses.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At messages.html, line 384:
<comment>Throw when the backend returns `Success: false`; callers currently treat API failures as successful responses.</comment>
<file context>
@@ -0,0 +1,710 @@
+ })
+ });
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ return res.json();
+}
+
</file context>
|
|
||
| async function uploadImageData(dataUrl) { | ||
| var textarea = document.getElementById('thread-compose'); | ||
| var placeholder = '![上传中…]()'; |
There was a problem hiding this comment.
P2: Use a unique placeholder per upload; identical markers let concurrent uploads replace each other out of order.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At messages.html, line 534:
<comment>Use a unique placeholder per upload; identical markers let concurrent uploads replace each other out of order.</comment>
<file context>
@@ -0,0 +1,710 @@
+
+async function uploadImageData(dataUrl) {
+ var textarea = document.getElementById('thread-compose');
+ var placeholder = '![上传中…]()';
+ textarea.value += '\n' + placeholder;
+ setUploadIndicator('图片上传中…');
</file context>
| var placeholder = '![上传中…]()'; | |
| var placeholder = '![上传中… ' + Date.now().toString(36) + Math.random().toString(36).slice(2) + ']()'; |
| var atBottom = isFirstLoad || | ||
| (scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < SCROLL_THRESHOLD); | ||
| try { | ||
| var result = await apiCall('GetMail', { OtherUser: currentThread }); |
There was a problem hiding this comment.
P2: Capture the selected thread before awaiting here; a stale response can overwrite the conversation the user is currently viewing.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At messages.html, line 444:
<comment>Capture the selected thread before awaiting here; a stale response can overwrite the conversation the user is currently viewing.</comment>
<file context>
@@ -0,0 +1,710 @@
+ var atBottom = isFirstLoad ||
+ (scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < SCROLL_THRESHOLD);
+ try {
+ var result = await apiCall('GetMail', { OtherUser: currentThread });
+ var mails = result && result.Data && result.Data.Mail;
+ var tbody = document.getElementById('thread-tbody');
</file context>
API returns "参数DebugMode未找到" without it, causing all calls to fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Status badge (未读/已读) now reflects IsRead directly. Row highlight (table-primary) still only applies to incoming unread messages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@copilot 如果检测到用户是桌面端就优先显示书签登陆方式,移动端优先显示会话登录;对话内容和现在的顺序反一下,确保最新的内容在最下面 |
|
@PythonSmall-Q I've opened a new pull request, #945, to work on those changes. Once the pull request is ready, I'll request review from you. |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@copilot 如果检测到用户是桌面端就优先显示书签登陆方式,移动端优先显示会话登录;发送者的名字不要是链接,而是点击名字之后从地址获取信息作为一个popup然后展示出来;图片支持放大(参照script已有功能);检测ctrl+v内容,如果为图片则上传(逻辑和现在页面上有的相同);优化用户体验 |
|
@PythonSmall-Q I've opened a new pull request, #946, to work on those changes. Once the pull request is ready, I'll request review from you. |
| <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.2.3/css/bootstrap.min.css" rel="stylesheet"> | ||
| <script src="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.2.3/js/bootstrap.min.js"></script> | ||
| <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/css/bootstrap.min.css" rel="stylesheet"> | ||
| <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/js/bootstrap.min.js"></script> |
Check warning
Code scanning / CodeQL
Inclusion of functionality from an untrusted source Medium
- Desktop/mobile login tab priority: bookmarklet on desktop (pointer: fine), session on mobile - Replace sender name links with Bootstrap popovers showing user info popup - Image zoom: cursor: zoom-in/zoom-out, title tooltip on images - Global Ctrl+V paste handler for images anywhere in thread view - Auto-resize compose textarea as user types Co-authored-by: PythonSmall-Q <106425289+PythonSmall-Q@users.noreply.github.com>
Co-authored-by: PythonSmall-Q <106425289+PythonSmall-Q@users.noreply.github.com>
Co-authored-by: PythonSmall-Q <106425289+PythonSmall-Q@users.noreply.github.com>
…nfo modal Co-authored-by: PythonSmall-Q <106425289+PythonSmall-Q@users.noreply.github.com>
…in userinfo fetch Co-authored-by: PythonSmall-Q <106425289+PythonSmall-Q@users.noreply.github.com>
Co-authored-by: PythonSmall-Q <106425289+PythonSmall-Q@users.noreply.github.com>
…ck + auto-scroll fix Co-authored-by: PythonSmall-Q <106425289+PythonSmall-Q@users.noreply.github.com>
feat(messages.html): user badges, contact search, auto-scroll to latest
Add caption "点击任意行打开对话 →" above table and a › chevron in a trailing column on each row. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
2 issues found across 1 file (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="messages.html">
<violation number="1" location="messages.html:408">
P3: Include the year for older timestamps; otherwise prior-year messages render ambiguously.</violation>
<violation number="2" location="messages.html:578">
P2: Re-scroll after inline images load; one immediate `scrollTop` update does not keep image threads pinned to the latest message.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Restored: contact search, renderMailList, mailListCache, user badges, user info modal, userSpan, auto-resize textarea, global paste handler, requestAnimationFrame scroll, setLoginTab, dark-mode table-primary fix. Added on top: caption "点击任意行打开对话 →" and › chevron column to make list rows obviously clickable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="messages.html">
<violation number="1" location="messages.html:386">
P2: Clear `mailListCache` when sessions change; otherwise the search box can expose the previous account's cached contacts.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| let currentThread = null; // other user's username | ||
| let refreshTimer = null; | ||
| let isFirstLoad = true; | ||
| let mailListCache = []; // last fetched mail list (for local search) |
There was a problem hiding this comment.
P2: Clear mailListCache when sessions change; otherwise the search box can expose the previous account's cached contacts.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At messages.html, line 386:
<comment>Clear `mailListCache` when sessions change; otherwise the search box can expose the previous account's cached contacts.</comment>
<file context>
@@ -348,12 +383,14 @@ <h5 class="mb-0 flex-grow-1" id="thread-title">与 … 的对话</h5>
let currentThread = null; // other user's username
let refreshTimer = null;
let isFirstLoad = true;
+let mailListCache = []; // last fetched mail list (for local search)
// ── Bootstrap instances ────────────────────────────────────────────────────
</file context>
There was a problem hiding this comment.
2 issues found across 1 file (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="messages.html">
<violation number="1" location="messages.html:515">
P2: Guard the async modal update so a slower earlier click cannot overwrite the currently selected user's info.</violation>
<violation number="2" location="messages.html:516">
P2: This cross-origin profile fetch is blocked by CORS, so the new user-info modal never receives the HTML it needs.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| body.innerHTML = '<div class="text-center py-3"><span class="spinner-border spinner-border-sm me-2"></span>加载中…</div>'; | ||
| userInfoModalBS.show(); | ||
|
|
||
| var results = await Promise.allSettled([ |
There was a problem hiding this comment.
P2: Guard the async modal update so a slower earlier click cannot overwrite the currently selected user's info.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At messages.html, line 515:
<comment>Guard the async modal update so a slower earlier click cannot overwrite the currently selected user's info.</comment>
<file context>
@@ -399,18 +436,153 @@ <h5 class="mb-0 flex-grow-1" id="thread-title">与 … 的对话</h5>
+ body.innerHTML = '<div class="text-center py-3"><span class="spinner-border spinner-border-sm me-2"></span>加载中…</div>';
+ userInfoModalBS.show();
+
+ var results = await Promise.allSettled([
+ fetch(profileUrl, { referrer: XMOJ_BASE + '/', credentials: 'include' })
+ .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }),
</file context>
| userInfoModalBS.show(); | ||
|
|
||
| var results = await Promise.allSettled([ | ||
| fetch(profileUrl, { referrer: XMOJ_BASE + '/', credentials: 'include' }) |
There was a problem hiding this comment.
P2: This cross-origin profile fetch is blocked by CORS, so the new user-info modal never receives the HTML it needs.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At messages.html, line 516:
<comment>This cross-origin profile fetch is blocked by CORS, so the new user-info modal never receives the HTML it needs.</comment>
<file context>
@@ -399,18 +436,153 @@ <h5 class="mb-0 flex-grow-1" id="thread-title">与 … 的对话</h5>
+ userInfoModalBS.show();
+
+ var results = await Promise.allSettled([
+ fetch(profileUrl, { referrer: XMOJ_BASE + '/', credentials: 'include' })
+ .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }),
+ getUserBadge(username)
</file context>
Signed-off-by: Shan Wenxiao <seanoj_noreply@yeah.net>
Signed-off-by: Shan Wenxiao <seanoj_noreply@yeah.net>
关联 Issue
Closes #752
改动内容
messages.html:独立的短消息 WebUI,无需安装用户脚本即可收发站内短消息index.html:导航栏新增"短消息 WebUI (Alpha)"链接,功能介绍中补充 WebUI 说明动机与背景
iOS/iPadOS 用户无法安装油猴脚本,无法使用 XMOJ 短消息功能。此页面托管于同一 Cloudflare Pages 站点(xmoj-bbs.me),让所有浏览器均可直接使用。
此前已有两次尝试(Copilot PR #941、Codex PR #942/#943),但设计或正确性不满意,本 PR 从
dev重新实现。messages.html 功能
技术栈
data-bs-theme自动深/浅色模式登录
#profile读取用户名,通过 URL hash 传回)消息列表页
table-hover table-borderless表格,列:用户 / 最新消息预览 / 时间table-primary),未读数显示红色 badge对话页
table-primary)其他
测试计划
python3 -m http.server 8080启动本地服务,访问http://localhost:8080/messages.htmlSummary by Sourcery
Add a standalone web-based UI for XMOJ short messages and surface it from the main page for environments that cannot use user scripts.
New Features:
Documentation:
Summary by cubic
Adds a standalone short‑messages WebUI at
messages.htmlso browsers without userscripts (e.g., iOS/iPadOS Safari) can read and send XMOJ messages. Updates the main page link and wording to surface it.New Features
messages.html, no build) usingBootstrap 5.3.3,marked@9.1.6, andDOMPurify@3.0.6fromcdnjswith auto light/dark theme.PHPSESSID) with per‑browser steps by default, plus a bookmarklet option for desktop.index.htmlto add the messages link and adjust its label.Bug Fixes
DebugModein API requests to avoid backend errors.IsRead; keep row highlight only for incoming unread. Sort thread messages oldest‑first so the newest is at the bottom.colspanto 4. Blur image before closing the viewer modal to avoid the aria‑hidden focus warning.index.htmlBootstrap CDN tocdnjs; update page/link text for clarity.Written for commit 7dd281c. Summary will update on new commits.