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-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>
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user