diff --git a/src/components/profile-by-did.tsx b/src/components/profile-by-did.tsx
index 14007a26..363a2f15 100644
--- a/src/components/profile-by-did.tsx
+++ b/src/components/profile-by-did.tsx
@@ -1,44 +1,10 @@
-import type { AppBskyActorDefs } from '@atproto/api';
-import type { mastodon } from 'masto';
-
import { useProfile } from '../data/profiles';
+import { profileToAccount } from '../render/profile-view-map';
import AccountBlock from './account-block';
import Loader from './loader';
import ProfileModerationGate from './profile-moderation-gate';
-function profileToAccount(
- profile:
- | AppBskyActorDefs.ProfileView
- | AppBskyActorDefs.ProfileViewDetailed,
-): mastodon.v1.Account {
- const detailed =
- profile as AppBskyActorDefs.ProfileViewDetailed;
- return {
- id: profile.did,
- acct: profile.handle,
- username: profile.handle.split('.')[0] ?? profile.handle,
- displayName: profile.displayName || profile.handle,
- avatar: profile.avatar || '',
- avatarStatic: profile.avatar || '',
- header: detailed.banner || '',
- headerStatic: detailed.banner || '',
- locked: false,
- bot: false,
- group: false,
- createdAt: profile.indexedAt || new Date().toISOString(),
- note: profile.description || '',
- url: `https://bsky.app/profile/${profile.handle}`,
- followersCount: detailed.followersCount ?? 0,
- followingCount: detailed.followsCount ?? 0,
- statusesCount: detailed.postsCount ?? 0,
- lastStatusAt: profile.indexedAt || null,
- emojis: [],
- fields: [],
- roles: [],
- } as mastodon.v1.Account;
-}
-
export interface ProfileByDidProps {
did: string;
instance?: string;
diff --git a/src/components/profile-moderation-gate.tsx b/src/components/profile-moderation-gate.tsx
index 8a62eeb7..6314bd1c 100644
--- a/src/components/profile-moderation-gate.tsx
+++ b/src/components/profile-moderation-gate.tsx
@@ -5,11 +5,13 @@ import { useState } from 'react';
import { useProfileModeration } from '../data/moderation';
import type { ProfileModerationDecision } from '../render/moderation-decision';
+import { profileModerationShowsContent } from '../render/profile-moderation-policy';
import Icon from './icon';
export interface ProfileModerationGateProps {
profile:
+ | AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewDetailed
| undefined;
@@ -86,7 +88,7 @@ export default function ProfileModerationGate({
return null;
}
- if (!decision || decision.visibility === 'show' || revealed) {
+ if (!decision || profileModerationShowsContent(decision, revealed)) {
return children;
}
diff --git a/src/components/search-data-results.tsx b/src/components/search-data-results.tsx
index 32e41dbc..4ef32ecf 100644
--- a/src/components/search-data-results.tsx
+++ b/src/components/search-data-results.tsx
@@ -4,12 +4,14 @@ import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useSearchActors, useSearchPosts } from '../data/search';
+import { profileToAccount } from '../render/profile-view-map';
+import AccountBlock from './account-block';
import Icon from './icon';
import Loader from './loader';
import PostByUri from './post-by-uri';
import PostUriFeed from './post-uri-feed';
-import ProfileByDid from './profile-by-did';
+import ProfileModerationGate from './profile-moderation-gate';
const SHORT_LIMIT = 5;
@@ -18,6 +20,7 @@ export interface SearchDataResultsProps {
type: string | null;
instance?: string;
headerStart?: ReactNode | false;
+ filterBar?: ReactNode;
}
function SearchActorsList({
@@ -48,9 +51,15 @@ function SearchActorsList({
return (
<>
- {items.map((did) => (
- -
-
+ {items.map((actor) => (
+
-
+
+
+
))}
@@ -135,6 +144,7 @@ function SearchPostsPreview({
function SearchPostsFeed({
query,
headerStart,
+ filterBar,
}: SearchDataResultsProps) {
const { t } = useLingui();
const source = useSearchPosts(query);
@@ -148,6 +158,7 @@ function SearchPostsFeed({
path={path}
id="search-posts"
headerStart={headerStart ?? false}
+ timelineStart={filterBar}
emptyText={t`No posts found.`}
errorText={t`Unable to load search results.`}
/>
diff --git a/src/data/moderation.ts b/src/data/moderation.ts
index 5d382abd..f4c5ca43 100644
--- a/src/data/moderation.ts
+++ b/src/data/moderation.ts
@@ -143,6 +143,7 @@ export function usePostModeration(
export function useProfileModeration(
profile:
+ | AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewDetailed
| undefined,
diff --git a/src/data/profiles.ts b/src/data/profiles.ts
index 45ec2acc..98533e67 100644
--- a/src/data/profiles.ts
+++ b/src/data/profiles.ts
@@ -169,7 +169,7 @@ export function useSearchActorsTypeahead(term: string | undefined) {
const qc = useQueryClient();
return useQuery({
- queryKey: keys.search(scope, term ?? '', 'actors'),
+ queryKey: [...keys.search(scope, term ?? '', 'actors'), 'typeahead'] as const,
enabled: Boolean(
term &&
term.length >= 1 &&
diff --git a/src/data/search.ts b/src/data/search.ts
index 0b91a488..aca042ba 100644
--- a/src/data/search.ts
+++ b/src/data/search.ts
@@ -1,4 +1,8 @@
-import type { AppBskyFeedDefs, AppBskyFeedSearchPosts } from '@atproto/api';
+import type {
+ AppBskyActorDefs,
+ AppBskyFeedDefs,
+ AppBskyFeedSearchPosts,
+} from '@atproto/api';
import { useQueryClient } from '@tanstack/react-query';
import { useActiveDid, useClients } from '../contexts/SessionProvider';
@@ -103,7 +107,9 @@ export function useSearchActors(query: string | undefined) {
const activeDid = useActiveDid();
const qc = useQueryClient();
- return useInfiniteList({
+ return useInfiniteList<
+ AppBskyActorDefs.ProfileView | AppBskyActorDefs.ProfileViewBasic
+ >({
queryKey: keys.search(scope, query ?? '', 'actors'),
enabled: Boolean(
query &&
@@ -112,15 +118,38 @@ export function useSearchActors(query: string | undefined) {
),
queryFn: async ({ pageParam }) => {
const agent = getReadAgent(clients, feedReadMode(activeDid));
- const res = await agent.searchActors({
- q: query!,
- limit: 25,
- cursor: pageParam,
+ const q = query!;
+ const [searchRes, typeaheadRes] = await Promise.all([
+ agent.searchActors({
+ q,
+ limit: 25,
+ cursor: pageParam,
+ }),
+ pageParam
+ ? Promise.resolve(undefined)
+ : agent.searchActorsTypeahead({ q, limit: 10 }).catch(() => undefined),
+ ]);
+ const searchActors = searchRes.data.actors;
+ const typeaheadActors = typeaheadRes?.data.actors ?? [];
+ const normalizedQuery = q.trim().replace(/^[@@]/, '').toLowerCase();
+ const leadingActor = typeaheadActors.find((actor) => {
+ const handle = actor.handle.toLowerCase();
+ const displayName = actor.displayName?.toLowerCase() ?? '';
+ return (
+ handle.startsWith(normalizedQuery) ||
+ displayName.startsWith(normalizedQuery)
+ );
});
- primeProfiles(qc, scope, res.data);
+ const actors = leadingActor
+ ? [
+ leadingActor,
+ ...searchActors.filter((actor) => actor.did !== leadingActor.did),
+ ]
+ : searchActors;
+ primeProfiles(qc, scope, { actors });
return {
- items: res.data.actors.map((actor) => actor.did),
- cursor: res.data.cursor,
+ items: actors,
+ cursor: searchRes.data.cursor,
};
},
});
diff --git a/src/pages/search.css b/src/pages/search.css
index 51273b3a..e77a5bf2 100644
--- a/src/pages/search.css
+++ b/src/pages/search.css
@@ -1,6 +1,16 @@
#search-page .deck > header .header-grid {
grid-template-columns: auto 1fr auto;
}
+
+#search-page ~ #compose-button {
+ display: none;
+}
+
+#search-page,
+#search-posts-page {
+ transform: none;
+}
+
#search-page header {
input {
width: 100%;
@@ -27,7 +37,8 @@
}
@media (max-width: calc(40em - 1px)) {
- #search-page {
+ #search-page,
+ #search-posts-page {
--mobile-search-bottom: calc(52px + env(safe-area-inset-bottom));
--mobile-search-height: 56px;
--mobile-search-filter-height: 0px;
@@ -36,11 +47,13 @@
var(--mobile-search-filter-height) + 16px
);
- &:has(main > .filter-bar) {
+ &:has(.search-filter-bar) {
--mobile-search-filter-height: 48px;
}
+ }
- .deck > header {
+ #search-page {
+ > .deck > header {
position: fixed;
top: auto;
bottom: var(--mobile-search-bottom);
@@ -51,7 +64,7 @@
pointer-events: none;
}
- .deck > header .header-grid {
+ > .deck > header .header-grid {
grid-template-columns: 1fr auto;
width: min(var(--main-width), calc(100vw - 16px));
margin-inline: auto;
@@ -62,11 +75,11 @@
pointer-events: auto;
}
- .deck > header .header-grid > .header-side:first-child {
+ > .deck > header .header-grid > .header-side:first-child {
display: none;
}
- .deck > header .header-grid > .header-side:last-child {
+ > .deck > header .header-grid > .header-side:last-child {
grid-column: auto;
}
@@ -79,22 +92,51 @@
overflow-y: auto;
}
- main > .filter-bar {
+ .search-filter-bar {
position: fixed;
bottom: calc(
- var(--mobile-search-bottom) + var(--mobile-search-height) + 8px
+ var(--mobile-search-bottom) + var(--mobile-search-height) + 2px
);
- inset-inline: 0;
+ inset-inline: 8px;
+ width: min(var(--main-width), calc(100vw - 16px));
+ margin-inline: auto;
z-index: 119;
+ padding: 6px;
+ border: var(--hairline-width) solid var(--outline-color);
+ border-radius: 999px;
background-color: var(--bg-color);
- border-top: var(--hairline-width) solid var(--outline-color);
- box-shadow: 0 -8px 24px -16px var(--drop-shadow-color);
+ box-shadow: 0 10px 36px -4px var(--drop-shadow-color);
+ mask-image: none;
+ scroll-padding-inline: 8px;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ > :is(a, button, label):not(.filter-clear) {
+ padding: 8px 12px;
+ background-color: transparent;
+ }
+
+ > :is(a, button, label).is-active {
+ background-color: var(--link-bg-color);
+ }
}
}
+ #search-posts-page .search-filter-bar {
+ --mobile-search-bottom: calc(52px + env(safe-area-inset-bottom));
+ --mobile-search-height: 56px;
+ }
+
body.exp-tab-bar-v2 #search-page {
--mobile-search-bottom: calc(88px + env(safe-area-inset-bottom));
}
+
+ body.exp-tab-bar-v2 #search-posts-page .search-filter-bar {
+ --mobile-search-bottom: calc(88px + env(safe-area-inset-bottom));
+ }
}
#columns #search-page {
diff --git a/src/pages/search.tsx b/src/pages/search.tsx
index 1138973e..100b5bea 100644
--- a/src/pages/search.tsx
+++ b/src/pages/search.tsx
@@ -30,7 +30,6 @@ import StatusComponent, {
import { api, getMastoV2Resource } from '../utils/api';
import { fetchRelationships } from '../utils/relationships';
import shortenNumber from '../utils/shorten-number';
-import { sorted } from '../utils/sorted';
import usePageVisibility from '../utils/usePageVisibility';
import useTitle from '../utils/useTitle';
@@ -479,6 +478,50 @@ function Search({ columnMode, ...props }: SearchProps) {
);
const [filterBarParent] = useAutoAnimate();
+ const filterLinks = q
+ ? [
+ {
+ label: t`All`,
+ type: null,
+ to: `/search?q=${encodeURIComponent(q)}`,
+ },
+ {
+ label: t`Accounts`,
+ type: 'accounts',
+ to: `/search?q=${encodeURIComponent(q)}&type=accounts`,
+ },
+ {
+ label: t`Hashtags`,
+ type: 'hashtags',
+ to: `/search?q=${encodeURIComponent(q)}&type=hashtags`,
+ },
+ {
+ label: t`Posts`,
+ type: 'statuses',
+ to: `/search?q=${encodeURIComponent(q)}&type=statuses`,
+ },
+ ]
+ : [];
+ const filterBar =
+ !!q && !columnMode ? (
+
+ {filterLinks.map((link) => (
+
+ {link.label}
+
+ ))}
+
+ ) : null;
+ const useNestedFilterBar = atproto && type === 'statuses';
return (
- {!!q && !columnMode && (
-
- {!!type && (
-
- All
-
- )}
- {sorted(
- [
- {
- label: t`Accounts`,
- type: 'accounts',
- to: `/search?q=${encodeURIComponent(q)}&type=accounts`,
- },
- {
- label: t`Hashtags`,
- type: 'hashtags',
- to: `/search?q=${encodeURIComponent(q)}&type=hashtags`,
- },
- {
- label: t`Posts`,
- type: 'statuses',
- to: `/search?q=${encodeURIComponent(q)}&type=statuses`,
- },
- ],
- (a, b) => {
- if (a.type === type) return -1;
- if (b.type === type) return 1;
- return 0;
- },
- ).map((link) => (
-
- {link.label}
-
- ))}
-
- )}
+ {useNestedFilterBar ? null : filterBar}
{q ? (
atproto && type !== 'hashtags' ? (
) : (
<>
diff --git a/src/render/moderation-decision.ts b/src/render/moderation-decision.ts
index 7a45640d..9ff2d883 100644
--- a/src/render/moderation-decision.ts
+++ b/src/render/moderation-decision.ts
@@ -213,7 +213,10 @@ function profileFieldBlurred(
}
export function decideProfileModeration(
- profile: AppBskyActorDefs.ProfileView | AppBskyActorDefs.ProfileViewDetailed,
+ profile:
+ | AppBskyActorDefs.ProfileViewBasic
+ | AppBskyActorDefs.ProfileView
+ | AppBskyActorDefs.ProfileViewDetailed,
ctx: ModerationContext,
): ProfileModerationDecision {
const mod = moderateProfile(profile, moderationContextToOpts(ctx));
diff --git a/src/render/profile-moderation-policy.ts b/src/render/profile-moderation-policy.ts
new file mode 100644
index 00000000..7cea875e
--- /dev/null
+++ b/src/render/profile-moderation-policy.ts
@@ -0,0 +1,13 @@
+import type { ProfileModerationDecision } from './moderation-decision';
+
+export function profileModerationShowsContent(
+ decision: ProfileModerationDecision | undefined,
+ revealed: boolean,
+): boolean {
+ return (
+ !decision ||
+ decision.visibility === 'show' ||
+ decision.visibility === 'warn' ||
+ revealed
+ );
+}
diff --git a/src/render/profile-view-map.ts b/src/render/profile-view-map.ts
new file mode 100644
index 00000000..731c6f7e
--- /dev/null
+++ b/src/render/profile-view-map.ts
@@ -0,0 +1,59 @@
+import type { AppBskyActorDefs } from '@atproto/api';
+import type { mastodon } from 'masto';
+
+export type RenderableProfile =
+ | AppBskyActorDefs.ProfileViewBasic
+ | AppBskyActorDefs.ProfileView
+ | AppBskyActorDefs.ProfileViewDetailed;
+
+export function profileToAccount(
+ profile: RenderableProfile,
+): mastodon.v1.Account {
+ const createdAt =
+ 'indexedAt' in profile && profile.indexedAt
+ ? profile.indexedAt
+ : 'createdAt' in profile &&
+ typeof profile.createdAt === 'string' &&
+ profile.createdAt
+ ? profile.createdAt
+ : new Date().toISOString();
+ const banner = 'banner' in profile ? (profile.banner ?? '') : '';
+ const description =
+ 'description' in profile ? (profile.description ?? '') : '';
+ const followersCount =
+ 'followersCount' in profile && profile.followersCount
+ ? profile.followersCount
+ : 0;
+ const followingCount =
+ 'followsCount' in profile && profile.followsCount
+ ? profile.followsCount
+ : 0;
+ const postsCount =
+ 'postsCount' in profile && profile.postsCount
+ ? profile.postsCount
+ : 0;
+
+ return {
+ id: profile.did,
+ acct: profile.handle,
+ username: profile.handle.split('.')[0] ?? profile.handle,
+ displayName: profile.displayName || profile.handle,
+ avatar: profile.avatar || '',
+ avatarStatic: profile.avatar || '',
+ header: banner,
+ headerStatic: banner,
+ locked: false,
+ bot: false,
+ group: false,
+ createdAt,
+ note: description,
+ url: `https://bsky.app/profile/${profile.handle}`,
+ followersCount,
+ followingCount,
+ statusesCount: postsCount,
+ lastStatusAt: createdAt,
+ emojis: [],
+ fields: [],
+ roles: [],
+ } as mastodon.v1.Account;
+}
diff --git a/tests/logged-out-view.spec.js b/tests/logged-out-view.spec.js
index 18bf0220..2a180e02 100644
--- a/tests/logged-out-view.spec.js
+++ b/tests/logged-out-view.spec.js
@@ -696,16 +696,16 @@ function getBootReloadAttempts() {
/**
* @param {import('@playwright/test').Page} page
- * @param {{ homeTimeline?: { type: string; id: string } }} [options]
+ * @param {{ accessToken?: string; homeTimeline?: { type: string; id: string } }} [options]
*/
async function seedAtprotoLogin(page, options = {}) {
await page.addInitScript(
- ({ avatar, homeTimeline, repo }) => {
+ ({ accessToken, avatar, homeTimeline, repo }) => {
localStorage.setItem(
'accounts',
JSON.stringify([
{
- accessToken: 'test-token',
+ accessToken,
atproto: true,
info: {
id: repo,
@@ -738,6 +738,7 @@ async function seedAtprotoLogin(page, options = {}) {
}
},
{
+ accessToken: options.accessToken ?? 'test-token',
avatar: AT_PROFILE_AVATAR,
homeTimeline: options.homeTimeline,
repo: AT_REPO,
@@ -1156,7 +1157,13 @@ async function routeAtprotoScrollableFeed(page) {
* blackskyOmitPresentation?: boolean;
* blueskyOmitPresentation?: boolean;
* fallbackFails?: boolean;
+ * labelerViews?: unknown[];
* onBlueskyProfile?: () => void;
+ * profileFailures?: string[];
+ * preferences?: unknown[];
+ * profilesByDid?: Record;
+ * searchActorsByQuery?: Record;
+ * searchActorTypeaheadByQuery?: Record;
* searchPostsByQuery?: Record;
* }} [options]
*/
@@ -1166,7 +1173,13 @@ async function routeAtprotoRecords(page, options = {}) {
blackskyOmitPresentation = false,
blueskyOmitPresentation = false,
fallbackFails = false,
+ labelerViews = [],
onBlueskyProfile,
+ profileFailures = [],
+ preferences = [],
+ profilesByDid = {},
+ searchActorsByQuery = {},
+ searchActorTypeaheadByQuery = {},
searchPostsByQuery = {},
} = options;
const post = makeAtprotoPost();
@@ -1227,8 +1240,12 @@ async function routeAtprotoRecords(page, options = {}) {
const endpoint = url.pathname.replace('/xrpc/', '');
if (endpoint === 'app.bsky.actor.getProfile') {
const isBlacksky = url.hostname === 'api.blacksky.community';
+ const actor = url.searchParams.get('actor') || '';
if (!isBlacksky) onBlueskyProfile?.();
- if (!isBlacksky && fallbackFails) {
+ if (
+ !isBlacksky &&
+ (fallbackFails || profileFailures.includes(actor))
+ ) {
await route.fulfill({
headers,
status: 500,
@@ -1241,7 +1258,9 @@ async function routeAtprotoRecords(page, options = {}) {
: blueskyOmitPresentation;
await route.fulfill({
headers,
- json: omitPresentation
+ json: profilesByDid[actor]
+ ? profilesByDid[actor]
+ : omitPresentation
? {
...profile,
displayName: undefined,
@@ -1339,6 +1358,34 @@ async function routeAtprotoRecords(page, options = {}) {
});
return;
}
+ if (endpoint === 'app.bsky.actor.searchActors') {
+ const query = url.searchParams.get('q') || '';
+ await route.fulfill({
+ headers,
+ json: { actors: searchActorsByQuery[query] || [] },
+ });
+ return;
+ }
+ if (endpoint === 'app.bsky.actor.searchActorsTypeahead') {
+ const query = url.searchParams.get('q') || '';
+ await route.fulfill({
+ headers,
+ json: { actors: searchActorTypeaheadByQuery[query] || [] },
+ });
+ return;
+ }
+ if (endpoint === 'app.bsky.actor.getPreferences') {
+ await route.fulfill({ headers, json: { preferences } });
+ return;
+ }
+ if (endpoint === 'app.bsky.actor.putPreferences') {
+ await route.fulfill({ headers, json: {} });
+ return;
+ }
+ if (endpoint === 'app.bsky.labeler.getServices') {
+ await route.fulfill({ headers, json: { views: labelerViews } });
+ return;
+ }
await route.fulfill({ headers, json: {} });
},
);
@@ -1774,24 +1821,85 @@ test('keeps mobile search controls at the bottom and resets post results scroll'
waitUntil: 'domcontentloaded',
});
await expect(page.getByText('Alpha first search result')).toBeVisible();
+ await expect(page.locator('#compose-button')).toBeHidden();
const controlsGeometry = await page.evaluate(() => {
const input = document.querySelector('#search-page input[type="search"]');
- const filter = document.querySelector('#search-page main > .filter-bar');
+ const filter = document.querySelector('.search-filter-bar');
const inputBox = input?.getBoundingClientRect();
const filterBox = filter?.getBoundingClientRect();
+ const filterStyle = filter ? getComputedStyle(filter) : undefined;
return {
filterBottom: filterBox?.bottom ?? 0,
inputTop: inputBox?.top ?? 0,
viewportHeight: window.innerHeight,
+ filterPosition: filterStyle?.position,
+ filterBackground: filterStyle?.backgroundColor,
+ filterRadius: filterStyle?.borderRadius,
};
});
+ expect(controlsGeometry.filterPosition).toBe('fixed');
+ expect(controlsGeometry.filterBackground).not.toBe('rgb(220, 226, 234)');
+ expect(Number.parseFloat(controlsGeometry.filterRadius || '0')).toBeGreaterThan(
+ 20,
+ );
+ await expect
+ .poll(() =>
+ page
+ .locator('.search-filter-bar a')
+ .evaluateAll((links) =>
+ links.map((link) => link.textContent?.trim() || ''),
+ ),
+ )
+ .toEqual(['All', 'Accounts', 'Hashtags', 'Posts']);
expect(controlsGeometry.filterBottom).toBeLessThanOrEqual(
controlsGeometry.inputTop,
);
expect(controlsGeometry.filterBottom).toBeGreaterThan(
controlsGeometry.viewportHeight - 180,
);
+ await expect
+ .poll(() =>
+ page
+ .locator('#search-posts-page > .timeline-deck > header')
+ .evaluate((element) => getComputedStyle(element).position),
+ )
+ .not.toBe('fixed');
+
+ await page.locator('#search-posts-page').evaluate((element) => {
+ element.scrollTop = 160;
+ });
+ const scrolledControlsGeometry = await page.evaluate(() => {
+ const input = document.querySelector('#search-page input[type="search"]');
+ const filter = document.querySelector('.search-filter-bar');
+ const inputBox = input?.getBoundingClientRect();
+ const filterBox = filter?.getBoundingClientRect();
+ return {
+ filterBottom: filterBox?.bottom ?? 0,
+ inputTop: inputBox?.top ?? 0,
+ };
+ });
+ expect(scrolledControlsGeometry).toEqual({
+ filterBottom: controlsGeometry.filterBottom,
+ inputTop: controlsGeometry.inputTop,
+ });
+ await page.locator('#search-page').evaluate((element) => {
+ element.scrollTop = 160;
+ });
+ const outerScrolledControlsGeometry = await page.evaluate(() => {
+ const input = document.querySelector('#search-page input[type="search"]');
+ const filter = document.querySelector('.search-filter-bar');
+ const inputBox = input?.getBoundingClientRect();
+ const filterBox = filter?.getBoundingClientRect();
+ return {
+ filterBottom: filterBox?.bottom ?? 0,
+ inputTop: inputBox?.top ?? 0,
+ };
+ });
+ expect(outerScrolledControlsGeometry).toEqual({
+ filterBottom: controlsGeometry.filterBottom,
+ inputTop: controlsGeometry.inputTop,
+ });
await page.locator('#search-posts-page').evaluate((element) => {
element.scrollTop = 320;
@@ -1813,6 +1921,118 @@ test('keeps mobile search controls at the bottom and resets post results scroll'
.toBeLessThan(8);
});
+test('keeps the leading account search match visible', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await seedAtprotoLogin(page, {
+ accessToken: JSON.stringify({
+ type: 'atproto',
+ service: 'https://bsky.social',
+ session: {
+ refreshJwt: 'test-refresh',
+ accessJwt: 'test-access',
+ handle: 'alice.test',
+ did: AT_REPO,
+ active: true,
+ },
+ }),
+ });
+ const warningLabelerDid = 'did:plc:dm6tjhimvcxsgh2yxbppbqkx';
+ const exactActor = {
+ ...makeAtprotoTestActor(
+ 'did:plc:samuelexact',
+ 'samuel.fm',
+ 'Samuel',
+ ),
+ labels: [
+ {
+ src: warningLabelerDid,
+ uri: 'did:plc:samuelexact',
+ val: 'folklore',
+ cts: '2024-11-08T20:16:43.383Z',
+ },
+ ],
+ };
+ const fallbackActor = makeAtprotoTestActor(
+ 'did:plc:samuelfallback',
+ 'samueloakford.bsky.social',
+ 'Samuel Oakford',
+ );
+ await routeAtprotoRecords(page, {
+ labelerViews: [
+ {
+ $type: 'app.bsky.labeler.defs#labelerViewDetailed',
+ uri: `at://${warningLabelerDid}/app.bsky.labeler.service/self`,
+ cid: 'bafyreididididididididididididididididididididididididid',
+ creator: {
+ did: warningLabelerDid,
+ handle: 'labels.example.com',
+ displayName: 'Example Labeler',
+ labels: [],
+ viewer: {},
+ },
+ policies: {
+ labelValues: ['folklore'],
+ labelValueDefinitions: [
+ {
+ identifier: 'folklore',
+ severity: 'alert',
+ blurs: 'none',
+ defaultSetting: 'warn',
+ locales: [
+ {
+ lang: 'en',
+ name: 'Folklore',
+ description: 'Folklore label used by the regression fixture.',
+ },
+ ],
+ },
+ ],
+ },
+ indexedAt: '2024-01-01T00:00:00.000Z',
+ },
+ ],
+ preferences: [
+ {
+ $type: 'app.bsky.actor.defs#labelersPref',
+ labelers: [{ did: warningLabelerDid }],
+ },
+ {
+ $type: 'app.bsky.actor.defs#contentLabelPref',
+ label: 'folklore',
+ labelerDid: warningLabelerDid,
+ visibility: 'warn',
+ },
+ ],
+ searchActorsByQuery: {
+ samuel: [exactActor, fallbackActor],
+ },
+ searchActorTypeaheadByQuery: {
+ samuel: [exactActor],
+ },
+ });
+
+ await page.goto('/search?q=samuel&type=accounts', {
+ waitUntil: 'domcontentloaded',
+ });
+
+ await expect(page.getByText('@samuel.fm')).toBeVisible();
+ await expect(page.getByText('@samueloakford.bsky.social')).toBeVisible();
+ const firstAccount = page.locator('.accounts-list .account-block').first();
+ await expect(firstAccount).toContainText('Samuel');
+ await expect(firstAccount).toContainText('@samuel.fm');
+ await page.waitForTimeout(1000);
+ await expect(firstAccount).toContainText('@samuel.fm');
+ await expect
+ .poll(() =>
+ page
+ .locator('.search-filter-bar a')
+ .evaluateAll((links) =>
+ links.map((link) => link.textContent?.trim() || ''),
+ ),
+ )
+ .toEqual(['All', 'Accounts', 'Hashtags', 'Posts']);
+});
+
test('restores AT feed position after opening a feed post in the sidebar', async ({
page,
}) => {
diff --git a/tests/profile-moderation-gate.test.tsx b/tests/profile-moderation-gate.test.tsx
new file mode 100644
index 00000000..f3aec4b7
--- /dev/null
+++ b/tests/profile-moderation-gate.test.tsx
@@ -0,0 +1,22 @@
+import { describe, expect, test } from 'bun:test';
+
+import { profileModerationShowsContent } from '../src/render/profile-moderation-policy';
+import type { ProfileModerationDecision } from '../src/render/moderation-decision';
+
+function decision(
+ visibility: ProfileModerationDecision['visibility'],
+): ProfileModerationDecision {
+ return { visibility };
+}
+
+describe('profileModerationShowsContent', () => {
+ test('shows warn profiles without replacing the account row', () => {
+ expect(profileModerationShowsContent(decision('warn'), false)).toBe(true);
+ });
+
+ test('covers blur and hide profiles until revealed', () => {
+ expect(profileModerationShowsContent(decision('blur'), false)).toBe(false);
+ expect(profileModerationShowsContent(decision('hide'), false)).toBe(false);
+ expect(profileModerationShowsContent(decision('blur'), true)).toBe(true);
+ });
+});