Message replies in chat (#10903)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-16 20:26:48 +03:00
committed by GitHub
parent f075187e57
commit b8fdce6478
19 changed files with 925 additions and 180 deletions
+62 -20
View File
@@ -86,6 +86,22 @@ function toSystemMessageView(
return ev.message
}
/**
* Derive a deleted-message tombstone from a (now-deleted) message, preserving
* the fields the deleted view carries so a reply can render it as deleted.
*/
function toDeletedMessageView(
m: ChatBskyConvoDefs.MessageView,
): $Typed<ChatBskyConvoDefs.DeletedMessageView> {
return {
$type: 'chat.bsky.convo.defs#deletedMessageView',
id: m.id,
rev: m.rev,
sender: m.sender,
sentAt: m.sentAt,
}
}
export class Convo {
private id: string
@@ -119,6 +135,7 @@ export class Convo {
optimisticEmbedView?:
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
optimisticReplyTo?: $Typed<ChatBskyConvoDefs.MessageView>
}
> = new Map()
private deletedMessages: Set<string> = new Set()
@@ -802,6 +819,12 @@ export class Convo {
})
const {cursor, messages, relatedProfiles} = response.data
// Trust the cursor for pagination. We can't infer "no more pages" from a
// short page: the server pages by raw rows but strips deleted messages
// from the response, so a full page containing a deleted message (e.g.
// from a deleted account) comes back short *with* a valid cursor. Using a
// count heuristic here would stop history fetching early and hide
// messages. The tradeoff is one extra empty fetch at the true top.
this.oldestRev = cursor ?? null
if (relatedProfiles) {
@@ -811,14 +834,6 @@ export class Convo {
this.applyProfileShadows()
}
/*
* If the response contained fewer messages than the limit, we know
* there are no more pages, regardless of whether a cursor was returned.
*/
if (messages.length < (IS_NATIVE ? 30 : 60)) {
this.oldestRev = null
}
for (const message of messages) {
if (
ChatBskyConvoDefs.isMessageView(message) ||
@@ -965,17 +980,17 @@ export class Convo {
ChatBskyConvoDefs.isDeletedMessageView(ev.message)
) {
/*
* Update if we have this in state. If we don't, don't worry about it.
* Remove the message itself, and keep its id in `deletedMessages`
* so any message that quotes it keeps rendering a deleted-message
* tombstone (see `tombstoneDeletedReplyTo`) rather than reverting
* to the original hydrated text. We add here rather than relying on
* the optimistic entry so deletes from elsewhere (e.g. another
* device) are covered too.
*/
if (
this.pastMessages.has(ev.message.id) ||
this.newMessages.has(ev.message.id)
) {
this.pastMessages.delete(ev.message.id)
this.newMessages.delete(ev.message.id)
this.deletedMessages.delete(ev.message.id)
needsCommit = true
}
this.pastMessages.delete(ev.message.id)
this.newMessages.delete(ev.message.id)
this.deletedMessages.add(ev.message.id)
needsCommit = true
} else if (
(ChatBskyConvoDefs.isLogAddReaction(ev) ||
ChatBskyConvoDefs.isLogRemoveReaction(ev)) &&
@@ -1020,6 +1035,7 @@ export class Convo {
optimisticEmbedView?:
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>,
optimisticReplyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) {
// Ignore empty messages for now since they have no other purpose atm
if (!message.text.trim() && !message.embed) return
@@ -1033,6 +1049,7 @@ export class Convo {
id: tempId,
message,
optimisticEmbedView,
optimisticReplyTo,
})
if (this.convo?.view.status === 'request') {
this.updateConvo({
@@ -1316,6 +1333,26 @@ export class Convo {
}
}
/**
* When a message is deleted locally, it's removed from the list, but other
* messages that reply to it still carry a hydrated `replyTo` with the
* original text until the server re-sends them. Swap that `replyTo` for a
* deleted-message tombstone so the quote reflects the deletion immediately,
* matching what the server returns on refresh.
*/
private tombstoneDeletedReplyTo(
m: ChatBskyConvoDefs.MessageView,
): ChatBskyConvoDefs.MessageView {
const {replyTo} = m
if (
!ChatBskyConvoDefs.isMessageView(replyTo) ||
!this.deletedMessages.has(replyTo.id)
) {
return m
}
return {...m, replyTo: toDeletedMessageView(replyTo)}
}
/*
* Items in reverse order, since FlatList inverts
*/
@@ -1327,7 +1364,7 @@ export class Convo {
items.unshift({
type: 'message',
key: m.id,
message: m,
message: this.tombstoneDeletedReplyTo(m),
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.unshift({
@@ -1360,7 +1397,7 @@ export class Convo {
items.push({
type: 'message',
key: m.id,
message: m,
message: this.tombstoneDeletedReplyTo(m),
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.push({
@@ -1378,12 +1415,17 @@ export class Convo {
})
this.pendingMessages.forEach(m => {
const optimisticReplyTo =
m.optimisticReplyTo && this.deletedMessages.has(m.optimisticReplyTo.id)
? toDeletedMessageView(m.optimisticReplyTo)
: m.optimisticReplyTo
items.push({
type: 'pending-message',
key: m.id,
message: {
...m.message,
embed: m.optimisticEmbedView,
replyTo: optimisticReplyTo,
$type: 'chat.bsky.convo.defs#messageView',
id: nanoid(),
rev: '__fake__',
+1
View File
@@ -113,6 +113,7 @@ type SendMessage = (
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
| undefined,
optimisticReplyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => void
type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void