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 */
|
||||
stashPendingLink() {
|
||||
const fragment = window.location.hash?.slice(1)
|
||||
if (fragment && fragment.includes('/')) {
|
||||
if (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() {
|
||||
// Try URL hash first, then sessionStorage (for post-login flow)
|
||||
let fragment = window.location.hash?.slice(1)
|
||||
if (!fragment || !fragment.includes('/')) {
|
||||
if (!fragment) {
|
||||
fragment = sessionStorage.getItem('pendingMessageLink')
|
||||
sessionStorage.removeItem('pendingMessageLink')
|
||||
}
|
||||
if (!fragment) return
|
||||
|
||||
const slashIdx = fragment.indexOf('/')
|
||||
if (slashIdx === -1) return
|
||||
let roomId, msgHash
|
||||
|
||||
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
|
||||
|
||||
// Store the target hash so selectRoom skips scrollToBottom
|
||||
@ -559,7 +579,7 @@
|
||||
await this.selectRoom(roomId)
|
||||
} catch (e) {
|
||||
this._pendingScrollHash = null
|
||||
const status = e?.status || e?.response?.status
|
||||
const status = e?.status
|
||||
if (status === 403) {
|
||||
this.update({ linkError: 'You don\'t have access to this room' })
|
||||
} else if (status === 404) {
|
||||
@ -567,7 +587,6 @@
|
||||
} else {
|
||||
this.update({ linkError: 'Could not load this room' })
|
||||
}
|
||||
// Clean the hash from URL
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
return
|
||||
}
|
||||
|
||||
@ -86,6 +86,7 @@ export const api = {
|
||||
joinRoom: (roomId) => request('POST', `/rooms/${roomId}/join`),
|
||||
deleteRoom: (roomId) => request('DELETE', `/rooms/${roomId}`),
|
||||
clearRoom: (roomId) => request('POST', `/rooms/${roomId}/clear`),
|
||||
resolveMessageHash: (hash) => request('GET', `/messages/hash/${hash}`),
|
||||
|
||||
// Models
|
||||
listModels: () => request('GET', '/models'),
|
||||
|
||||
@ -236,6 +236,30 @@ pub async fn get_messages(
|
||||
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(
|
||||
State(state): State<Arc<AppState>>,
|
||||
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/join", post(handlers::rooms::join_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)
|
||||
.route("/api/upload", post(handlers::upload::upload_chat_image))
|
||||
// Models
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user