From e630cca6c651529455f0a23b2145ef6309afd24a Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 16 Mar 2026 17:40:18 -0600 Subject: [PATCH] fix: message permalinks work on fresh page load and handle permissions - Stash permalink hash in sessionStorage before login so it survives the auth flow and navigates after login completes - Wait for DOM render (double rAF) before scrolling to target message - Skip scrollToBottom when navigating via permalink - Show error screen for 403 (no access) and 404 (room not found) - Attach HTTP status code to API errors for proper error differentiation Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/components/app.riot | 141 +++++++++++++++++++++++---------- client/src/services/api.js | 4 +- 2 files changed, 102 insertions(+), 43 deletions(-) diff --git a/client/src/components/app.riot b/client/src/components/app.riot index 91c1980..8e011e9 100644 --- a/client/src/components/app.riot +++ b/client/src/components/app.riot @@ -33,7 +33,18 @@ cb-delete-room={() => update({ showDeleteModal: true })} cb-clear-room={() => update({ showClearModal: true })} /> -
+
+ +
+
@@ -147,6 +158,19 @@ font-size: var(--text-sm); } + .link-error svg { + color: var(--error, #e53e3e); + opacity: 0.8; + } + + .link-error h2 { + color: var(--error, #e53e3e); + } + + .link-error .btn { + margin-top: var(--space-md); + } + :global(.hash-highlight) { animation: hash-flash 3s ease; } @@ -184,6 +208,7 @@ aiToolStatus: null, streamingMessage: null, typingUsers: [], + linkError: null, }, onMounted() { @@ -201,7 +226,8 @@ // Verify the token is still valid with the server before trusting it this.verifyAndInit(user) } else { - // Not logged in — store invite token so we can accept after login + // Not logged in — stash permalink and invite for after login + this.stashPendingLink() this.checkPendingInvite() } }, @@ -388,31 +414,31 @@ }, async selectRoom(roomId) { - try { - // Cancel any active stream buffer when switching rooms - if (this.streamBuffer) { - this.streamBuffer.cancel() - this.streamBuffer = null - this._streamMsgId = null - this._streamContent = '' - } - const [room, messages] = await Promise.all([ - api.getRoom(roomId), - api.getMessages(roomId), - ]) - this.update({ - activeRoomId: roomId, - activeRoom: room, - messages, - aiTyping: false, - aiToolStatus: null, - streamingMessage: null, - typingUsers: [], - }) - ws.joinRoom(roomId) + // Cancel any active stream buffer when switching rooms + if (this.streamBuffer) { + this.streamBuffer.cancel() + this.streamBuffer = null + this._streamMsgId = null + this._streamContent = '' + } + const [room, messages] = await Promise.all([ + api.getRoom(roomId), + api.getMessages(roomId), + ]) + this.update({ + activeRoomId: roomId, + activeRoom: room, + messages, + aiTyping: false, + aiToolStatus: null, + streamingMessage: null, + typingUsers: [], + linkError: null, + }) + ws.joinRoom(roomId) + // Only scroll to bottom if not navigating to a specific message + if (!this._pendingScrollHash) { this.scrollToBottom() - } catch (e) { - console.error('Failed to load room:', e) } }, @@ -451,6 +477,14 @@ this.update({ user }) }, + /** Stash a message permalink hash so it survives the login flow */ + stashPendingLink() { + const fragment = window.location.hash?.slice(1) + if (fragment && fragment.includes('/')) { + sessionStorage.setItem('pendingMessageLink', fragment) + } + }, + /** Check URL for /invite/:token and stash it for after login if needed */ checkPendingInvite() { const match = window.location.pathname.match(/^\/invite\/(.+)$/) @@ -499,39 +533,62 @@ }) }, - /** Parse #roomId/messageHash from URL, load the room, and scroll to the message */ + /** Parse #roomId/messageHash from URL or sessionStorage, load the room, and scroll to the message */ async navigateToMessageLink() { - const fragment = window.location.hash?.slice(1) + // Try URL hash first, then sessionStorage (for post-login flow) + let fragment = window.location.hash?.slice(1) + if (!fragment || !fragment.includes('/')) { + fragment = sessionStorage.getItem('pendingMessageLink') + sessionStorage.removeItem('pendingMessageLink') + } if (!fragment) return + const slashIdx = fragment.indexOf('/') - if (slashIdx === -1) return // not a room/hash link + if (slashIdx === -1) return const roomId = fragment.slice(0, slashIdx) const msgHash = fragment.slice(slashIdx + 1) if (!roomId || !msgHash) return - // Store the target hash before selecting the room + // Store the target hash so selectRoom skips scrollToBottom this._pendingScrollHash = msgHash - // Select the room if not already active + // Load the room if not already active if (this.state.activeRoomId !== roomId) { - await this.selectRoom(roomId) + try { + await this.selectRoom(roomId) + } catch (e) { + this._pendingScrollHash = null + const status = e?.status || e?.response?.status + if (status === 403) { + this.update({ linkError: 'You don\'t have access to this room' }) + } else if (status === 404) { + this.update({ linkError: 'Room not found' }) + } else { + this.update({ linkError: 'Could not load this room' }) + } + // Clean the hash from URL + window.history.replaceState(null, '', window.location.pathname) + return + } } - // Scroll to the message after render - this.scrollToHash(this._pendingScrollHash) + // Wait for DOM to render the messages, then scroll this._pendingScrollHash = null + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) + this.scrollToHash(msgHash) + + // Clean the hash from URL after navigating + window.history.replaceState(null, '', window.location.pathname) }, scrollToHash(hash) { - requestAnimationFrame(() => { - const el = document.getElementById('msg-' + hash) - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }) - el.classList.add('hash-highlight') - setTimeout(() => el.classList.remove('hash-highlight'), 3000) - } - }) + const el = document.getElementById('msg-' + hash) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + el.classList.add('hash-highlight') + setTimeout(() => el.classList.remove('hash-highlight'), 3000) + } }, } diff --git a/client/src/services/api.js b/client/src/services/api.js index 2ca90a1..ccb0745 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -38,7 +38,9 @@ async function request(method, path, body) { throw new Error('Session expired — please log in again') } const text = await res.text() - throw new Error(text || `HTTP ${res.status}`) + const err = new Error(text || `HTTP ${res.status}`) + err.status = res.status + throw err } if (res.status === 204 || res.headers.get('content-length') === '0') {