From 48591ae3ad9aba42874563ebba26d8671c82bad3 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 13 Oct 2026 20:12:28 -0700 Subject: [PATCH] Add shared user search backed by indexed handles --- .maestro/logged-out/user-search.yaml | 78 +++++++ js/app/README.md | 12 + js/app/components/empty-state.tsx | 3 + js/app/components/sidebar/sidebar-overlay.tsx | 1 + js/app/src/linking-config.ts | 6 + js/app/src/navigation-types.ts | 6 + js/app/src/screens/search.tsx | 209 ++++++++++++++++++ js/app/src/shell.tsx | 109 ++++++--- js/e2e-web/flows/user-search.spec.ts | 131 +++++++++++ js/i18n/locales/en-US/common.ftl | 5 + js/i18n/public/locales/en-US/common.json | 5 + js/web/public/locales/en-US/common.json | 5 + pkg/model/repo.go | 15 +- pkg/model/repo_search_test.go | 48 ++++ 14 files changed, 596 insertions(+), 37 deletions(-) create mode 100644 .maestro/logged-out/user-search.yaml create mode 100644 js/app/src/screens/search.tsx create mode 100644 js/e2e-web/flows/user-search.spec.ts create mode 100644 pkg/model/repo_search_test.go diff --git a/.maestro/logged-out/user-search.yaml b/.maestro/logged-out/user-search.yaml new file mode 100644 index 000000000..e7756b05b --- /dev/null +++ b/.maestro/logged-out/user-search.yaml @@ -0,0 +1,78 @@ +appId: ${APP_ID} +--- +# Searches the harness node's real database for the harness account by a +# mixed-case prefix of its handle ("sp-1a2b3c4d." -> "SP-1A2B3C4D"). +# The result is matched by its testID pattern rather than the account's DID, +# which the runner doesn't pass to Maestro. +- evalScript: ${output.prefix = ACCOUNT_HANDLE.split('.')[0].toUpperCase()} +- launchApp +- extendedWaitUntil: + visible: + text: "Live now" + timeout: 30000 +- tapOn: + text: "Search" +- extendedWaitUntil: + visible: + id: "search-hint" + timeout: 10000 +- takeScreenshot: user-search-initial + +# a query nobody matches +- tapOn: + id: "search-input" +- inputText: "nobody-matches-this" +- tapOn: + id: "search-submit" +- extendedWaitUntil: + visible: + id: "search-no-results" + timeout: 15000 +- assertNotVisible: + id: "search-result-.*" +- takeScreenshot: user-search-no-results + +# case, a leading @ and trailing whitespace don't matter +- tapOn: + id: "search-input" +- eraseText: 100 +- inputText: "@${output.prefix} " +- tapOn: + id: "search-submit" +- extendedWaitUntil: + visible: + id: "search-result-.*" + timeout: 15000 +- assertVisible: + text: ".*${ACCOUNT_HANDLE}.*" +- assertNotVisible: + id: "search-no-results" +- takeScreenshot: user-search-result + +# emptying the box drops the stale result +- tapOn: + id: "search-input" +- eraseText: 100 +- extendedWaitUntil: + visible: + id: "search-hint" + timeout: 10000 +- assertNotVisible: + id: "search-result-.*" + +# the keyboard submits too +- inputText: "${output.prefix}" +- pressKey: Enter +- extendedWaitUntil: + visible: + id: "search-result-.*" + timeout: 15000 + +# selecting the account opens its stream +- tapOn: + id: "search-result-.*" +- extendedWaitUntil: + visible: + text: ".*e2e test stream.*" + timeout: 30000 +- takeScreenshot: user-search-stream diff --git a/js/app/README.md b/js/app/README.md index 253e4a09c..128460ae6 100644 --- a/js/app/README.md +++ b/js/app/README.md @@ -4,6 +4,18 @@ Expo (SDK 55) app for iOS/Android. Native projects (`ios/`, `android/`) are gitignored and generated via `expo prebuild`; shared UI lives in `@streamplace/components` and logic in `@streamplace/core`. +## User search + +Search is available without signing in from the native Search tab or the web +sidebar at `/search`. Enter the beginning of a handle and press Search (or the +keyboard's search/Enter key), then select a result to open that user's stream. +Matching ignores case, surrounding whitespace, and a leading `@`. + +The shared screen uses `place.stream.live.searchActorsTypeahead` to search the +current node's indexed users in its database. It shows up to 20 active accounts, +ordered by handle; it does not search display names, videos, or the wider +Bluesky network. + ## Daily loop (iOS simulator) Three commands, first time only for the middle one: diff --git a/js/app/components/empty-state.tsx b/js/app/components/empty-state.tsx index 242bf7a9f..56aaa25f4 100644 --- a/js/app/components/empty-state.tsx +++ b/js/app/components/empty-state.tsx @@ -45,14 +45,17 @@ export function EmptyState({ title, subtitle, action, + testID, }: { illustration: ReactNode; title: string; subtitle?: string; action?: ReactNode; + testID?: string; }) { return ( ; VideosTab: NavigatorScreenParams; + SearchTab: NavigatorScreenParams; GoLiveTab: undefined; SettingsTab: NavigatorScreenParams; }; diff --git a/js/app/src/screens/search.tsx b/js/app/src/screens/search.tsx new file mode 100644 index 000000000..d532dde1c --- /dev/null +++ b/js/app/src/screens/search.tsx @@ -0,0 +1,209 @@ +import { Avatar, Button, Input, Text, useTheme } from "@streamplace/components"; +import { + borderRadius, + breakpoints, + spacing, + touchTargets, +} from "@streamplace/components/src/lib/theme/tokens"; +import { usePossiblyUnauthedPDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; +import AQLink from "components/aqlink"; +import { EmptyState, EmptyStateTile } from "components/empty-state"; +import { AlertCircle, Search } from "lucide-react-native"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, Platform, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { place } from "streamplace"; + +const RESULT_LIMIT = 20; + +type SearchState = + | { status: "idle" } + | { status: "loading" } + | { status: "error" } + | { + status: "done"; + query: string; + actors: place.stream.live.searchActorsTypeahead.Actor[]; + }; + +// The server matches the start of a handle case-insensitively; a leading "@" +// is how people write handles, not part of one. +function normalizeQuery(text: string): string { + return text.trim().replace(/^@/, ""); +} + +export default function SearchScreen() { + const { t } = useTranslation("common"); + const { theme } = useTheme(); + const agent = usePossiblyUnauthedPDSAgent(); + const safeAreaInsets = useSafeAreaInsets(); + const [text, setText] = useState(""); + const [state, setState] = useState({ status: "idle" }); + // Bumped on edits, searches, node changes, and unmount so only the latest + // response is applied. + const requestId = useRef(0); + + useEffect(() => { + setState({ status: "idle" }); + return () => { + requestId.current++; + }; + }, [agent]); + + const onChangeText = (value: string) => { + setText(value); + requestId.current++; + setState({ status: "idle" }); + }; + + const submit = async () => { + const q = normalizeQuery(text); + if (!q) return; + const id = ++requestId.current; + if (!agent) { + setState({ status: "error" }); + return; + } + setState({ status: "loading" }); + try { + const res = await agent.client.call( + place.stream.live.searchActorsTypeahead, + { q, limit: RESULT_LIMIT }, + ); + if (id === requestId.current) { + setState({ status: "done", query: q, actors: res.actors }); + } + } catch (error) { + console.error("Failed to search users:", error); + if (id === requestId.current) setState({ status: "error" }); + } + }; + + return ( + + + + + + + + + {state.status === "idle" && ( + } + title={t("search-users-hint")} + /> + )} + + {state.status === "loading" && ( + + + + )} + + {state.status === "error" && ( + + } + title={t("search-failed")} + action={ + + } + /> + )} + + {state.status === "done" && state.actors.length === 0 && ( + } + title={t("no-results-for", { query: state.query })} + /> + )} + + {state.status === "done" && state.actors.length > 0 && ( + + {state.actors.map((actor) => ( + + + + {`@${actor.handle}`} + + + ))} + + )} + + ); +} diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx index 1fb377b34..aad26e77e 100644 --- a/js/app/src/shell.tsx +++ b/js/app/src/shell.tsx @@ -33,7 +33,7 @@ import UploadProgressIndicator from "components/upload/upload-progress-indicator import { useBlueskyNotifications } from "hooks/useBlueskyNotifications"; import usePlatform from "hooks/usePlatform"; import { useIsLargeScreen, useSidebarControl } from "hooks/useSidebarControl"; -import { Clapperboard, Cog, Home, Video } from "lucide-react-native"; +import { Clapperboard, Cog, Home, Search, Video } from "lucide-react-native"; import { useEffect, useState } from "react"; import { Platform, Pressable, StatusBar, View } from "react-native"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; @@ -41,6 +41,7 @@ import { SFSymbols7_0 } from "sf-symbols-typescript"; import { lazyScreen } from "src/lazy-screen"; import "src/navigation-types"; import HomeScreen from "src/screens/home"; + import { useStore } from "store"; import { useHydrated, @@ -155,6 +156,7 @@ const PopoutMultistream = lazyScreen( const PopoutStreamMonitor = lazyScreen( () => import("src/screens/popout-stream-monitor"), ); +const SearchScreen = lazyScreen(() => import("src/screens/search")); const SupportScreen = lazyScreen(() => import("src/screens/support")); const UploadScreen = lazyScreen(() => import("src/screens/upload")); const UploadVideoScreen = lazyScreen(() => @@ -182,6 +184,7 @@ const Tab = createBottomTabNavigator(); const RootStack = createNativeStackNavigator(); const HomeStack = createNativeStackNavigator(); const VideosStack = createNativeStackNavigator(); +const SearchStack = createNativeStackNavigator(); const SettingsStack = createNativeStackNavigator(); function useBaseScreenOptions() { @@ -364,43 +367,48 @@ function HomeNavigator() { ); } -// Videos stack navigator (global + per-user VOD listings). Unlike the pushed -// Home screens, these are tab roots, so they always carry their own header. -function VideosNavigator() { +// Shared chrome for tab-root stacks (Videos, Search). Unlike the pushed Home +// screens, these are tab roots, so they always carry their own header. +function useTabRootScreenOptions(): NativeStackNavigationOptions { const baseScreenOptions = useBaseScreenOptions(); const isNative = Platform.OS !== "web"; - return ( - ( - - ), - headerRight: () => ( - - - - + return { + ...baseScreenOptions, + headerLeft: isNative + ? undefined + : ({ canGoBack }: NativeStackHeaderBackProps) => ( + ), - ...(Platform.OS === "ios" && { - unstable_headerRightItems: () => [ - { - type: "custom", - hidesSharedBackground: true, - element: ( - - - - - ), - }, - ], - }), - }} - > + headerRight: () => ( + + + + + ), + ...(Platform.OS === "ios" && { + unstable_headerRightItems: () => [ + { + type: "custom", + hidesSharedBackground: true, + element: ( + + + + + ), + }, + ], + }), + }; +} + +// Videos stack navigator (global + per-user VOD listings) +function VideosNavigator() { + const screenOptions = useTabRootScreenOptions(); + + return ( + + + + ); +} + // Settings stack navigator function SettingsNavigator() { const baseScreenOptions = useBaseScreenOptions(); @@ -545,11 +568,13 @@ function SettingsNavigator() { const IOS_ICONS: Record = { Home: "house.fill", Videos: "play.rectangle.fill", + Search: "magnifyingglass", Settings: "gearshape.fill", }; const ANDROID_ICONS = { Home: "home", Videos: "video_library", + Search: "search", Settings: "settings", }; @@ -637,6 +662,22 @@ function TabNavigator() { }), }} /> + ( + + ), + }), + }} + /> {!isNative && ( " -> "SP-1A2B3C4D": a mixed-case prefix of the handle. +const PREFIX = HANDLE.split(".")[0].toUpperCase(); +const SEARCH_URL = "**/xrpc/place.stream.live.searchActorsTypeahead*"; + +async function search(page: Page, query: string) { + const input = page.getByTestId("search-input"); + await input.fill(query); + await page.getByTestId("search-submit").click(); +} + +test("user-search: find an account by handle prefix and open it", async ({ + page, +}) => { + await page.goto("/"); + await page.getByRole("link", { name: "Search", exact: true }).first().click(); + await expect(page).toHaveURL(/\/search$/); + await expect(page.getByTestId("search-hint")).toBeVisible(); + + await search(page, `nobody-${Date.now()}`); + await expect(page.getByTestId("search-no-results")).toBeVisible(); + await expect(page.getByTestId(`search-result-${DID}`)).toBeHidden(); + + // case, leading @ and surrounding whitespace don't matter + await search(page, ` @${PREFIX} `); + const result = page.getByTestId(`search-result-${DID}`); + await expect(result).toBeVisible(); + await expect(result).toContainText(HANDLE); + await expect(page.getByTestId("search-no-results")).toBeHidden(); + + // emptying the box drops the stale result, whatever it was searching for + await page.getByTestId("search-input").fill(""); + await expect(result).toBeHidden(); + await expect(page.getByTestId("search-hint")).toBeVisible(); + + // the keyboard submits too + await page.getByTestId("search-input").fill(PREFIX); + await page.getByTestId("search-input").press("Enter"); + await expect(result).toBeVisible(); + + // selecting the account opens its stream + await result.click(); + await expect + .poll(() => decodeURIComponent(new URL(page.url()).pathname)) + .toBe(`/${HANDLE}`); + await expect( + page.getByText("Now streaming - e2e test stream").first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.locator("video").first()).toBeVisible(); +}); + +test("user-search: narrow viewport", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await page.goto("/search"); + await expect(page.getByTestId("search-hint")).toBeVisible(); + + await search(page, PREFIX); + const result = page.getByTestId(`search-result-${DID}`); + await expect(result).toBeVisible(); + await expect(result).toContainText(HANDLE); + const box = await result.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(320); +}); + +test("user-search: a failed request shows an error and can be retried", async ({ + page, +}) => { + await page.route(SEARCH_URL, (route) => + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "InternalServerError" }), + }), + ); + await page.goto("/search"); + await search(page, PREFIX); + await expect(page.getByTestId("search-error")).toBeVisible(); + await expect(page.getByTestId(`search-result-${DID}`)).toBeHidden(); + + await page.unroute(SEARCH_URL); + await page.getByTestId("search-submit").click(); + await expect(page.getByTestId(`search-result-${DID}`)).toBeVisible(); + await expect(page.getByTestId("search-error")).toBeHidden(); +}); + +test("user-search: a slow earlier response doesn't replace a newer search", async ({ + page, +}) => { + const slowQuery = PREFIX; + let release!: () => void; + const released = new Promise((resolve) => (release = resolve)); + let slowSeen!: () => void; + const slowRequested = new Promise((resolve) => (slowSeen = resolve)); + await page.route(SEARCH_URL, async (route) => { + const q = new URL(route.request().url()).searchParams.get("q"); + if (q?.toUpperCase() === slowQuery) { + slowSeen(); + await released; + } + await route.continue(); + }); + + await page.goto("/search"); + await search(page, slowQuery); + await slowRequested; + await expect(page.getByTestId("search-loading")).toBeVisible(); + + await search(page, `nobody-${Date.now()}`); + await expect(page.getByTestId("search-no-results")).toBeVisible(); + + // let the superseded request finish, then give its result time to (not) land + const slowResponse = page.waitForResponse( + (response) => + new URL(response.url()).searchParams.get("q")?.toUpperCase() === + slowQuery, + ); + release(); + await slowResponse; + await page.waitForTimeout(500); + await expect(page.getByTestId(`search-result-${DID}`)).toBeHidden(); + await expect(page.getByTestId("search-no-results")).toBeVisible(); +}); diff --git a/js/i18n/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl index 89cc2b32a..d6f402d90 100644 --- a/js/i18n/locales/en-US/common.ftl +++ b/js/i18n/locales/en-US/common.ftl @@ -312,6 +312,11 @@ could-not-load-videos = Couldn't load videos: { $error } ## Search Page no-results-for = No results for "{ $query }" find-streamers = Find streamers by name or handle +search = Search +search-users-label = Search users by handle +search-users-placeholder = Start of a handle, e.g. alice +search-users-hint = Find people by the start of their handle +search-failed = Search failed ## Error Boundary something-went-wrong = Something went wrong diff --git a/js/i18n/public/locales/en-US/common.json b/js/i18n/public/locales/en-US/common.json index 333712aa8..c1aec2ef1 100644 --- a/js/i18n/public/locales/en-US/common.json +++ b/js/i18n/public/locales/en-US/common.json @@ -246,6 +246,11 @@ "could-not-load-videos": "Couldn't load videos: {$error}", "no-results-for": "No results for \"{$query}\"", "find-streamers": "Find streamers by name or handle", + "search": "Search", + "search-users-label": "Search users by handle", + "search-users-placeholder": "Start of a handle, e.g. alice", + "search-users-hint": "Find people by the start of their handle", + "search-failed": "Search failed", "something-went-wrong": "Something went wrong", "unexpected-error": "An unexpected error occurred.", "upload-add-tag": "Add tag, press Enter", diff --git a/js/web/public/locales/en-US/common.json b/js/web/public/locales/en-US/common.json index 333712aa8..c1aec2ef1 100644 --- a/js/web/public/locales/en-US/common.json +++ b/js/web/public/locales/en-US/common.json @@ -246,6 +246,11 @@ "could-not-load-videos": "Couldn't load videos: {$error}", "no-results-for": "No results for \"{$query}\"", "find-streamers": "Find streamers by name or handle", + "search": "Search", + "search-users-label": "Search users by handle", + "search-users-placeholder": "Start of a handle, e.g. alice", + "search-users-hint": "Find people by the start of their handle", + "search-failed": "Search failed", "something-went-wrong": "Something went wrong", "unexpected-error": "An unexpected error occurred.", "upload-add-tag": "Add tag, press Enter", diff --git a/pkg/model/repo.go b/pkg/model/repo.go index 95ce0648e..2539e1c12 100644 --- a/pkg/model/repo.go +++ b/pkg/model/repo.go @@ -3,6 +3,7 @@ package model import ( "context" "errors" + "strings" "gorm.io/gorm" ) @@ -232,11 +233,19 @@ func (m *DBModel) TerminalRepoDIDs(ctx context.Context) ([]string, error) { return dids, nil } +var handleSearchEscaper = strings.NewReplacer("!", "!!", "%", "!%", "_", "!_") + func (m *DBModel) SearchReposByHandle(query string, limit int) ([]Repo, error) { + query = strings.TrimPrefix(strings.TrimSpace(query), "@") + if query == "" { + return nil, nil + } var repos []Repo - // Search for repos where handle starts with the query (case-insensitive) - // Use LIKE with LOWER for sqlite/postgres compatibility - res := m.DB.Where("LOWER(handle) LIKE LOWER(?)", query+"%").Limit(limit).Find(&repos) + res := m.DB. + Where("LOWER(handle) LIKE LOWER(?) ESCAPE '!'", handleSearchEscaper.Replace(query)+"%"). + Where("status = ? OR status IS NULL", RepoStatusOK). + Order("LOWER(handle), did"). + Limit(limit).Find(&repos) if res.Error != nil { return nil, res.Error } diff --git a/pkg/model/repo_search_test.go b/pkg/model/repo_search_test.go new file mode 100644 index 000000000..ae30c5324 --- /dev/null +++ b/pkg/model/repo_search_test.go @@ -0,0 +1,48 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSearchReposByHandle(t *testing.T) { + db := indexedTestDB(t) + for _, repo := range []Repo{ + {DID: "did:plc:a", Handle: "alice.example"}, + {DID: "did:plc:z", Handle: "alice-z.example"}, + {DID: "did:plc:b", Handle: "bob.example"}, + {DID: "did:plc:suffix", Handle: "not-alice.example"}, + {DID: "did:plc:inactive", Handle: "alice-inactive.example", Status: RepoStatusDeactivated}, + {DID: "did:plc:hidden", Handle: "alice-hidden.example", Status: RepoStatusTakendown}, + } { + require.NoError(t, db.UpdateRepo(&repo)) + } + + for _, tc := range []struct { + name string + query string + limit int + want []string + }{ + {name: "case insensitive prefix", query: "ALICE", limit: 20, want: []string{"alice-z.example", "alice.example"}}, + {name: "at handle and whitespace", query: " @Alice ", limit: 20, want: []string{"alice-z.example", "alice.example"}}, + {name: "stable limit", query: "alice", limit: 1, want: []string{"alice-z.example"}}, + {name: "no matches", query: "nobody", limit: 20}, + {name: "empty", query: "", limit: 20}, + {name: "blank", query: " @ ", limit: 20}, + {name: "literal percent", query: "%", limit: 20}, + {name: "literal underscore", query: "alic_", limit: 20}, + {name: "literal escape", query: "alice!", limit: 20}, + } { + t.Run(tc.name, func(t *testing.T) { + repos, err := db.SearchReposByHandle(tc.query, tc.limit) + require.NoError(t, err) + var handles []string + for _, repo := range repos { + handles = append(handles, repo.Handle) + } + require.Equal(t, tc.want, handles) + }) + } +} -- knotmirror 0.0.1