diff --git a/js/app/components/name-color-picker/name-color-picker.tsx b/js/app/components/name-color-picker/name-color-picker.tsx
index 40f20b100..966b7cf74 100644
--- a/js/app/components/name-color-picker/name-color-picker.tsx
+++ b/js/app/components/name-color-picker/name-color-picker.tsx
@@ -1,4 +1,9 @@
-import { Button, formatHandleWithAt, zero } from "@streamplace/components";
+import {
+ Button,
+ formatHandleWithAt,
+ Text,
+ zero,
+} from "@streamplace/components";
import { Palette, SwatchBook, X } from "lucide-react-native";
import { useEffect, useState } from "react";
import {
@@ -6,24 +11,52 @@ import {
Modal,
Platform,
Pressable,
- Text,
TouchableOpacity,
View,
} from "react-native";
import ColorPicker, {
HueSlider,
+ InputWidget,
Panel1,
- Preview,
Swatches,
} from "reanimated-color-picker";
import { useStore } from "store";
import { useChatProfile, useUserProfile } from "store/hooks";
import { PlaceStreamChatProfile } from "streamplace";
+/**
+ * Returns black or white depending on which contrasts better against the given color
+ */
+function getContrastColor(color: string): string {
+ let r: number, g: number, b: number;
+
+ if (color.startsWith("#")) {
+ const hex = color.replace("#", "");
+ r = parseInt(hex.substring(0, 2), 16);
+ g = parseInt(hex.substring(2, 4), 16);
+ b = parseInt(hex.substring(4, 6), 16);
+ } else if (color.startsWith("rgb")) {
+ const match = color.match(/(\d+),\s*(\d+),\s*(\d+)/);
+ if (match) {
+ r = parseInt(match[1]);
+ g = parseInt(match[2]);
+ b = parseInt(match[3]);
+ } else {
+ return "#fff";
+ }
+ } else {
+ return "#fff";
+ }
+
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ return luminance > 0.5 ? "#000" : "#fff";
+}
+
/**
* Parses an RGB color string and returns an object with red, green, and blue values
*/
function parseRgbString(rgbString: string): PlaceStreamChatProfile.Color {
+ console.log(rgbString);
if (
!rgbString ||
(!rgbString.startsWith("rgb(") && !rgbString.startsWith("rgba("))
@@ -45,6 +78,25 @@ function parseRgbString(rgbString: string): PlaceStreamChatProfile.Color {
};
}
+function rgbToHex(rgb: PlaceStreamChatProfile.Color) {
+ const hex = (
+ (1 << 24) +
+ (rgb.red << 16) +
+ (rgb.green << 8) +
+ rgb.blue
+ ).toString(16);
+ return `#${hex.slice(-6)}`;
+}
+
+// rgb(r, g, b) to hex
+function cssRgbToHex(rgb: string) {
+ if (rgb.startsWith("#")) {
+ return rgb;
+ }
+ const parsed = parseRgbString(rgb);
+ return rgbToHex(parsed);
+}
+
export function useNameColorPicker() {
const [modalVisible, setModalVisible] = useState(false);
const [tempColor, setTempColor] = useState("#bd6e86");
@@ -159,32 +211,77 @@ export function useNameColorPicker() {
zero.bg.gray[800],
zero.r.md,
zero.p[3],
- zero.mb[5],
+ zero.mb[3],
zero.layout.flex.alignCenter,
]}
>
-
- @{profile.handle}
-
-
- Preview
+
+ {formatHandleWithAt(profile)}
)}
{/* Color Picker */}
-
+
setTempColor(result.rgb)}
+ onChangeJS={(result) => setTempColor(result.rgb)}
>
-
-
+
+ {cssRgbToHex(currentColor) !== cssRgbToHex(tempColor) && (
+
+
+ {cssRgbToHex(currentColor)}
+
+
+ )}
+
+
+
@@ -250,47 +347,7 @@ export default function NameColorPicker({
text?: (color: string) => React.ReactNode;
buttonProps?: any;
}) {
- const [modalVisible, setModalVisible] = useState(false);
- const [tempColor, setTempColor] = useState("#bd6e86");
- const createChatProfileRecord = useStore(
- (state) => state.createChatProfileRecord,
- );
- const getChatProfileRecordFromPDS = useStore(
- (state) => state.getChatProfileRecordFromPDS,
- );
- const chatProfile = useChatProfile();
- const profile = useUserProfile();
- const isWeb = Platform.OS === "web";
-
- const currentColor = chatProfile?.profile?.color
- ? `rgb(${chatProfile.profile.color.red}, ${chatProfile.profile.color.green}, ${chatProfile.profile.color.blue})`
- : "#bd6e86";
-
- useEffect(() => {
- if (profile?.did && !chatProfile?.profile) {
- getChatProfileRecordFromPDS();
- }
- setTempColor(currentColor);
- }, [profile?.did, chatProfile?.profile?.color, currentColor]);
-
- const handleOpenModal = () => {
- if (!isWeb) {
- Keyboard.dismiss();
- }
- setTempColor(currentColor);
- setModalVisible(true);
- };
-
- const handleCloseModal = () => {
- setModalVisible(false);
- setTempColor(currentColor); // Reset to current color on cancel
- };
-
- const handleSaveColor = () => {
- setModalVisible(false);
- const parsed = parseRgbString(tempColor);
- createChatProfileRecord(parsed.red, parsed.green, parsed.blue);
- };
+ const { currentColor, openModal, modal } = useNameColorPicker();
return (
@@ -298,7 +355,7 @@ export default function NameColorPicker({
variant="secondary"
leftIcon={}
style={[buttonProps?.style]}
- onPress={handleOpenModal}
+ onPress={openModal}
{...buttonProps}
>
@@ -306,149 +363,7 @@ export default function NameColorPicker({
-
-
- e.stopPropagation()}
- >
- {/* Header */}
-
-
-
-
- Choose Color
-
-
-
-
-
-
-
- {/* User Preview */}
- {profile?.handle && (
-
-
- {formatHandleWithAt(profile)}
-
-
- Preview
-
-
- )}
-
- {/* Color Picker */}
-
- setTempColor(result.rgb)}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Actions */}
-
-
-
- Cancel
-
-
-
-
- Save Color
-
-
-
-
-
-
+ {modal}
{children}
--
2.51.2
From 01bbebaf34c8d503bb52c33afdcddbdd100051c0 Mon Sep 17 00:00:00 2001
From: Natalie Bridgers
Date: Sun, 31 May 2026 15:28:30 -0500
Subject: [PATCH 02/28] feat: try delegating pins
---
js/app/src/screens/chat-popout.tsx | 5 +-
.../components/dashboard/moderator-panel.tsx | 47 +++++++++++++++++++
.../src/content/docs/features/chat-popout.md | 13 +++++
pkg/spxrpc/place_stream_moderation.go | 19 --------
4 files changed, 63 insertions(+), 21 deletions(-)
diff --git a/js/app/src/screens/chat-popout.tsx b/js/app/src/screens/chat-popout.tsx
index 382da0b51..49622165c 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 f13ce7d26..4366cb668 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 b8b120d03..d071e85aa 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 f621f8e31..a70ff81dd 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 03/28] 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 da6a63ccd..9949c753c 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 a70ff81dd..2a806e5c2 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,
--
2.51.2
From ebabf5e7a96c208d4964e5e496fe0376f59c1330 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 2 Jun 2026 15:08:50 -0700
Subject: [PATCH 04/28] vod: add login from /upload
---
js/app/src/router.tsx | 17 ++++++++++--
js/app/src/screens/upload.tsx | 49 +++++++++++++++++++++++++++++++++++
2 files changed, 64 insertions(+), 2 deletions(-)
diff --git a/js/app/src/router.tsx b/js/app/src/router.tsx
index 743d1afbb..69c150318 100644
--- a/js/app/src/router.tsx
+++ b/js/app/src/router.tsx
@@ -275,6 +275,7 @@ export const UploadButton = () => {
const isCompact = windowWidth <= 800;
const { status: betaStatus, loading: betaLoading } = useBetaStatus("vod");
+ const navigation = useNavigation();
if (!did) return null;
if (betaLoading) return null;
@@ -294,7 +295,13 @@ export const UploadButton = () => {
to={{ screen: "HomeTab", params: { screen: "Upload" } }}
style={{ marginRight: 10 }}
>
-