From 93c5330917663c4b91542dcc0bfaaa4983d3b97a Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 29 Sep 2026 13:52:26 -0700 Subject: [PATCH] Fix duplicate teleport announcements --- js/components/src/lib/system-messages.ts | 15 +++++++++++-- js/core/src/lib/system-messages.ts | 17 ++++++++++---- .../websocket-consumer.test.ts | 22 +++++++++++++++++++ .../livestream-store/websocket-consumer.ts | 1 + 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/js/components/src/lib/system-messages.ts b/js/components/src/lib/system-messages.ts index 5744d8df3..b6cae529f 100644 --- a/js/components/src/lib/system-messages.ts +++ b/js/components/src/lib/system-messages.ts @@ -81,7 +81,8 @@ export const SystemMessages = { streamerName: string, streamerDid: string, count: number, - chatProfile?: any, + chatProfile?: ChatMessageViewHydrated["chatProfile"], + teleportUri?: string, ): ChatMessageViewHydrated => { const text = count > 0 @@ -92,6 +93,16 @@ export const SystemMessages = { count, }); + // The live event and the target stream's initial burst can both carry the + // same arrival. Use the teleport record as the message identity so chat + // reducer deduplication keeps only one announcement. + if (teleportUri) { + const id = encodeURIComponent(teleportUri); + message.uri = + `at://did:sys:system/place.stream.chat.message/teleport-${id}` as ChatMessageViewHydrated["uri"]; + message.cid = `system-teleport-${id}` as ChatMessageViewHydrated["cid"]; + } + // create a mention facet for the streamer name so it gets colored using existing mention rendering if (chatProfile && streamerDid) { const nameStart = text.indexOf(streamerName); @@ -110,7 +121,7 @@ export const SystemMessages = { features: [ { $type: "app.bsky.richtext.facet#mention", - did: streamerDid as any, + did: streamerDid as ChatMessageViewHydrated["author"]["did"], }, ], }, diff --git a/js/core/src/lib/system-messages.ts b/js/core/src/lib/system-messages.ts index 15502b0cc..2045d7d06 100644 --- a/js/core/src/lib/system-messages.ts +++ b/js/core/src/lib/system-messages.ts @@ -81,7 +81,8 @@ export const SystemMessages = { streamerName: string, streamerDid: string, count: number, - chatProfile?: any, + chatProfile?: ChatMessageViewHydrated["chatProfile"], + teleportUri?: string, ): ChatMessageViewHydrated => { const text = count > 0 @@ -92,15 +93,23 @@ export const SystemMessages = { count, }); + // The live event and the target stream's initial burst can both carry the + // same arrival. Use the teleport record as the message identity so chat + // reducer deduplication keeps only one announcement. + if (teleportUri) { + const id = encodeURIComponent(teleportUri); + message.uri = + `at://did:sys:system/place.stream.chat.message/teleport-${id}` as ChatMessageViewHydrated["uri"]; + message.cid = `system-teleport-${id}` as ChatMessageViewHydrated["cid"]; + } + // create a mention facet for the streamer name so it gets colored using existing mention rendering if (chatProfile && streamerDid) { const nameStart = text.indexOf(streamerName); - // encode byte positions const encoder = new TextEncoder(); const byteStart = encoder.encode(text.substring(0, nameStart)).length; const byteEnd = byteStart + encoder.encode(streamerName).length; - message.record.facets = [ { index: { @@ -110,7 +119,7 @@ export const SystemMessages = { features: [ { $type: "app.bsky.richtext.facet#mention", - did: streamerDid as any, + did: streamerDid as ChatMessageViewHydrated["author"]["did"], }, ], }, diff --git a/js/core/src/livestream-store/websocket-consumer.test.ts b/js/core/src/livestream-store/websocket-consumer.test.ts index 719d47081..e4028cbab 100644 --- a/js/core/src/livestream-store/websocket-consumer.test.ts +++ b/js/core/src/livestream-store/websocket-consumer.test.ts @@ -281,4 +281,26 @@ describe("handleWebSocketMessages: teleport records", () => { expect(result.activeTeleport).toEqual(teleport); }); + + it("deduplicates live and initial-burst teleport arrivals", () => { + const arrival = { + $type: "place.stream.livestream#teleportArrival", + teleportUri: + "at://did:plc:source/place.stream.live.teleport/3lteleport0001", + source: { + did: "did:plc:source", + handle: "source.example.com", + }, + viewerCount: 15, + startsAt: "2026-09-25T12:00:00.000Z", + }; + + const result = handleWebSocketMessages(makeState(), [ + arrival, + { ...arrival, viewerCount: 12 }, + ]); + + expect(result.chat).toHaveLength(1); + expect(result.chat[0].record.text).toContain("15 viewers teleported"); + }); }); diff --git a/js/core/src/livestream-store/websocket-consumer.ts b/js/core/src/livestream-store/websocket-consumer.ts index fc3a6adf5..a88dc93d0 100644 --- a/js/core/src/livestream-store/websocket-consumer.ts +++ b/js/core/src/livestream-store/websocket-consumer.ts @@ -278,6 +278,7 @@ export const handleWebSocketMessages = ( arrival.source.did, arrival.viewerCount, arrival.chatProfile, + arrival.teleportUri, ); // set proper times systemMessage.indexedAt = arrival.startsAt; -- knotmirror 0.0.1