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 ( <> @@ -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); + }); +});