diff --git a/js/app/components/mobile/badge-picker.tsx b/js/app/components/mobile/badge-picker.tsx new file mode 100644 index 000000000..832d381cd --- /dev/null +++ b/js/app/components/mobile/badge-picker.tsx @@ -0,0 +1,412 @@ +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, + formatHandleWithAt, + hexToRgba, + ResponsiveDropdownMenuContent, + Text, + useLivestream, + useToast, + zero, +} from "@streamplace/components"; +import { + Badge, + BADGE_IMAGES, +} from "@streamplace/components/src/components/chat/badge"; +import { borderRadius as radiusTokens } from "@streamplace/components/src/lib/theme/tokens"; +import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; +import { useNameColorPicker } from "components/name-color-picker/name-color-picker"; +import { Image } from "expo-image"; +import { SwatchBook } from "lucide-react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Pressable, + TouchableOpacity, + View, +} from "react-native"; +import { useStore } from "store"; +import type { PlaceStreamBadgeDefs } from "streamplace"; + +const { gap, layout } = zero; + +const HOT_COLORS = [ + { r: 232, g: 121, b: 160 }, // rose + { r: 251, g: 113, b: 133 }, // coral + { r: 251, g: 146, b: 60 }, // orange + { r: 251, g: 191, b: 36 }, // amber + { r: 163, g: 230, b: 53 }, // lime + { r: 45, g: 212, b: 191 }, // teal + { r: 56, g: 189, b: 248 }, // sky + { r: 167, g: 139, b: 250 }, // lavender + { r: 244, g: 114, b: 182 }, // pink + { r: 253, g: 224, b: 71 }, // yellow + { r: 52, g: 211, b: 153 }, // emerald + { r: 96, g: 165, b: 250 }, // blue +]; + +export function BadgePicker() { + const agent = usePDSAgent(); + const { theme } = zero.useTheme(); + const toast = useToast(); + const linfo = useLivestream(); + const streamerDid = linfo?.author?.did; + + const [loading, setLoading] = useState(false); + const [streamerSlot, setStreamerSlot] = + useState(null); + const [userSlot, setUserSlot] = + useState(null); + const [toggling, setToggling] = useState(null); + const togglingRef = useRef(null); + + const activeBadge = streamerSlot?.selected ?? userSlot?.selected ?? null; + + const load = useCallback(async () => { + if (!agent?.did) return; + try { + setLoading(true); + const res = await agent.place.stream.badge.getIssuedBadges({ + streamer: streamerDid, + }); + setStreamerSlot(res.data.streamer); + setUserSlot(res.data.user); + } catch (e: any) { + toast.show("Failed to load badges", e?.message, { variant: "error" }); + } finally { + setLoading(false); + } + }, [agent, streamerDid]); + + useEffect(() => { + load(); + }, [load]); + + const handleToggle = useCallback( + async ( + badge: PlaceStreamBadgeDefs.BadgeIssuanceView, + slot: "streamer" | "global", + ) => { + if (!agent?.did || togglingRef.current) return; + togglingRef.current = badge.issuanceUri; + setToggling(badge.issuanceUri); + + try { + let currentRecord: Record = { + $type: "place.stream.chat.profile", + }; + let swapCid: string | undefined; + + try { + const getRes = await agent.com.atproto.repo.getRecord({ + repo: agent.did, + collection: "place.stream.chat.profile", + rkey: "self", + }); + currentRecord = getRes.data.value as Record; + swapCid = getRes.data.cid; + } catch { + // no profile yet, will create + } + + const currentBadges: Record = + (currentRecord.badges as Record) ?? {}; + const isCurrentlySelected = badge.selected ?? false; + const ref = { uri: badge.issuanceUri, cid: badge.issuanceCid ?? "" }; + + let newBadges: Record; + if (slot === "streamer") { + const currentStreamer: Array<{ + streamer: string; + badge: { uri: string; cid: string }; + }> = (currentBadges.streamer as any[]) ?? []; + newBadges = { + ...currentBadges, + streamer: isCurrentlySelected + ? currentStreamer.filter((s) => s.badge.uri !== badge.issuanceUri) + : [ + ...currentStreamer.filter((s) => s.streamer !== badge.issuer), + { streamer: badge.issuer, badge: ref }, + ], + }; + } else { + newBadges = { + ...currentBadges, + global: isCurrentlySelected ? undefined : ref, + }; + } + + await agent.com.atproto.repo.putRecord({ + repo: agent.did, + collection: "place.stream.chat.profile", + rkey: "self", + record: { ...currentRecord, badges: newBadges }, + swapRecord: swapCid, + }); + + if (slot === "streamer") { + setStreamerSlot((prev) => { + if (!prev) return prev; + const available = prev.available.map((b) => + b.issuanceUri === badge.issuanceUri + ? { ...b, selected: !isCurrentlySelected } + : b.issuer === badge.issuer + ? { ...b, selected: false } + : b, + ); + return { + available, + selected: isCurrentlySelected + ? undefined + : available.find((b) => b.issuanceUri === badge.issuanceUri), + }; + }); + } else { + setUserSlot((prev) => { + if (!prev) return prev; + const available = prev.available.map((b) => + b.issuanceUri === badge.issuanceUri + ? { ...b, selected: !isCurrentlySelected } + : { ...b, selected: false }, + ); + return { + available, + selected: isCurrentlySelected + ? undefined + : available.find((b) => b.issuanceUri === badge.issuanceUri), + }; + }); + } + } catch (e: any) { + toast.show("Failed to update badge", e?.message, { variant: "error" }); + } finally { + togglingRef.current = null; + setToggling(null); + } + }, + [agent], + ); + + const { currentColor, openModal, modal } = useNameColorPicker(); + const createChatProfileRecord = useStore((s) => s.createChatProfileRecord); + + if (!agent?.did) return null; + + const hasStreamerBadges = (streamerSlot?.available?.length ?? 0) > 0; + const hasUserBadges = (userSlot?.available?.length ?? 0) > 0; + const streamerName = linfo?.author; + + return ( + <> + + + + + + + Your chat appearance + + {loading ? ( + + + + ) : hasStreamerBadges || hasUserBadges ? ( + <> + {hasStreamerBadges && ( + + {streamerSlot!.available.map((badge) => ( + handleToggle(badge, "streamer")} + /> + ))} + + )} + {hasUserBadges && ( + + {userSlot!.available.map((badge) => ( + handleToggle(badge, "global")} + /> + ))} + + )} + + ) : null} + + + {HOT_COLORS.map(({ r, g, b }) => { + const hex = `rgb(${r}, ${g}, ${b})`; + const isSelected = currentColor === hex; + return ( + createChatProfileRecord(r, g, b)} + style={{ + width: 28, + height: 28, + borderRadius: 999, + backgroundColor: hex, + borderWidth: isSelected ? 2 : 0, + borderColor: "white", + shadowColor: isSelected ? hex : "transparent", + shadowOpacity: isSelected ? 0.8 : 0, + shadowRadius: 4, + shadowOffset: { width: 0, height: 0 }, + elevation: isSelected ? 4 : 0, + }} + /> + ); + })} + + + + + Custom color... + + + + + + {modal} + + ); +} + +function BadgeTriggerButton({ + badge, + loading, +}: { + badge: PlaceStreamBadgeDefs.BadgeIssuanceView | null; + loading: boolean; +}) { + const { theme } = zero.useTheme(); + const [hover, setHover] = useState(false); + + return ( + setHover(true)} + onHoverOut={() => setHover(false)} + > + {loading ? ( + + ) : badge?.imageUrl ? ( + + ) : badge && BADGE_IMAGES[badge.badgeType] ? ( + + ) : ( + + )} + + ); +} + +function BadgeCheckboxItem({ + badge, + toggling, + onToggle, +}: { + badge: PlaceStreamBadgeDefs.BadgeIssuanceView; + toggling: boolean; + onToggle: () => void; +}) { + const { theme } = zero.useTheme(); + const badgeName = badge.name ?? badge.badgeType.split("#")[1]; + const desc = badge.description; + + return ( + + + {badge.imageUrl ? ( + + ) : BADGE_IMAGES[badge.badgeType] ? ( + + ) : ( + + )} + + {badgeName} + {desc && ( + + {desc} + + )} + + {toggling && ( + + )} + + + ); +} diff --git a/js/app/components/mobile/useResponsiveLayout.ts b/js/app/components/mobile/useResponsiveLayout.ts index 774a96a9f..d26c3e539 100644 --- a/js/app/components/mobile/useResponsiveLayout.ts +++ b/js/app/components/mobile/useResponsiveLayout.ts @@ -47,8 +47,8 @@ export function useResponsiveLayout({ const chatPanelWidth = responsiveValue( { md: 320, - lg: 400, - xl: 480, + lg: 380, + xl: 400, default: 300, }, screenWidth, diff --git a/js/components/src/components/chat/chat-box.tsx b/js/components/src/components/chat/chat-box.tsx index 73473c078..2754ec080 100644 --- a/js/components/src/components/chat/chat-box.tsx +++ b/js/components/src/components/chat/chat-box.tsx @@ -13,6 +13,7 @@ import { import { StreamNotifications } from "../../lib/stream-notifications"; import { SystemMessages } from "../../lib/system-messages"; import { + bg, borders, flex, gap, @@ -62,6 +63,7 @@ export function ChatBox({ emojiPicker, skinTone = 0, hideLogin = false, + leftSlot, }: { isPopout?: boolean; chatBoxStyle?: any; @@ -75,8 +77,10 @@ export function ChatBox({ ) => ReactNode; skinTone?: number; hideLogin?: boolean; + leftSlot?: ReactNode; }) { const [submitting, setSubmitting] = useState(false); + const [inputFocused, setInputFocused] = useState(false); const [message, setMessage] = useState(""); const [showSuggestions, setShowSuggestions] = useState(false); const [showEmojiSuggestions, setShowEmojiSuggestions] = useState(false); @@ -423,90 +427,133 @@ export function ChatBox({ )} -