From 5567f40bddba5108653ce74618dba77d8d48de70 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 20 Oct 2026 23:25:44 -0700 Subject: [PATCH 1/5] Fix invalid handle fallbacks and periodically refresh identities --- .maestro/README.md | 35 +++++ .maestro/logged-out/invalid-handle.js | 44 ++++++ .maestro/logged-out/invalid-handle.yaml | 48 ++++++ hack/e2e-local.sh | 1 + js/app/components/create-livestream.tsx | 9 +- js/app/components/edit-livestream.tsx | 2 +- js/app/components/home/cards.tsx | 16 +- .../live-dashboard/livestream-panel.tsx | 2 +- .../live-dashboard/stream-monitor.tsx | 6 +- js/app/components/login/login-form.tsx | 5 +- js/app/components/mobile/badge-picker.tsx | 4 +- js/app/components/mobile/bottom-metadata.tsx | 11 +- .../mobile/desktop-ui/top-controls.tsx | 5 +- js/app/components/mobile/ui.tsx | 17 ++- js/app/components/mobile/user-offline.tsx | 14 +- .../name-color-picker/name-color-picker.tsx | 11 +- js/app/components/search-results.tsx | 12 +- .../settings/account-category-settings.tsx | 9 +- .../settings/recommendations-manager.tsx | 3 +- js/app/components/settings/settings.tsx | 3 +- js/app/components/sidebar/sidebar-overlay.tsx | 8 +- js/app/components/user-search.tsx | 3 +- js/app/components/video/video-card.tsx | 5 +- js/app/hooks/useActorTypeahead.tsx | 122 --------------- js/app/hooks/useTitle.tsx | 7 +- js/app/src/router.tsx | 10 +- js/app/src/screens/chat-popout.native.tsx | 3 +- js/app/src/screens/home.tsx | 7 +- js/app/src/screens/video-list.tsx | 3 +- js/app/store/slices/blueskySlice.ts | 12 +- .../src/components/chat/chat-box.tsx | 8 +- .../src/components/chat/chat-message.tsx | 19 ++- .../src/components/chat/mod-view.tsx | 2 +- .../src/components/chat/teleport-modal.tsx | 11 +- .../src/components/chat/user-profile-card.tsx | 9 +- .../components/dashboard/moderator-panel.tsx | 2 +- .../src/components/mobile-player/shared.tsx | 22 +-- .../src/components/share/sharesheet.tsx | 19 ++- .../stream-notification/pin-notification.tsx | 7 +- .../teleport-notification.tsx | 8 +- .../src/components/vod/vod-comments.tsx | 3 +- .../components/vod/vod-mobile-metadata.tsx | 16 +- js/components/src/index.tsx | 1 - js/components/src/lib/stream-notifications.ts | 10 +- .../src/livestream-provider/index.tsx | 14 +- .../src/livestream-provider/websocket.tsx | 2 +- js/components/src/livestream-store/hooks.tsx | 3 +- .../src/streamplace-store/branding.tsx | 5 +- .../src/streamplace-store/stream.tsx | 8 +- js/components/src/utils/format-handle.ts | 24 --- .../tests/playback-identity-url.test.tsx | 67 ++++++++ js/core/src/hooks/use-actor-typeahead.ts | 2 + js/core/src/index.ts | 1 + js/core/src/lib/badge-label.test.ts | 6 + js/core/src/lib/badge-label.ts | 10 +- js/core/src/lib/format-handle.test.ts | 23 ++- js/core/src/lib/format-handle.ts | 19 +-- js/core/src/slash-commands/teleport.test.ts | 37 ++++- js/core/src/slash-commands/teleport.ts | 54 ++++--- js/dev-env/run.mjs | 9 +- js/docs/src/content/docs/developers.mdx | 30 ++++ js/e2e-web/README.md | 16 ++ js/e2e-web/flows/invalid-handle-fixture.ts | 69 +++++++++ js/e2e-web/flows/invalid-handle.spec.ts | 143 ++++++++++++++++++ js/i18n/locales/en-US/common.ftl | 12 +- js/i18n/locales/en-US/settings.ftl | 2 +- js/i18n/locales/es-ES/common.ftl | 8 +- js/i18n/locales/es-ES/settings.ftl | 2 +- js/i18n/locales/fr-FR/common.ftl | 8 +- js/i18n/locales/fr-FR/settings.ftl | 2 +- js/i18n/locales/pt-BR/common.ftl | 8 +- js/i18n/locales/pt-BR/settings.ftl | 2 +- js/i18n/locales/ro-RO/common.ftl | 8 +- js/i18n/locales/ro-RO/settings.ftl | 2 +- js/i18n/locales/zh-Hans/common.ftl | 8 +- js/i18n/locales/zh-Hans/settings.ftl | 2 +- js/i18n/locales/zh-Hant/common.ftl | 8 +- js/i18n/locales/zh-Hant/settings.ftl | 2 +- js/web/src/components/auth/login-modal.tsx | 3 +- .../src/components/dashboard/moderators.tsx | 5 +- js/web/src/components/header.tsx | 15 +- js/web/src/components/stream/chat-input.tsx | 18 ++- .../components/stream/chat-message-menu.tsx | 14 +- js/web/src/components/stream/chat-panel.tsx | 36 ++--- js/web/src/components/stream/chat-sidebar.tsx | 3 +- .../components/stream/livestream-provider.tsx | 2 +- js/web/src/components/stream/mention-list.tsx | 7 +- .../stream/offline-recommendation.test.ts | 15 +- .../stream/offline-recommendation.ts | 2 +- .../src/components/stream/player-offline.tsx | 35 +++-- js/web/src/components/stream/stream-card.tsx | 13 +- js/web/src/components/stream/stream-info.tsx | 53 +++---- .../stream/stream-notifications.tsx | 10 +- .../src/components/stream/teleport-dialog.tsx | 23 +-- js/web/src/components/video/video-card.tsx | 5 +- .../src/components/video/vod-watch-header.tsx | 16 +- js/web/src/components/video/vod-watch.test.ts | 27 ---- js/web/src/components/video/vod-watch.ts | 7 - js/web/src/hooks/use-live-users.ts | 4 +- js/web/src/hooks/use-livestream-store.ts | 2 +- js/web/src/routes/$user/index.tsx | 34 +++-- js/web/src/routes/$user/video/$tid.tsx | 1 - .../dashboard/recommendations/index.tsx | 9 +- js/web/src/routes/search.tsx | 9 +- js/web/src/routes/settings/account.tsx | 5 +- js/web/src/routes/settings/chat-profile.tsx | 3 +- pkg/atproto/atproto.go | 35 +---- pkg/atproto/headcheck.go | 43 +++--- pkg/atproto/headcheck_test.go | 98 ++++++++++++ pkg/atproto/refresh_identity_test.go | 83 ++++++++++ pkg/config/config.go | 17 ++- pkg/director/stream_session.go | 9 +- pkg/linking/invalid_handle_test.go | 59 ++++++++ pkg/linking/linking.go | 14 +- pkg/model/repo.go | 66 ++++++-- pkg/model/repo_handle_test.go | 108 +++++++++++++ pkg/spxrpc/com_atproto_moderation.go | 3 +- pkg/spxrpc/og.go | 10 +- pkg/spxrpc/place_stream_live.go | 7 +- 119 files changed, 1455 insertions(+), 663 deletions(-) create mode 100644 .maestro/logged-out/invalid-handle.js create mode 100644 .maestro/logged-out/invalid-handle.yaml delete mode 100644 js/app/hooks/useActorTypeahead.tsx delete mode 100644 js/components/src/utils/format-handle.ts create mode 100644 js/components/tests/playback-identity-url.test.tsx create mode 100644 js/e2e-web/flows/invalid-handle-fixture.ts create mode 100644 js/e2e-web/flows/invalid-handle.spec.ts create mode 100644 pkg/linking/invalid_handle_test.go create mode 100644 pkg/model/repo_handle_test.go diff --git a/.maestro/README.md b/.maestro/README.md index 0de15b0ff..707ce1e53 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -87,6 +87,41 @@ These focused tests use native presentation adapters; the Maestro flow exercises the actual native sheet and portal. The component tests also run in `pnpm run check`. +## Invalid handle coverage + +`logged-out/invalid-handle.yaml` covers an account whose handle no longer +verifies, while its DID and live stream stay the same. Its `onFlowStart` hook +runs `invalid-handle.js` on the host. The script signs in to the harness PDS as +the harness account and moves it to a fresh `invalid-.test` handle. +Public resolution can't verify that handle, so when the script asks the node to +`com.atproto.identity.refreshIdentity` the account, the node must report the +`handle.invalid` sentinel. + +The flow waits up to 60 seconds for the home card to name the account by its +bare DID. The node's live feed caches identities for 30 seconds, and the app +polls it every 3. The flow then checks that the screen shows no sentinel and no +`@did:` label, opens the stream from that card, and waits for the "Now streaming" +chat system message. + +`onFlowComplete` runs even when the flow or its setup fails. It moves the account +back to `ACCOUNT_HANDLE` and requires the node to verify that handle again. It +then relaunches the app and waits for the card to show `@`, so later +flows see the verified handle. + +The flow is shared by iOS and Android. It needs `ACCOUNT_DID`, `ACCOUNT_HANDLE`, +`ACCOUNT_PASSWORD`, `PDS_HTTP_URL`, and `SERVER_HTTP_URL`. `SERVER_HTTP_URL` is +the node's plain-HTTP loopback URL, which is the harness's own `SERVER_URL`. +Maestro's `SERVER_URL` is the HTTPS name the app uses, and the host's JavaScript +doesn't trust the harness CA. The harness must accept `.test` handles on its PDS, +as `js/dev-env` configures it to. To run the flow alone: + +```bash +E2E_FLOWS=".maestro/logged-out/invalid-handle.yaml" hack/e2e-local.sh android +``` + +The Web counterpart, `js/e2e-web/flows/invalid-handle.spec.ts`, covers the Expo +and Vite shells with the same fixture. + ## HTTPS, and logging in The app reaches the harness over HTTPS only: release builds refuse cleartext, diff --git a/.maestro/logged-out/invalid-handle.js b/.maestro/logged-out/invalid-handle.js new file mode 100644 index 000000000..21532f73a --- /dev/null +++ b/.maestro/logged-out/invalid-handle.js @@ -0,0 +1,44 @@ +// Moves the harness account to a .test handle that public resolution can't +// verify, so the node reports the "handle.invalid" sentinel for it, or +// (INVALID_HANDLE_STEP=restore) back to ACCOUNT_HANDLE. Same DID, same live +// stream. Maestro runs this on the host, so it reaches the PDS and node over +// their plain-HTTP loopback listeners. +function post(url, body, headers) { + const res = http.post(url, { + headers: Object.assign({ "Content-Type": "application/json" }, headers), + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`${url}: HTTP ${res.status}: ${res.body}`); + } + return res; +} + +const restore = INVALID_HANDLE_STEP === "restore"; +const handle = restore + ? ACCOUNT_HANDLE + : `invalid-${Date.now().toString(36)}.test`; +const { accessJwt } = json( + post(`${PDS_HTTP_URL}/xrpc/com.atproto.server.createSession`, { + identifier: ACCOUNT_DID, + password: ACCOUNT_PASSWORD, + }).body, +); +post( + `${PDS_HTTP_URL}/xrpc/com.atproto.identity.updateHandle`, + { handle }, + { Authorization: `Bearer ${accessJwt}` }, +); +// The node re-reads the DID document and verifies the handle it claims. +const identity = json( + post(`${SERVER_HTTP_URL}/xrpc/com.atproto.identity.refreshIdentity`, { + identifier: ACCOUNT_DID, + }).body, +); +const expected = restore ? ACCOUNT_HANDLE : "handle.invalid"; +if (identity.handle !== expected) { + throw new Error( + `refreshIdentity after updateHandle(${handle}): handle ${identity.handle}, want ${expected}`, + ); +} +console.log(`${ACCOUNT_DID} now resolves to ${identity.handle}`); diff --git a/.maestro/logged-out/invalid-handle.yaml b/.maestro/logged-out/invalid-handle.yaml new file mode 100644 index 000000000..284bc197c --- /dev/null +++ b/.maestro/logged-out/invalid-handle.yaml @@ -0,0 +1,48 @@ +appId: ${APP_ID} +# The harness account's handle stops verifying while its stream stays live. +# onFlowComplete puts the verified handle back even when the flow fails, and +# doesn't rely on anything onFlowStart did. +onFlowStart: + - runScript: + file: invalid-handle.js + env: + INVALID_HANDLE_STEP: invalidate +onFlowComplete: + - runScript: + file: invalid-handle.js + env: + INVALID_HANDLE_STEP: restore + # Later flows expect the verified handle. The node's live feed caches + # identities for 30 seconds, and the app polls it every 3. + - launchApp + - extendedWaitUntil: + visible: + text: ".*@${ACCOUNT_HANDLE}.*" + timeout: 60000 +--- +- launchApp +# Until the feed cache expires the card still shows the verified handle; then +# it names the account by its bare DID. Matched as a substring because iOS +# collapses the card into one element with a concatenated label. +- extendedWaitUntil: + visible: + text: ".*${ACCOUNT_DID}.*" + timeout: 60000 +- assertNotVisible: + text: '.*handle\.invalid.*' +- assertNotVisible: + text: ".*@did:.*" +- takeScreenshot: invalid-handle-home +# The card routes by DID; the stream's chat system message proves its +# context loaded. +- tapOn: + text: ".*${ACCOUNT_DID}.*" +- extendedWaitUntil: + visible: + text: ".*Now streaming - e2e test stream.*" + timeout: 30000 +- assertNotVisible: + text: '.*handle\.invalid.*' +- assertNotVisible: + text: ".*@did:.*" +- takeScreenshot: invalid-handle-stream diff --git a/hack/e2e-local.sh b/hack/e2e-local.sh index 8ecccd527..67f6469fc 100755 --- a/hack/e2e-local.sh +++ b/hack/e2e-local.sh @@ -144,6 +144,7 @@ adb_ shell am force-stop com.android.chrome || true device_offset="$(adb_ shell date +%z | tr -d '\r')" [[ "$device_offset" =~ ^[+-][0-9]{4}$ ]] || { echo "can't read $DEVICE's UTC offset: '$device_offset'"; exit 1; } MAESTRO_ARGS=(-e APP_ID="$APP_ID" -e SERVER_URL="$SERVER_HTTPS_URL" + -e SERVER_HTTP_URL="$SERVER_URL" -e ACCOUNT_HANDLE="$ACCOUNT_HANDLE" -e ACCOUNT_PASSWORD="$ACCOUNT_PASSWORD" -e ACCOUNT_DID="$ACCOUNT_DID" -e PDS_HTTP_URL="$PDS_HTTP_URL" -e DEVICE_UTC_OFFSET="${device_offset:0:3}:${device_offset:3}") diff --git a/js/app/components/create-livestream.tsx b/js/app/components/create-livestream.tsx index 1ed854c1d..f28724855 100644 --- a/js/app/components/create-livestream.tsx +++ b/js/app/components/create-livestream.tsx @@ -1,10 +1,5 @@ -import { - Button, - formatHandleWithAt, - Text, - useTheme, - zero, -} from "@streamplace/components"; +import { Button, Text, useTheme, zero } from "@streamplace/components"; +import { formatHandleWithAt } from "@streamplace/core"; import ActivityPicker from "components/activity-picker"; import ThumbnailSelector from "components/thumbnail-selector"; import { useCaptureVideoFrame } from "hooks/useCaptureVideoFrame"; diff --git a/js/app/components/edit-livestream.tsx b/js/app/components/edit-livestream.tsx index 8ba232766..892a5ed5a 100644 --- a/js/app/components/edit-livestream.tsx +++ b/js/app/components/edit-livestream.tsx @@ -1,11 +1,11 @@ import { Button, - formatHandleWithAt, Text, useLivestream, useTheme, zero, } from "@streamplace/components"; +import { formatHandleWithAt } from "@streamplace/core"; import ActivityPicker from "components/activity-picker"; import { useLiveUser } from "hooks/useLiveUser"; import { useEffect, useState } from "react"; diff --git a/js/app/components/home/cards.tsx b/js/app/components/home/cards.tsx index 79279929b..fbd678f3c 100644 --- a/js/app/components/home/cards.tsx +++ b/js/app/components/home/cards.tsx @@ -10,6 +10,7 @@ import { motion, spacing, } from "@streamplace/components/src/lib/theme/tokens"; +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { Image } from "expo-image"; import useStreamplaceNode from "hooks/useStreamplaceNode"; import { useState } from "react"; @@ -23,7 +24,7 @@ interface StreamCardProps { thumbnailUrl: string; avatarUrl?: string; title?: string; - streamerName?: string; + streamer?: { did: string; handle?: string | null }; viewers?: number; category: string[]; activity?: string; @@ -53,7 +54,7 @@ const StreamCard = ({ thumbnailUrl, avatarUrl, title, - streamerName, + streamer, viewers = 0, category = [], activity, @@ -160,7 +161,12 @@ const StreamCard = ({ ]} > {showAvatar && ( - + )} )} - {streamerName && ( + {streamer && ( - @{streamerName} + {formatHandleWithAt(streamer)} )} {watchingLine.length > 0 && ( diff --git a/js/app/components/live-dashboard/livestream-panel.tsx b/js/app/components/live-dashboard/livestream-panel.tsx index 96016e664..f4f741fd8 100644 --- a/js/app/components/live-dashboard/livestream-panel.tsx +++ b/js/app/components/live-dashboard/livestream-panel.tsx @@ -7,7 +7,6 @@ import { DropdownMenu, DropdownMenuItem, DropdownMenuTrigger, - formatHandle, getBlob, hexToRgba, Input, @@ -36,6 +35,7 @@ import { surfaces, textAlphas, } from "@streamplace/components/src/lib/theme/tokens"; +import { formatHandle } from "@streamplace/core"; import { Image } from "expo-image"; import { ChevronsUpDown, Globe, ImagePlus, Lock, X } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; diff --git a/js/app/components/live-dashboard/stream-monitor.tsx b/js/app/components/live-dashboard/stream-monitor.tsx index 597b7ec51..5252cf2e4 100644 --- a/js/app/components/live-dashboard/stream-monitor.tsx +++ b/js/app/components/live-dashboard/stream-monitor.tsx @@ -18,6 +18,7 @@ import { surfaces, textAlphas, } from "@streamplace/components/src/lib/theme/tokens"; +import { formatHandle } from "@streamplace/core"; import { DesktopUi } from "components/mobile/desktop-ui"; import { OfflineCounter } from "components/mobile/offline-counter"; import { Image } from "expo-image"; @@ -145,7 +146,8 @@ export default function StreamMonitor({ const [currentThumbnail, setCurrentThumbnail] = useState(null); // Mock thumbnails - in a real implementation, these would come from your stream service - const thumbnails = "/api/playback/" + profile?.did + "/stream.jpg"; + const thumbnails = + "/api/playback/" + encodeURIComponent(profile?.did ?? "") + "/stream.jpg"; // Rotate thumbnails every 30 seconds when stream is hidden useEffect(() => { @@ -220,7 +222,7 @@ export default function StreamMonitor({ numberOfLines={1} ellipsizeMode="tail" > - {userProfile?.handle || userProfile?.displayName || "Not live"} + {userProfile ? formatHandle(userProfile) : "Not live"} ); } diff --git a/js/app/components/login/login-form.tsx b/js/app/components/login/login-form.tsx index 2f9c326b7..b259f0455 100644 --- a/js/app/components/login/login-form.tsx +++ b/js/app/components/login/login-form.tsx @@ -11,8 +11,8 @@ import { useTranslation, zero, } from "@streamplace/components"; +import { formatHandle, useActorTypeahead } from "@streamplace/core"; import { Image } from "expo-image"; -import useActorTypeahead from "hooks/useActorTypeahead"; import { ArrowRightToLine, AtSign, @@ -48,8 +48,9 @@ export default function LoginForm({ const [blueskyPermissions, setBlueskyPermissions] = useState(true); const { actors } = useActorTypeahead(handle); + // an unverifiable account formats as its DID, so it can't complete a handle const filteredActors = useMemo( - () => actors.filter((actor) => actor.handle.startsWith(handle)), + () => actors.filter((actor) => formatHandle(actor).startsWith(handle)), [actors, handle], ); diff --git a/js/app/components/mobile/badge-picker.tsx b/js/app/components/mobile/badge-picker.tsx index 131091432..ed1d8ef4e 100644 --- a/js/app/components/mobile/badge-picker.tsx +++ b/js/app/components/mobile/badge-picker.tsx @@ -4,7 +4,6 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger, - formatHandleWithAt, hexToRgba, ResponsiveDropdownMenuContent, Text, @@ -21,6 +20,7 @@ import { import { borderRadius as radiusTokens } from "@streamplace/components/src/lib/theme/tokens"; import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; import { p } from "@streamplace/components/src/ui"; +import { formatHandleWithAt } from "@streamplace/core"; import { useNameColorPicker } from "components/name-color-picker/name-color-picker"; import { Image } from "expo-image"; import { Check, SwatchBook } from "lucide-react-native"; @@ -241,7 +241,7 @@ export function BadgePicker() { /> )} - @{me?.handle || me?.did || "yourhand.le"} + {me ? formatHandleWithAt(me) : "@yourhand.le"} : what is a bluesky account diff --git a/js/app/components/mobile/bottom-metadata.tsx b/js/app/components/mobile/bottom-metadata.tsx index a14a0c4a5..bf6af8ee7 100644 --- a/js/app/components/mobile/bottom-metadata.tsx +++ b/js/app/components/mobile/bottom-metadata.tsx @@ -3,8 +3,6 @@ import { ContentRights, ContentWarnings, formatActivity, - formatHandle, - formatHandleWithAt, IconButton, layout, LiveBadge, @@ -25,6 +23,7 @@ import { motion, statusColors, } from "@streamplace/components/src/lib/theme/tokens"; +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import AQLink from "components/aqlink"; import FollowButton from "components/follow-button"; import { @@ -189,9 +188,9 @@ export function BottomMetadata({ { - if (profile?.handle) { + if (profile?.did) { Linking.openURL( - `https://bsky.app/profile/${formatHandle(profile)}`, + `https://bsky.app/profile/${encodeURIComponent(formatHandle(profile))}`, ); } }} @@ -469,9 +468,9 @@ function CompactStreamInfo() { ) : null} { - if (profile?.handle) { + if (profile?.did) { Linking.openURL( - `https://bsky.app/profile/${formatHandle(profile)}`, + `https://bsky.app/profile/${encodeURIComponent(formatHandle(profile))}`, ); } }} diff --git a/js/app/components/mobile/desktop-ui/top-controls.tsx b/js/app/components/mobile/desktop-ui/top-controls.tsx index 82ced1406..9ec163ad4 100644 --- a/js/app/components/mobile/desktop-ui/top-controls.tsx +++ b/js/app/components/mobile/desktop-ui/top-controls.tsx @@ -17,6 +17,7 @@ import { surfaces, textAlphas, } from "@streamplace/components/src/lib/theme/tokens"; +import { formatHandle } from "@streamplace/core"; import { LogoTile } from "components/brand/logo"; import { Image } from "expo-image"; import { ChevronLeft, MessageSquare, SwitchCamera } from "lucide-react-native"; @@ -116,7 +117,9 @@ export function TopControlBar({ {title} - {profile?.handle} + + {profile && formatHandle(profile)} + diff --git a/js/app/components/mobile/ui.tsx b/js/app/components/mobile/ui.tsx index 85c61ec15..1e8c7de17 100644 --- a/js/app/components/mobile/ui.tsx +++ b/js/app/components/mobile/ui.tsx @@ -36,6 +36,7 @@ import { textAlphas, } from "@streamplace/components/src/lib/theme/tokens"; import { px, py } from "@streamplace/components/src/ui"; +import { formatHandle } from "@streamplace/core"; import { Image } from "expo-image"; import { ChevronLeft, @@ -134,7 +135,7 @@ export function MobileUi({ const [showLoading, setShowLoading] = useState(false); const openChatOnlyMode = () => { - const user = streamProfile?.handle; + const user = streamProfile && formatHandle(streamProfile); if (!user) return; navigation.navigate("PopoutChat" as any, { user }); }; @@ -273,7 +274,7 @@ export function MobileUi({ {profile ? ( Linking.openURL(profileUrl(profile))} style={[ headerControlStyle, @@ -284,7 +285,11 @@ export function MobileUi({ p[2], ]} > - + - {profile.handle || profile.did} + {formatHandle(profile)} ) : ( @@ -314,7 +319,7 @@ export function MobileUi({ {shouldShowChatSidePanel && setShowChat && ( @@ -578,7 +583,7 @@ function LeftControlsPanel({ ]} /> - {profile?.handle} + {profile && formatHandle(profile)} diff --git a/js/app/components/mobile/user-offline.tsx b/js/app/components/mobile/user-offline.tsx index 152bb9116..8ca6e0f37 100644 --- a/js/app/components/mobile/user-offline.tsx +++ b/js/app/components/mobile/user-offline.tsx @@ -13,6 +13,7 @@ import { } from "@streamplace/components"; import { overflow } from "@streamplace/components/src/lib/theme/atoms"; import { scrims } from "@streamplace/components/src/lib/theme/tokens"; +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { Image } from "expo-image"; import { ChevronLeft } from "lucide-react-native"; import { memo, useEffect, useMemo, useState } from "react"; @@ -166,7 +167,7 @@ export const UserOffline = memo(() => { borders.color.gray[700], ]} /> - {profile?.handle} + {profile && formatHandle(profile)} )} @@ -221,7 +222,7 @@ export const UserOffline = memo(() => { { borders.color.gray[700], ]} /> - {profile?.handle} + {profile && formatHandle(profile)} )} @@ -355,7 +356,7 @@ export const UserOffline = memo(() => { i18nKey="user-offline-message" ns="common" values={{ - handle: profile.handle, + handle: formatHandleWithAt(profile), source: recommendedSource.source, }} components={{ @@ -422,6 +423,7 @@ const RecommendedSourceInfo = memo(() => { const pfp = useAvatars(profile?.did ? [profile.did] : []); const detailedProfile = profile?.did ? pfp[profile.did] : null; + const author = detailedProfile ?? profile; return ( { ]} /> - - @{detailedProfile?.handle || profile?.handle} - + {author && formatHandleWithAt(author)} {t("viewer-count", { count: viewers || 0 })} 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 a02f972b6..a124f45c2 100644 --- a/js/app/components/name-color-picker/name-color-picker.tsx +++ b/js/app/components/name-color-picker/name-color-picker.tsx @@ -1,10 +1,5 @@ -import { - Button, - formatHandleWithAt, - Text, - useTheme, - zero, -} from "@streamplace/components"; +import { Button, Text, useTheme, zero } from "@streamplace/components"; +import { formatHandleWithAt } from "@streamplace/core"; import { Palette, SwatchBook, X } from "lucide-react-native"; import { useEffect, useState } from "react"; import { @@ -153,7 +148,7 @@ export function useNameColorPicker() { {/* Preview — your handle in the chosen color */} - {profile?.handle && ( + {profile?.did && ( - {`@${actor.handle}`} + {formatHandleWithAt(actor)} ); diff --git a/js/app/components/settings/account-category-settings.tsx b/js/app/components/settings/account-category-settings.tsx index 09ef13dcc..3adf63d5b 100644 --- a/js/app/components/settings/account-category-settings.tsx +++ b/js/app/components/settings/account-category-settings.tsx @@ -12,6 +12,7 @@ import { View, zero, } from "@streamplace/components"; +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { useNameColorPicker } from "components/name-color-picker/name-color-picker"; import { Image } from "expo-image"; import { Award, Edit3, LogOut, Palette, X } from "lucide-react-native"; @@ -101,10 +102,8 @@ export function AccountCategorySettings() { )} {(() => { - const greeting = t("account-greeting", { - handle: userProfile.handle, - }); - const handle = `@${userProfile.handle}`; + const handle = formatHandleWithAt(userProfile); + const greeting = t("account-greeting", { handle }); const parts = greeting.split(handle); return ( <> @@ -131,7 +130,7 @@ export function AccountCategorySettings() { diff --git a/js/app/components/settings/recommendations-manager.tsx b/js/app/components/settings/recommendations-manager.tsx index 5eb68dbf6..6db3179a4 100644 --- a/js/app/components/settings/recommendations-manager.tsx +++ b/js/app/components/settings/recommendations-manager.tsx @@ -13,6 +13,7 @@ import { zero, } from "@streamplace/components"; import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; +import { formatHandleWithAt } from "@streamplace/core"; import { EmptyState, EmptyStateTile } from "components/empty-state"; import Loading from "components/loading/loading"; import { @@ -392,7 +393,7 @@ export default function RecommendationsManager() { ]} > - @{actor.handle} + {formatHandleWithAt(actor)} {alreadyAdded && ( - @{userProfile.handle} + {formatHandleWithAt(userProfile)} diff --git a/js/app/components/sidebar/sidebar-overlay.tsx b/js/app/components/sidebar/sidebar-overlay.tsx index 2719e7cf0..7f348aa41 100644 --- a/js/app/components/sidebar/sidebar-overlay.tsx +++ b/js/app/components/sidebar/sidebar-overlay.tsx @@ -16,6 +16,7 @@ import { import { BlueskyIcon } from "@streamplace/components/src/components/icons/bluesky-icon"; import { DiscordIcon } from "@streamplace/components/src/components/icons/discord-icon"; import { colors, spacing } from "@streamplace/components/src/lib/theme/tokens"; +import { formatHandle } from "@streamplace/core"; import { decodeDataUrlText, SiteTitleLockup } from "components/brand/logo"; import { LogoBrandMenu } from "components/brand/logo-brand-menu"; import { Image } from "expo-image"; @@ -185,7 +186,12 @@ export function resolveNavUrl( } } return url - .replace("{handle}", encodeURIComponent(viewer.handle || viewer.did)) + .replace( + "{handle}", + encodeURIComponent( + formatHandle({ did: viewer.did, handle: viewer.handle }), + ), + ) .replace("{did}", encodeURIComponent(viewer.did)); } diff --git a/js/app/components/user-search.tsx b/js/app/components/user-search.tsx index b927bdde1..135368bc9 100644 --- a/js/app/components/user-search.tsx +++ b/js/app/components/user-search.tsx @@ -6,6 +6,7 @@ import { touchTargets, } from "@streamplace/components/src/lib/theme/tokens"; import { usePossiblyUnauthedPDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; +import { formatHandle } from "@streamplace/core"; import { EmptyState, EmptyStateTile } from "components/empty-state"; import SearchResults, { SEARCH_RESULT_HEIGHT } from "components/search-results"; import { AlertCircle, Search } from "lucide-react-native"; @@ -302,7 +303,7 @@ function DropdownSearch({ dismiss(); linkTo( interpolateParams(getAbsolutePath("Stream"), { - user: activeActor.handle || activeActor.did, + user: formatHandle(activeActor), }), ); } diff --git a/js/app/components/video/video-card.tsx b/js/app/components/video/video-card.tsx index 67ccbfe7e..45a6876ec 100644 --- a/js/app/components/video/video-card.tsx +++ b/js/app/components/video/video-card.tsx @@ -4,6 +4,7 @@ import { motion, scrims, } from "@streamplace/components/src/lib/theme/tokens"; +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { Image } from "expo-image"; import { VideoView } from "hooks/useVideoList"; import { View } from "react-native"; @@ -35,7 +36,7 @@ export default function VideoCard({ const { theme } = useTheme(); const record = video.record as place.stream.video.Main; const author = video.author; - const user = author.handle || author.did; + const user = formatHandle(author); const tid = getTidFromAtUri(video.uri); const title = record.title || "Untitled"; const thumbnailUrl = getVideoThumbnailUrl(record, author.did); @@ -135,7 +136,7 @@ export default function VideoCard({ style={{ color: theme.colors.textMuted }} numberOfLines={1} > - @{user} + {formatHandleWithAt(author)} {formatCount(viewCount)} view{viewCount === 1 ? "" : "s"} ·{" "} diff --git a/js/app/hooks/useActorTypeahead.tsx b/js/app/hooks/useActorTypeahead.tsx deleted file mode 100644 index e54405af9..000000000 --- a/js/app/hooks/useActorTypeahead.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -interface Actor { - did: string; - handle: string; - displayName?: string; - avatar?: string; -} - -interface TypeaheadResult { - actors: Actor[]; - loading: boolean; - error: string | null; -} - -const MIN_QUERY_LENGTH = 3; -const DEBOUNCE_MS = 500; -const MIN_REQUEST_INTERVAL_MS = 1000; -const RESULT_LIMIT = 3; - -export default function useActorTypeahead(query: string): TypeaheadResult { - const [actors, setActors] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const abortControllerRef = useRef(null); - const lastRequestTimeRef = useRef(0); - const debounceTimerRef = useRef(null); - const actorsRef = useRef([]); - - useEffect(() => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - if (query.length < MIN_QUERY_LENGTH) { - setActors([]); - setLoading(false); - setError(null); - return; - } - - setLoading(true); - setError(null); - - debounceTimerRef.current = setTimeout(async () => { - const now = Date.now(); - const timeSinceLastRequest = now - lastRequestTimeRef.current; - - if (timeSinceLastRequest < MIN_REQUEST_INTERVAL_MS) { - const delay = MIN_REQUEST_INTERVAL_MS - timeSinceLastRequest; - await new Promise((resolve) => setTimeout(resolve, delay)); - } - - lastRequestTimeRef.current = Date.now(); - - const controller = new AbortController(); - abortControllerRef.current = controller; - - try { - const params = new URLSearchParams({ - q: query, - limit: RESULT_LIMIT.toString(), - }); - - const response = await fetch( - `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?${params.toString()}`, - { signal: controller.signal }, - ); - - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - - const data = await response.json(); - - if (!controller.signal.aborted) { - const newActors = data.actors || []; - - // check if actors actually changed - const actorsChanged = - newActors.length !== actorsRef.current.length || - newActors.some( - (actor: Actor, i: number) => - actor.did !== actorsRef.current[i]?.did || - actor.avatar !== actorsRef.current[i]?.avatar, - ); - - if (actorsChanged) { - actorsRef.current = newActors; - setActors(newActors); - } else { - // keep the same reference to prevent re-renders - setActors(actorsRef.current); - } - setLoading(false); - } - } catch (err: any) { - if (err.name !== "AbortError" && !controller.signal.aborted) { - setError(err.message || "Failed to search actors"); - setActors([]); - setLoading(false); - } - } - }, DEBOUNCE_MS); - - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }; - }, [query]); - - return { actors, loading, error }; -} diff --git a/js/app/hooks/useTitle.tsx b/js/app/hooks/useTitle.tsx index f1731b43c..c0511eda5 100644 --- a/js/app/hooks/useTitle.tsx +++ b/js/app/hooks/useTitle.tsx @@ -1,13 +1,15 @@ import { useNavigation } from "@react-navigation/native"; import { useBrandingAsset, useVideo } from "@streamplace/components"; +import { formatHandleWithAt } from "@streamplace/core"; import { useEffect } from "react"; export default function useTitle(user: string) { const navigation = useNavigation(); const title = useBrandingAsset("siteTitle")?.data || "Streamplace"; useEffect(() => { + // the route names the streamer by handle or bare DID; DIDs never get an @ navigation.setOptions({ - title: `@${user} on ${title}`, + title: `${user?.startsWith("did:") ? user : `@${user}`} on ${title}`, }); }, [user, navigation]); } @@ -20,10 +22,9 @@ export function useVideoTitle() { const navigation = useNavigation(); const video = useVideo(); const siteTitle = useBrandingAsset("siteTitle")?.data || "Streamplace"; - const handle = video?.author.handle; const title = video?.record.title?.trim() || - (handle ? `@${handle} on ${siteTitle}` : undefined); + (video ? `${formatHandleWithAt(video.author)} on ${siteTitle}` : undefined); useEffect(() => { if (title) { navigation.setOptions({ title }); diff --git a/js/app/src/router.tsx b/js/app/src/router.tsx index 235e99068..cde50a926 100644 --- a/js/app/src/router.tsx +++ b/js/app/src/router.tsx @@ -14,6 +14,7 @@ import { useTheme, } from "@streamplace/components"; import { statusColors } from "@streamplace/components/src/lib/theme/tokens"; +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import Provider from "components/provider/provider"; import { ImageBackground } from "expo-image"; import { useLiveUser } from "hooks/useLiveUser"; @@ -291,9 +292,8 @@ export const AvatarButton = () => { if (userProfile) { source = userProfile.avatar ? { uri: userProfile.avatar } : undefined; - const handle = userProfile.handle; - const name = userProfile.displayName?.trim() || handle; - const channelUser = handle || userProfile.did; + const channelUser = formatHandle(userProfile); + const name = userProfile.displayName?.trim() || channelUser; const avatar = ( { > {name} - {handle ? ( + {userProfile.did ? ( - @{handle} + {formatHandleWithAt(userProfile)} ) : null} diff --git a/js/app/src/screens/chat-popout.native.tsx b/js/app/src/screens/chat-popout.native.tsx index 99f143662..0108817cc 100644 --- a/js/app/src/screens/chat-popout.native.tsx +++ b/js/app/src/screens/chat-popout.native.tsx @@ -12,6 +12,7 @@ import { useSegment, zero, } from "@streamplace/components"; +import { formatHandle } from "@streamplace/core"; import LiveDot from "components/home/live-dot"; import { ArrowLeft } from "lucide-react-native"; import { useCallback, useEffect, useRef } from "react"; @@ -150,7 +151,7 @@ export function PopoutChatInner({ user }: { user: string }) { /> )} - {streamProfile?.handle || user} + {streamProfile ? formatHandle(streamProfile) : user} ( diff --git a/js/app/store/slices/blueskySlice.ts b/js/app/store/slices/blueskySlice.ts index 03e37dff3..411a4d2e7 100644 --- a/js/app/store/slices/blueskySlice.ts +++ b/js/app/store/slices/blueskySlice.ts @@ -10,6 +10,7 @@ import { OutputSchema } from "@atproto/api/dist/client/types/com/atproto/repo/li import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto"; import { OAuthSession } from "@atproto/oauth-client"; import { storage } from "@streamplace/components"; +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { Platform } from "react-native"; import { AppStore } from "store"; import { place, StreamplaceAgent } from "streamplace"; @@ -554,9 +555,10 @@ export const createBlueskySlice: StateCreator< time: new Date().toISOString(), }); - const linkUrl = `${u.protocol}//${u.host}/${profile.handle}?${params.toString()}`; + const user = encodeURIComponent(formatHandle(profile)); + const linkUrl = `${u.protocol}//${u.host}/${user}?${params.toString()}`; const prefix = `🔴 LIVE `; - const textUrl = `${u.protocol}//${u.host}/${profile.handle}`; + const textUrl = `${u.protocol}//${u.host}/${user}`; const suffix = ` ${text}`; const content = prefix + textUrl + suffix; @@ -579,7 +581,7 @@ export const createBlueskySlice: StateCreator< external: { description: text, thumb: thumbnail, - title: `@${profile.handle} is 🔴LIVE on ${u.host}!`, + title: `${formatHandleWithAt(profile)} is 🔴LIVE on ${u.host}!`, uri: linkUrl, }, }; @@ -846,10 +848,10 @@ export const createBlueskySlice: StateCreator< for (; tries < 3; tries++) { try { console.log( - `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`, + `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${encodeURIComponent(formatHandle(profile))}/stream.png`, ); const thumbnailRes = await fetch( - `${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`, + `${u.protocol}//${u.host}/api/playback/${encodeURIComponent(formatHandle(profile))}/stream.png`, ); if (!thumbnailRes.ok) { throw new Error( diff --git a/js/components/src/components/chat/chat-box.tsx b/js/components/src/components/chat/chat-box.tsx index 4bef43e95..66348e2f6 100644 --- a/js/components/src/components/chat/chat-box.tsx +++ b/js/components/src/components/chat/chat-box.tsx @@ -1,6 +1,7 @@ import { createTeleport, createTeleportCommands, + formatHandle, handleSlashCommand, } from "@streamplace/core"; import Graphemer from "graphemer"; @@ -141,8 +142,11 @@ export function ChatBox({ return chat.reduce((acc, msg) => { // our fake system user "did" if (msg.author.did === "did:sys:system") return acc; - if (acc.has(msg.author.handle)) return acc; - acc.set(msg.author.handle, msg.chatProfile); + // without a verified handle (formatHandle falls back to the DID) an + // author can't be @-mentioned + const handle = formatHandle(msg.author); + if (handle === msg.author.did || acc.has(handle)) return acc; + acc.set(handle, msg.chatProfile); return acc; }, new Map()); }, [chat]); diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 211ad5386..1c653ee95 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -3,7 +3,13 @@ import { Link, Mention, } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; -import { Facet, RichtextSegment, segmentize } from "@streamplace/core"; +import { + Facet, + formatHandle, + formatHandleWithAt, + RichtextSegment, + segmentize, +} from "@streamplace/core"; import { memo, useCallback, useMemo } from "react"; import { Linking, Platform, Pressable, View } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; @@ -17,7 +23,6 @@ import { selectableText, } from "../../lib/theme/atoms"; import { tabularNums, textAlphas } from "../../lib/theme/tokens"; -import { formatHandleWithAt } from "../../utils/format-handle"; import { DropdownMenu, DropdownMenuContent, @@ -37,8 +42,8 @@ import { Text } from "../ui/text"; import { Badge, BadgeDisplayRow } from "./badge"; import { ProfileCardContent, - UserProfileCard, useProfileCardData, + UserProfileCard, } from "./user-profile-card"; import { VerifiedBadge } from "./verified-badge"; @@ -129,7 +134,7 @@ const renderSegment = ( Linking.openURL( profileUrl ? profileUrl({ did: mtnFtr.did }) - : `https://bsky.app/profile/${mtnFtr.did || ""}`, + : `https://bsky.app/profile/${encodeURIComponent(mtnFtr.did || "")}`, ) } > @@ -335,7 +340,11 @@ const AvatarMessageBody = ({ item }: { item: ChatMessageViewHydrated }) => { { gap: 12, minWidth: 0, maxWidth: "100%", paddingVertical: 4 }, ]} > - + = ({ // filter by handle or stream title return liveUsers.filter( (stream) => - stream.author?.handle?.toLowerCase().includes(query) || + formatHandle(stream.author).toLowerCase().includes(query) || stream.record.title?.toString().toLowerCase().includes(query), ); }, [liveUsers, searchQuery]); @@ -60,14 +61,14 @@ export const TeleportModal: React.FC = ({ }; const handleSubmit = () => { - if (!selectedStream?.author?.handle) return; + if (!selectedStream) return; const countdown = parseInt(countdownSeconds, 10); if (isNaN(countdown) || countdown < 5 || countdown > 300) { return; } - onSubmit(selectedStream.author.handle, countdown); + onSubmit(formatHandle(selectedStream.author), countdown); handleCancel(); }; @@ -167,7 +168,7 @@ export const TeleportModal: React.FC = ({ source={{ uri: "/api/playback/" + - stream.author.did + + encodeURIComponent(stream.author.did) + "/stream.jpg", }} style={{ @@ -261,7 +262,7 @@ export const TeleportModal: React.FC = ({ {/* Text */} - {stream.author?.handle} + {formatHandle(stream.author)} {stream.record.title ? ( - @{author.handle} + {formatHandleWithAt(author)} {Platform.OS === "web" && ( diff --git a/js/web/src/components/stream/chat-input.tsx b/js/web/src/components/stream/chat-input.tsx index b683979c8..f5a5545ce 100644 --- a/js/web/src/components/stream/chat-input.tsx +++ b/js/web/src/components/stream/chat-input.tsx @@ -1,5 +1,7 @@ import { createTeleportCommands, + formatHandle, + formatHandleWithAt, handleSlashCommand, teleportErrorIdentifier, type LivestreamStore, @@ -74,6 +76,7 @@ function getMentionSuggestions( const did = msg.author.did; if (seen.has(did)) continue; seen.add(did); + if (!msg.author.handle || formatHandle(msg.author) === did) continue; const profile = authors[did]; items.push({ did, @@ -94,7 +97,7 @@ function createMentionSuggestion() { const q = query.toLowerCase(); return mentionDataRef.allItems.filter( (item) => - item.handle.toLowerCase().includes(q) || + formatHandle(item).toLowerCase().includes(q) || item.displayName.toLowerCase().includes(q), ); }, @@ -109,8 +112,8 @@ function createMentionSuggestion() { items: props.items, command: (item: MentionItem) => { props.command({ - id: item.handle, - label: `@${item.handle}`, + id: formatHandle(item), + label: formatHandleWithAt(item), did: item.did, handle: item.handle, displayName: item.displayName, @@ -144,8 +147,8 @@ function createMentionSuggestion() { items: props.items, command: (item: MentionItem) => { props.command({ - id: item.handle, - label: `@${item.handle}`, + id: formatHandle(item), + label: formatHandleWithAt(item), did: item.did, handle: item.handle, displayName: item.displayName, @@ -526,7 +529,7 @@ export function ChatInput({ countdownSeconds: number, ): Promise => { const result = await handleSlashCommand( - `/teleport @${handle} ${countdownSeconds}`, + `/teleport ${handle} ${countdownSeconds}`, commands, ); if (!result.handled || result.error) { @@ -581,8 +584,7 @@ export function ChatInput({ {t("chat-replying-to", { - handle: - replyToMessage.author.handle || replyToMessage.author.did, + handle: formatHandle(replyToMessage.author), })} {" · "} diff --git a/js/web/src/components/stream/chat-message-menu.tsx b/js/web/src/components/stream/chat-message-menu.tsx index cab1e1be4..b0db7a8dd 100644 --- a/js/web/src/components/stream/chat-message-menu.tsx +++ b/js/web/src/components/stream/chat-message-menu.tsx @@ -3,7 +3,7 @@ import { useModerationActions } from "@/hooks/use-moderation-actions"; import { useToast } from "@/hooks/use-toast"; import { useSession } from "@/lib/session"; import type { LivestreamStore } from "@streamplace/core"; -import { reduceChat } from "@streamplace/core"; +import { formatHandle, reduceChat } from "@streamplace/core"; import { EyeOff, Flag, @@ -106,22 +106,14 @@ export function ChatMessageMenu({ try { await blockUser(authorDid, streamerDid); toast.show( - t("moderation-user-blocked", { handle: message.author.handle }), + t("moderation-user-blocked", { handle: formatHandle(message.author) }), "", { duration: 3000 }, ); } catch (e) { fail(t("moderation-block-failed"), e); } - }, [ - authorDid, - blockUser, - fail, - message.author.handle, - streamerDid, - t, - toast, - ]); + }, [authorDid, blockUser, fail, message.author, streamerDid, t, toast]); const handlePin = useCallback( async (expiresAt?: string) => { diff --git a/js/web/src/components/stream/chat-panel.tsx b/js/web/src/components/stream/chat-panel.tsx index 4787a1ba5..45d1a372c 100644 --- a/js/web/src/components/stream/chat-panel.tsx +++ b/js/web/src/components/stream/chat-panel.tsx @@ -8,6 +8,8 @@ import { chatMessageOpacity, formatBadgeIssuer, formatBadgeLabel, + formatHandle, + formatHandleWithAt, segmentize, type Facet, type FacetFeature, @@ -483,7 +485,7 @@ function RichTextMessage({ key={i} href={ mtnFtr.did - ? `https://bsky.app/profile/${mtnFtr.did}` + ? `https://bsky.app/profile/${encodeURIComponent(mtnFtr.did)}` : undefined } target="_blank" @@ -522,7 +524,7 @@ function UserHandle({ badges?: place.stream.badge.defs.BadgeView[]; issuerProfiles: ReturnType; }) { - const name = author.displayName || author.handle || author.did; + const name = author.displayName || formatHandle(author); return ( @@ -573,8 +575,8 @@ function ProfileCardContent({ const backButtonRef = useRef(null); const detailWasOpenRef = useRef(false); const lastSelectedBadgeIndexRef = useRef(0); - const name = author.displayName || author.handle || author.did; - const handle = author.handle; + const handle = formatHandle(author); + const name = author.displayName || handle; useLayoutEffect(() => { const detailIsOpen = selectedBadgeIndex !== null; @@ -657,19 +659,17 @@ function ProfileCardContent({ {name} )} - {handle &&
@{handle}
} +
{formatHandleWithAt(author)}
- {handle && ( - - - - )} + + + {badges.length > 0 && ( @@ -722,7 +722,7 @@ function ReplyBanner({ if (parent.$type !== "place.stream.chat.defs#messageView") return null; const msg = parent as ChatMessageViewHydrated; const text = msg.record?.text || ""; - const handle = msg.author?.handle || "..."; + const handle = msg.author ? formatHandleWithAt(msg.author) : "@..."; const facets = msg.record?.facets as | ChatMessageViewHydrated["record"]["facets"] | undefined; @@ -730,7 +730,7 @@ function ReplyBanner({ return (
- @{handle} + {handle} diff --git a/js/web/src/components/stream/chat-sidebar.tsx b/js/web/src/components/stream/chat-sidebar.tsx index 9e6ce4d13..ff85f6d1a 100644 --- a/js/web/src/components/stream/chat-sidebar.tsx +++ b/js/web/src/components/stream/chat-sidebar.tsx @@ -1,4 +1,5 @@ import type { LivestreamStore } from "@streamplace/core"; +import { formatHandle } from "@streamplace/core"; import { ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useStore } from "zustand"; @@ -33,7 +34,7 @@ export function ChatSidebar({
Stream Chat
{ - const wsUrl = `${getStreamplaceUrl()}/api/websocket/${user}`; + const wsUrl = `${getStreamplaceUrl()}/api/websocket/${encodeURIComponent(user)}`; let ws: WebSocket | null = null; let reconnectTimeout: ReturnType | null = null; diff --git a/js/web/src/components/stream/mention-list.tsx b/js/web/src/components/stream/mention-list.tsx index 725eba485..04c07b1c8 100644 --- a/js/web/src/components/stream/mention-list.tsx +++ b/js/web/src/components/stream/mention-list.tsx @@ -1,3 +1,4 @@ +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { forwardRef, useCallback, @@ -144,11 +145,11 @@ export const MentionList = forwardRef( className="truncate text-sm font-medium" style={colorStr ? { color: colorStr } : undefined} > - {item.displayName || item.handle} + {item.displayName || formatHandle(item)} - {item.displayName && item.handle && ( + {item.displayName && ( - @{item.handle} + {formatHandleWithAt(item)} )} diff --git a/js/web/src/components/stream/offline-recommendation.test.ts b/js/web/src/components/stream/offline-recommendation.test.ts index dfbb87a41..7e82961d8 100644 --- a/js/web/src/components/stream/offline-recommendation.test.ts +++ b/js/web/src/components/stream/offline-recommendation.test.ts @@ -2,21 +2,21 @@ import { describe, expect, it } from "vitest"; import { validateOfflineRecommendation } from "./offline-recommendation"; describe("validateOfflineRecommendation", () => { - it("returns a recommendation only after its profile resolves", () => { + it("keeps a resolved recommendation without a verified handle", () => { expect( validateOfflineRecommendation( { did: "did:plc:recommended", source: "streamer" }, - { did: "did:plc:recommended", handle: "friend.example" }, + { did: "did:plc:recommended", handle: "" }, "did:plc:offline", ), ).toEqual({ did: "did:plc:recommended", - handle: "friend.example", + handle: "", source: "streamer", }); }); - it("rejects unresolved and self recommendations", () => { + it("rejects unresolved, mismatched, and self recommendations", () => { expect( validateOfflineRecommendation( { did: "did:plc:recommended", source: "streamer" }, @@ -31,5 +31,12 @@ describe("validateOfflineRecommendation", () => { "did:plc:offline", ), ).toBeNull(); + expect( + validateOfflineRecommendation( + { did: "did:plc:recommended", source: "streamer" }, + { did: "did:plc:other", handle: "other.example" }, + "did:plc:offline", + ), + ).toBeNull(); }); }); diff --git a/js/web/src/components/stream/offline-recommendation.ts b/js/web/src/components/stream/offline-recommendation.ts index 57b4d49fb..3d02bffb4 100644 --- a/js/web/src/components/stream/offline-recommendation.ts +++ b/js/web/src/components/stream/offline-recommendation.ts @@ -12,7 +12,7 @@ export function validateOfflineRecommendation( profile: { did: string; handle: string } | null | undefined, offlineDid: string | undefined, ): ResolvedOfflineRecommendation | null { - if (!recommendation || !profile?.handle) return null; + if (!recommendation || !profile?.did) return null; if (recommendation.did !== profile.did) return null; if (recommendation.did === offlineDid) return null; return { ...recommendation, handle: profile.handle }; diff --git a/js/web/src/components/stream/player-offline.tsx b/js/web/src/components/stream/player-offline.tsx index b306ffd96..82cf3451b 100644 --- a/js/web/src/components/stream/player-offline.tsx +++ b/js/web/src/components/stream/player-offline.tsx @@ -1,4 +1,5 @@ import type { LivestreamStore } from "@streamplace/core"; +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { Link } from "@tanstack/react-router"; import { ChevronRight } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -46,7 +47,9 @@ export function PlayerOffline({ const detailed = profile?.did ? avatars[profile.did] : null; const banner = detailed?.banner; const avatar = detailed?.avatar || profile?.avatar; - const handle = profile?.handle || user; + const authorLabel = formatHandleWithAt( + profile ?? { did: user, handle: user.startsWith("did:") ? "" : user }, + ); const [recommendation, setRecommendation] = useState<{ did: string; @@ -134,12 +137,14 @@ export function PlayerOffline({

{validatedRecommendation ? ( <> - Looks like @{handle} is + Looks like{" "} + {authorLabel} is offline, but they recommend checking out: ) : ( <> - Looks like @{handle} is + Looks like{" "} + {authorLabel} is offline right now. Check back later. )} @@ -151,7 +156,9 @@ export function PlayerOffline({ alt="" className="h-5 w-5 rounded-full border border-white/20 object-cover" /> -

@{handle}

+

+ {authorLabel} +

)}
@@ -164,13 +171,13 @@ export function PlayerOffline({ {validatedRecommendation ? (

Looks like{" "} - @{handle} is + {authorLabel} is offline, but they recommend checking out:

) : (

- Looks like @{handle} is - offline right now. Check back later. + Looks like {authorLabel}{" "} + is offline right now. Check back later.

)} {avatar && ( @@ -180,7 +187,9 @@ export function PlayerOffline({ alt="" className="h-5 w-5 rounded-full border border-white/20 object-cover" /> -

@{handle}

+

+ {authorLabel} +

)} @@ -213,6 +222,7 @@ function RecommendationEmbed({ handle: string; avatar?: string; }) { + const user = formatHandle({ did, handle }); const { playlistUrl, thumbnailUrl } = useMemo(() => { const base = getStreamplaceUrl(); return { @@ -238,8 +248,8 @@ function RecommendationEmbed({ play/pause. */}
@@ -253,7 +263,10 @@ function RecommendationEmbed({
)}

- You're going to @{handle} + You're going to{" "} + + {formatHandleWithAt({ did, handle })} +

diff --git a/js/web/src/components/stream/stream-card.tsx b/js/web/src/components/stream/stream-card.tsx index 5d9ea899f..a7633b67d 100644 --- a/js/web/src/components/stream/stream-card.tsx +++ b/js/web/src/components/stream/stream-card.tsx @@ -3,6 +3,7 @@ // expo-image, and React Native). Shows thumbnail, viewer count, // avatar, title, handle, activity label, and tags. Links to /$user. +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { Link } from "@tanstack/react-router"; import { useEffect, @@ -68,12 +69,12 @@ export function StreamCard({ stream, avatarUrl }: StreamCardProps) { const { t } = useTranslation("common"); const streamplaceUrl = useStreamplaceUrl(); const record = stream.record as place.stream.livestream.Main; - const handle = stream.author.handle || stream.author.did; + const user = formatHandle(stream.author); const title = record.title || t("default-stream-title"); const activity = getStreamActivity(record, t); const tags = record.tags ?? []; const viewers = stream.viewerCount?.count; - const user = handle; + const handleWithAt = formatHandleWithAt(stream.author); // Keep a streamer's accent stable even if their handle changes. const borderColor = useMemo( @@ -151,7 +152,7 @@ export function StreamCard({ stream, avatarUrl }: StreamCardProps) { style={{ "--tw-ring-color": borderColor } as CSSProperties} > { @@ -159,7 +160,7 @@ export function StreamCard({ stream, avatarUrl }: StreamCardProps) { }} /> { @@ -194,7 +195,7 @@ export function StreamCard({ stream, avatarUrl }: StreamCardProps) { /> ) : (
- {handle[0]?.toUpperCase() ?? "?"} + {user[0]?.toUpperCase() ?? "?"}
)}
@@ -205,7 +206,7 @@ export function StreamCard({ stream, avatarUrl }: StreamCardProps) { {title}
- @{handle} + {handleWithAt}
{(activity || tags.length > 0) && (
- - {author?.displayName || author?.handle || user} - - {author?.handle && author.displayName && ( + {authorLabel} + {author && author.displayName && ( - @{author.handle} + {formatHandleWithAt(author)} )} {isLive && state.viewers != null && ( @@ -209,7 +210,10 @@ export function StreamInfo({ : t("follow")} )} - + diff --git a/js/web/src/components/video/video-card.tsx b/js/web/src/components/video/video-card.tsx index 335b0c263..c14834cfc 100644 --- a/js/web/src/components/video/video-card.tsx +++ b/js/web/src/components/video/video-card.tsx @@ -3,6 +3,7 @@ // badge, avatar, title, handle, view/like count. Links to // /$user/vod/:tid. +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { Link } from "@tanstack/react-router"; import type { CSSProperties } from "react"; import { useTranslation } from "react-i18next"; @@ -26,7 +27,7 @@ export function VideoCard({ const { t } = useTranslation("common"); const record = video.record as place.stream.video.Main; const author = video.author; - const user = author.handle || author.did; + const user = formatHandle(author); const tid = getTidFromAtUri(video.uri); const title = record.title || t("untitled"); const thumbnailUrl = getVideoThumbnailUrl(record, author.did); @@ -99,7 +100,7 @@ export function VideoCard({ {title}
- @{user} + {formatHandleWithAt(author)}
{(isPositiveCount(viewCount) || isPositiveCount(likeCount)) && (
diff --git a/js/web/src/components/video/vod-watch-header.tsx b/js/web/src/components/video/vod-watch-header.tsx index 04b680dad..6ac82f3bb 100644 --- a/js/web/src/components/video/vod-watch-header.tsx +++ b/js/web/src/components/video/vod-watch-header.tsx @@ -22,6 +22,7 @@ import { useToast } from "@/hooks/use-toast"; import type { VideoView } from "@/hooks/use-video-record"; import { useVodLike } from "@/hooks/use-vod-like"; import { cn } from "@/lib/utils"; +import { formatHandle } from "@streamplace/core"; import { Link } from "@tanstack/react-router"; import { Check, @@ -37,15 +38,10 @@ import { import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { place } from "streamplace"; -import { - buildVodShareLinks, - getAuthorHandle, - shouldCollapseDescription, -} from "./vod-watch"; +import { buildVodShareLinks, shouldCollapseDescription } from "./vod-watch"; type VodWatchHeaderProps = { video: VideoView; - routeUser: string; tid: string; downloading: boolean; onDownload: () => void; @@ -53,7 +49,6 @@ type VodWatchHeaderProps = { export function VodWatchHeader({ video, - routeUser, tid, downloading, onDownload, @@ -67,8 +62,7 @@ export function VodWatchHeader({ const dids = useMemo(() => [video.author.did], [video.author.did]); const profiles = useAvatars(dids); const profile = profiles[video.author.did]; - const authorHandle = getAuthorHandle(video.author, routeUser); - const authorRoute = video.author.handle || video.author.did; + const authorHandle = formatHandle(video.author); const avatar = profile?.avatar ?? video.author.avatar; const showAvatar = avatar && failedAvatar !== avatar; const description = record.description?.trim() ?? ""; @@ -90,7 +84,7 @@ export function VodWatchHeader({ typeof window === "undefined" ? "https://stream.place" : window.location.origin, - routeUser, + authorHandle, tid, ); const like = useVodLike({ @@ -171,7 +165,7 @@ export function VodWatchHeader({
{ - it("uses the handle as the creator's only identifier", () => { - expect( - getAuthorHandle( - { handle: "chicago.at", displayName: "chicago.at" }, - "chicago.at", - ), - ).toBe("chicago.at"); - }); - - it("ignores a distinct display name", () => { - expect( - getAuthorHandle( - { handle: "chicago.at", displayName: "Chicago" }, - "did:plc:chicago", - ), - ).toBe("chicago.at"); - }); - - it("falls back to the route identifier while profile data loads", () => { - expect(getAuthorHandle({ displayName: "Chicago" }, "chicago.at")).toBe( - "chicago.at", - ); - }); -}); - describe("buildVodShareLinks", () => { it("builds canonical, embed, and iframe targets", () => { expect( diff --git a/js/web/src/components/video/vod-watch.ts b/js/web/src/components/video/vod-watch.ts index a7d174903..7d43c8261 100644 --- a/js/web/src/components/video/vod-watch.ts +++ b/js/web/src/components/video/vod-watch.ts @@ -1,10 +1,3 @@ -export function getAuthorHandle( - author: { handle?: string; displayName?: string } | null | undefined, - fallback: string, -): string { - return author?.handle?.trim() || fallback; -} - export function buildVodShareLinks(origin: string, user: string, tid: string) { const base = new URL(origin).origin; const route = `${encodeURIComponent(user)}/video/${encodeURIComponent(tid)}`; diff --git a/js/web/src/hooks/use-live-users.ts b/js/web/src/hooks/use-live-users.ts index e2a33ba05..089c39e54 100644 --- a/js/web/src/hooks/use-live-users.ts +++ b/js/web/src/hooks/use-live-users.ts @@ -24,7 +24,9 @@ export function useLiveUsers() { queryKey: ["liveUsers"], queryFn: async (): Promise => { const agent = await getAgent(); - const result = await agent.client.call(place.stream.live.getLiveUsers); + const result = await agent.client.call(place.stream.live.getLiveUsers, { + limit: 50, + }); return (result.streams ?? []) as LivestreamView[]; }, refetchInterval: REFETCH_INTERVAL, diff --git a/js/web/src/hooks/use-livestream-store.ts b/js/web/src/hooks/use-livestream-store.ts index 3e06abf5f..85f931c48 100644 --- a/js/web/src/hooks/use-livestream-store.ts +++ b/js/web/src/hooks/use-livestream-store.ts @@ -28,7 +28,7 @@ export function useLivestreamStore( useEffect(() => { if (!enabled) return; - const wsUrl = `${getStreamplaceUrl()}/api/websocket/${user}`; + const wsUrl = `${getStreamplaceUrl()}/api/websocket/${encodeURIComponent(user)}`; const { disconnect } = connectLivestreamWebsocket(store, wsUrl, { reconnectDelayMs: 3000, }); diff --git a/js/web/src/routes/$user/index.tsx b/js/web/src/routes/$user/index.tsx index d2de0591f..6277086ed 100644 --- a/js/web/src/routes/$user/index.tsx +++ b/js/web/src/routes/$user/index.tsx @@ -23,6 +23,7 @@ import { useLivestreamStore } from "@/hooks/use-livestream-store"; import { useStreamAvatar } from "@/hooks/use-stream-avatar"; import { useStreamplaceUrl } from "@/lib/store/hooks"; import type { LivestreamStore } from "@streamplace/core"; +import { formatHandle, formatHandleWithAt } from "@streamplace/core"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { ExternalLink, @@ -58,7 +59,9 @@ function StreamPage() { return ( { - const target = teleportProfiles[targetDid]?.handle || targetDid; + const target = formatHandle({ + did: targetDid, + handle: teleportProfiles[targetDid]?.handle, + }); void navigate({ to: "/$user", params: { user: target } }); }, Math.max(0, startsAt - Date.now()), @@ -328,7 +334,8 @@ function MobileStreamBar({ const author = state.livestream?.author; const record = state.livestream?.record; const title = record?.title || user; - const authorLabel = author?.displayName || author?.handle || user; + const authorLabel = + author?.displayName || (author ? formatHandle(author) : user); const node = useStreamplaceUrl(); return ( @@ -350,7 +357,11 @@ function MobileStreamBar({ )}
- {author?.handle ? `@${author.handle}` : authorLabel} + {author + ? formatHandleWithAt(author) + : user.startsWith("did:") + ? user + : `@${user}`}
@@ -384,7 +395,7 @@ function MobileStreamBar({ )} {authorLabel}
- {author?.handle && ( + {author && (
- @{author.handle} + {formatHandleWithAt(author)}
)}
- {author?.handle && ( + {author && (
- {resolvedHandle ? "@" : ""} - {resolvedHandle || streamer || "(empty)"} + {label || "(empty)"}