diff --git a/js/app/src/screens/chat-popout.tsx b/js/app/src/screens/chat-popout.tsx index 382da0b5..49622165 100644 --- a/js/app/src/screens/chat-popout.tsx +++ b/js/app/src/screens/chat-popout.tsx @@ -22,6 +22,7 @@ interface ChatPopoutParams { reverse?: string; hideAfter?: string; hideChatBox?: string; + hidePinnedComments?: string; showNotifications?: string; } @@ -65,7 +66,7 @@ export function PopoutChatInner({ params }: { params: ChatPopoutParams }) { ? parseInt(params.hideAfter, 10) : undefined; const hideChatBox = params.hideChatBox === "true"; - const showNotifications = params.showNotifications === "true"; + const hidePinnedComments = params.hidePinnedComments === "true"; useEffect(() => { setSrc(params.user); @@ -88,7 +89,7 @@ export function PopoutChatInner({ params }: { params: ChatPopoutParams }) { { maxHeight: "100vh" }, ]} > - {showNotifications ? ( + {!hidePinnedComments ? ( {chat} diff --git a/js/components/src/components/dashboard/moderator-panel.tsx b/js/components/src/components/dashboard/moderator-panel.tsx index f13ce7d2..4366cb66 100644 --- a/js/components/src/components/dashboard/moderator-panel.tsx +++ b/js/components/src/components/dashboard/moderator-panel.tsx @@ -596,6 +596,53 @@ function AddModeratorDialog({ + + + setPermissions((p) => ({ + ...p, + "message.pin": !p["message.pin"], + })) + } + style={[ + layout.flex.row, + layout.flex.alignCenter, + p[3], + r.md, + bg.neutral[800], + borders.width.thin, + borders.color.neutral[700], + ]} + > + + {permissions["message.pin"] && ( + ✓ + )} + + + + Pin Messages + + + Pin and unpin chat messages + + + diff --git a/js/docs/src/content/docs/features/chat-popout.md b/js/docs/src/content/docs/features/chat-popout.md index b8b120d0..d071e85a 100644 --- a/js/docs/src/content/docs/features/chat-popout.md +++ b/js/docs/src/content/docs/features/chat-popout.md @@ -63,3 +63,16 @@ Example: ``` https://stream.place/chat-popout/did:plc:abcdef123456...?hideChatBox=true ``` + +### `hidePinnedComments` + +Hides pinned comment notifications in the popout. + +- Type: `boolean` (values: `"true"`, `"false"`) +- Default: `"false"` + +Example: + +``` +https://stream.place/chat-popout/did:plc:abcdef123456...?hidePinnedComments=true +``` diff --git a/pkg/spxrpc/place_stream_moderation.go b/pkg/spxrpc/place_stream_moderation.go index f621f8e3..a70ff81d 100644 --- a/pkg/spxrpc/place_stream_moderation.go +++ b/pkg/spxrpc/place_stream_moderation.go @@ -376,25 +376,6 @@ func (s *Server) handlePlaceStreamModerationCreatePin(ctx context.Context, input return nil, err } - // Check that the streamer has an active livestream - ls, err := s.model.GetLatestLivestreamForRepo(input.Streamer) - if err != nil { - log.Error(ctx, "failed to get livestream for streamer", "err", err) - return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check livestream status") - } - if ls == nil || ls.Livestream == nil { - return nil, echo.NewHTTPError(http.StatusBadRequest, "no active livestream for this streamer") - } - lsRec, err := lexutil.CborDecodeValue(*ls.Livestream) - if err != nil { - log.Error(ctx, "failed to decode livestream record", "err", err) - return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check livestream status") - } - lsTyped, ok := lsRec.(*streamplace.Livestream) - if ok && lsTyped.EndedAt != nil { - return nil, echo.NewHTTPError(http.StatusBadRequest, "livestream has ended, cannot pin comments") - } - // Delete existing pinned records for this streamer (single-pin semantics) var listOutput comatproto.RepoListRecords_Output err = modCtx.StreamerClient.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.listRecords", -- 2.51.2 From 6668993348bdf50efe0eec90225fbbc1a56640a1 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Sun, 31 May 2026 18:34:57 -0500 Subject: [PATCH 2/2] Allow multiple pinned messages in chat Remove the logic that automatically deletes existing pinned records when a new one is created, allowing historical pins to persist. --- js/components/src/livestream-store/chat.tsx | 16 ------------ pkg/spxrpc/place_stream_moderation.go | 29 +-------------------- 2 files changed, 1 insertion(+), 44 deletions(-) diff --git a/js/components/src/livestream-store/chat.tsx b/js/components/src/livestream-store/chat.tsx index da6a63cc..9949c753 100644 --- a/js/components/src/livestream-store/chat.tsx +++ b/js/components/src/livestream-store/chat.tsx @@ -479,22 +479,6 @@ export const usePinChatMessage = () => { // If streamer, create directly if (agent.did === streamerDID) { - // First delete any existing pinned records - const listResult = await agent.com.atproto.repo.listRecords({ - repo: streamerDID, - collection: "place.stream.chat.pinnedRecord", - }); - for (const rec of listResult.data.records) { - const rkey = rec.uri.split("/").pop(); - if (rkey) { - await agent.com.atproto.repo.deleteRecord({ - repo: streamerDID, - collection: "place.stream.chat.pinnedRecord", - rkey, - }); - } - } - const record = { $type: "place.stream.chat.pinnedRecord", pinnedMessage: messageUri, diff --git a/pkg/spxrpc/place_stream_moderation.go b/pkg/spxrpc/place_stream_moderation.go index a70ff81d..2a806e5c 100644 --- a/pkg/spxrpc/place_stream_moderation.go +++ b/pkg/spxrpc/place_stream_moderation.go @@ -376,34 +376,7 @@ func (s *Server) handlePlaceStreamModerationCreatePin(ctx context.Context, input return nil, err } - // Delete existing pinned records for this streamer (single-pin semantics) - var listOutput comatproto.RepoListRecords_Output - err = modCtx.StreamerClient.Do(ctx, xrpc.Query, "application/json", "com.atproto.repo.listRecords", - map[string]any{ - "repo": input.Streamer, - "collection": constants.PLACE_STREAM_CHAT_PINNED_RECORD, - }, nil, &listOutput) - if err != nil { - log.Error(ctx, "failed to list existing pinned records", "err", err) - } else { - for _, rec := range listOutput.Records { - rkey, delErr := extractRKey(rec.Uri) - if delErr != nil { - continue - } - deleteInput := comatproto.RepoDeleteRecord_Input{ - Collection: constants.PLACE_STREAM_CHAT_PINNED_RECORD, - Rkey: rkey, - Repo: input.Streamer, - } - deleteOutput := comatproto.RepoDeleteRecord_Output{} - if err := modCtx.StreamerClient.Do(ctx, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", map[string]any{}, deleteInput, &deleteOutput); err != nil { - log.Error(ctx, "failed to delete existing pinned record", "rkey", rkey, "err", err) - } - } - } - - // Create the pinned record + // Create the pinned record (old pins persist as history) pinnedRecord := &streamplace.ChatPinnedRecord{ LexiconTypeID: "place.stream.chat.pinnedRecord", PinnedMessage: input.MessageUri,