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-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">
<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"/>
@ -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,7 +414,6 @@
},
async selectRoom(roomId) {
try {
// Cancel any active stream buffer when switching rooms
if (this.streamBuffer) {
this.streamBuffer.cancel()
@ -408,11 +433,12 @@
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) {
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)
}
})
},
}
</script>

View File

@ -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') {