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,