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) <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-03-16 17:40:18 -06:00
parent 5a0d26745a
commit e630cca6c6
2 changed files with 102 additions and 43 deletions

View File

@ -33,7 +33,18 @@
cb-delete-room={() => update({ showDeleteModal: true })} cb-delete-room={() => update({ showDeleteModal: true })}
cb-clear-room={() => update({ showClearModal: true })} cb-clear-room={() => update({ showClearModal: true })}
/> />
<div if={!state.activeRoom} class="no-room"> <div if={state.linkError} class="no-room">
<div class="no-room-content link-error">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
<h2>{state.linkError}</h2>
<p>The message link you followed could not be opened.</p>
<button class="btn btn-ghost" onclick={() => update({ linkError: null })}>Dismiss</button>
</div>
</div>
<div if={!state.activeRoom && !state.linkError} class="no-room">
<div class="no-room-content"> <div class="no-room-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
@ -147,6 +158,19 @@
font-size: var(--text-sm); 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) { :global(.hash-highlight) {
animation: hash-flash 3s ease; animation: hash-flash 3s ease;
} }
@ -184,6 +208,7 @@
aiToolStatus: null, aiToolStatus: null,
streamingMessage: null, streamingMessage: null,
typingUsers: [], typingUsers: [],
linkError: null,
}, },
onMounted() { onMounted() {
@ -201,7 +226,8 @@
// Verify the token is still valid with the server before trusting it // Verify the token is still valid with the server before trusting it
this.verifyAndInit(user) this.verifyAndInit(user)
} else { } 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() this.checkPendingInvite()
} }
}, },
@ -388,31 +414,31 @@
}, },
async selectRoom(roomId) { async selectRoom(roomId) {
try { // Cancel any active stream buffer when switching rooms
// Cancel any active stream buffer when switching rooms if (this.streamBuffer) {
if (this.streamBuffer) { this.streamBuffer.cancel()
this.streamBuffer.cancel() this.streamBuffer = null
this.streamBuffer = null this._streamMsgId = null
this._streamMsgId = null this._streamContent = ''
this._streamContent = '' }
} const [room, messages] = await Promise.all([
const [room, messages] = await Promise.all([ api.getRoom(roomId),
api.getRoom(roomId), api.getMessages(roomId),
api.getMessages(roomId), ])
]) this.update({
this.update({ activeRoomId: roomId,
activeRoomId: roomId, activeRoom: room,
activeRoom: room, messages,
messages, aiTyping: false,
aiTyping: false, aiToolStatus: null,
aiToolStatus: null, streamingMessage: null,
streamingMessage: null, typingUsers: [],
typingUsers: [], linkError: null,
}) })
ws.joinRoom(roomId) ws.joinRoom(roomId)
// Only scroll to bottom if not navigating to a specific message
if (!this._pendingScrollHash) {
this.scrollToBottom() this.scrollToBottom()
} catch (e) {
console.error('Failed to load room:', e)
} }
}, },
@ -451,6 +477,14 @@
this.update({ user }) 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 */ /** Check URL for /invite/:token and stash it for after login if needed */
checkPendingInvite() { checkPendingInvite() {
const match = window.location.pathname.match(/^\/invite\/(.+)$/) 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() { 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 if (!fragment) return
const slashIdx = fragment.indexOf('/') const slashIdx = fragment.indexOf('/')
if (slashIdx === -1) return // not a room/hash link if (slashIdx === -1) return
const roomId = fragment.slice(0, slashIdx) const roomId = fragment.slice(0, slashIdx)
const msgHash = fragment.slice(slashIdx + 1) const msgHash = fragment.slice(slashIdx + 1)
if (!roomId || !msgHash) return if (!roomId || !msgHash) return
// Store the target hash before selecting the room // Store the target hash so selectRoom skips scrollToBottom
this._pendingScrollHash = msgHash this._pendingScrollHash = msgHash
// Select the room if not already active // Load the room if not already active
if (this.state.activeRoomId !== roomId) { 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 // Wait for DOM to render the messages, then scroll
this.scrollToHash(this._pendingScrollHash)
this._pendingScrollHash = null 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) { scrollToHash(hash) {
requestAnimationFrame(() => { const el = document.getElementById('msg-' + hash)
const el = document.getElementById('msg-' + hash) if (el) {
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.scrollIntoView({ behavior: 'smooth', block: 'center' }) el.classList.add('hash-highlight')
el.classList.add('hash-highlight') setTimeout(() => el.classList.remove('hash-highlight'), 3000)
setTimeout(() => el.classList.remove('hash-highlight'), 3000) }
}
})
}, },
} }
</script> </script>

View File

@ -38,7 +38,9 @@ async function request(method, path, body) {
throw new Error('Session expired — please log in again') throw new Error('Session expired — please log in again')
} }
const text = await res.text() 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') { if (res.status === 204 || res.headers.get('content-length') === '0') {