fix: support hash-only permalink format with server-side resolution
Add /api/messages/hash/:hash endpoint that resolves a message hash to its room ID (with membership check). The client now handles both #roomId/hash and #hash formats - the latter calls the API to find which room the message belongs to, then loads it and scrolls. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e630cca6c6
commit
55c17b2999
@ -480,7 +480,7 @@
|
|||||||
/** Stash a message permalink hash so it survives the login flow */
|
/** Stash a message permalink hash so it survives the login flow */
|
||||||
stashPendingLink() {
|
stashPendingLink() {
|
||||||
const fragment = window.location.hash?.slice(1)
|
const fragment = window.location.hash?.slice(1)
|
||||||
if (fragment && fragment.includes('/')) {
|
if (fragment) {
|
||||||
sessionStorage.setItem('pendingMessageLink', fragment)
|
sessionStorage.setItem('pendingMessageLink', fragment)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -533,21 +533,41 @@
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Parse #roomId/messageHash from URL or sessionStorage, load the room, and scroll to the message */
|
/** Parse #roomId/messageHash or #messageHash from URL or sessionStorage, load the room, and scroll */
|
||||||
async navigateToMessageLink() {
|
async navigateToMessageLink() {
|
||||||
// Try URL hash first, then sessionStorage (for post-login flow)
|
// Try URL hash first, then sessionStorage (for post-login flow)
|
||||||
let fragment = window.location.hash?.slice(1)
|
let fragment = window.location.hash?.slice(1)
|
||||||
if (!fragment || !fragment.includes('/')) {
|
if (!fragment) {
|
||||||
fragment = sessionStorage.getItem('pendingMessageLink')
|
fragment = sessionStorage.getItem('pendingMessageLink')
|
||||||
sessionStorage.removeItem('pendingMessageLink')
|
sessionStorage.removeItem('pendingMessageLink')
|
||||||
}
|
}
|
||||||
if (!fragment) return
|
if (!fragment) return
|
||||||
|
|
||||||
const slashIdx = fragment.indexOf('/')
|
let roomId, msgHash
|
||||||
if (slashIdx === -1) return
|
|
||||||
|
if (fragment.includes('/')) {
|
||||||
|
// New format: #roomId/messageHash
|
||||||
|
const slashIdx = fragment.indexOf('/')
|
||||||
|
roomId = fragment.slice(0, slashIdx)
|
||||||
|
msgHash = fragment.slice(slashIdx + 1)
|
||||||
|
} else {
|
||||||
|
// Old format: #messageHash — resolve room via API
|
||||||
|
msgHash = fragment
|
||||||
|
try {
|
||||||
|
const result = await api.resolveMessageHash(msgHash)
|
||||||
|
roomId = result.room_id
|
||||||
|
} catch (e) {
|
||||||
|
const status = e?.status
|
||||||
|
if (status === 404) {
|
||||||
|
this.update({ linkError: 'Message not found or you don\'t have access' })
|
||||||
|
} else {
|
||||||
|
this.update({ linkError: 'Could not find this message' })
|
||||||
|
}
|
||||||
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const roomId = fragment.slice(0, slashIdx)
|
|
||||||
const msgHash = fragment.slice(slashIdx + 1)
|
|
||||||
if (!roomId || !msgHash) return
|
if (!roomId || !msgHash) return
|
||||||
|
|
||||||
// Store the target hash so selectRoom skips scrollToBottom
|
// Store the target hash so selectRoom skips scrollToBottom
|
||||||
@ -559,7 +579,7 @@
|
|||||||
await this.selectRoom(roomId)
|
await this.selectRoom(roomId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._pendingScrollHash = null
|
this._pendingScrollHash = null
|
||||||
const status = e?.status || e?.response?.status
|
const status = e?.status
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
this.update({ linkError: 'You don\'t have access to this room' })
|
this.update({ linkError: 'You don\'t have access to this room' })
|
||||||
} else if (status === 404) {
|
} else if (status === 404) {
|
||||||
@ -567,7 +587,6 @@
|
|||||||
} else {
|
} else {
|
||||||
this.update({ linkError: 'Could not load this room' })
|
this.update({ linkError: 'Could not load this room' })
|
||||||
}
|
}
|
||||||
// Clean the hash from URL
|
|
||||||
window.history.replaceState(null, '', window.location.pathname)
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,6 +86,7 @@ export const api = {
|
|||||||
joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`),
|
joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`),
|
||||||
deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`),
|
deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`),
|
||||||
clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`),
|
clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`),
|
||||||
|
resolveMessageHash: (hash) => request('GET', `/messages/hash/${hash}`),
|
||||||
|
|
||||||
// Models
|
// Models
|
||||||
listModels: () => request('GET', '/models'),
|
listModels: () => request('GET', '/models'),
|
||||||
|
|||||||
@ -236,6 +236,30 @@ pub async fn get_messages(
|
|||||||
Ok(Json(payloads))
|
Ok(Json(payloads))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_message_hash(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(hash): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
// Find the message by hash
|
||||||
|
let row = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT m.room_id FROM messages m \
|
||||||
|
JOIN room_members rm ON rm.room_id = m.room_id AND rm.user_id = ? \
|
||||||
|
JOIN rooms r ON r.id = m.room_id AND r.deleted_at IS NULL \
|
||||||
|
WHERE m.hash = ? LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&auth.user_id)
|
||||||
|
.bind(&hash)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some((room_id,)) => Ok(Json(serde_json::json!({ "room_id": room_id, "hash": hash }))),
|
||||||
|
None => Err((StatusCode::NOT_FOUND, "Message not found or no access".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn join_room(
|
pub async fn join_room(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
|
|||||||
@ -287,6 +287,7 @@ async fn main() {
|
|||||||
.route("/api/rooms/:room_id/messages", get(handlers::rooms::get_messages))
|
.route("/api/rooms/:room_id/messages", get(handlers::rooms::get_messages))
|
||||||
.route("/api/rooms/:room_id/join", post(handlers::rooms::join_room))
|
.route("/api/rooms/:room_id/join", post(handlers::rooms::join_room))
|
||||||
.route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_room))
|
.route("/api/rooms/:room_id/clear", post(handlers::rooms::clear_room))
|
||||||
|
.route("/api/messages/hash/:hash", get(handlers::rooms::resolve_message_hash))
|
||||||
// Upload (chat images)
|
// Upload (chat images)
|
||||||
.route("/api/upload", post(handlers::upload::upload_chat_image))
|
.route("/api/upload", post(handlers::upload::upload_chat_image))
|
||||||
// Models
|
// Models
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user