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:
parent
5a0d26745a
commit
e630cca6c6
@ -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>
|
||||
|
||||
@ -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') {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user