From 80c6134359ac80778f089655b72213d83bb18cb2 Mon Sep 17 00:00:00 2001 From: "Moon.eth" Date: Mon, 3 Aug 2026 16:40:37 +0900 Subject: [PATCH] top 8 feature --- .../profile/PublicProfileEditor.svelte | 159 +++++++++++++++- .../profile/PublicProfileEditor.test.ts | 35 ++++ src/components/profile/TopEightSpace.svelte | 51 +++++ src/lib/api/types.ts | 2 + src/lib/util/profile.ts | 10 +- src/lib/util/top-eight.test.ts | 62 ++++++ src/lib/util/top-eight.ts | 180 ++++++++++++++++++ src/routes/Profile.svelte | 48 +++++ src/routes/Profile.test.ts | 40 ++++ src/styles/forms.css | 65 +++++++ src/styles/profile.css | 40 ++++ 11 files changed, 684 insertions(+), 8 deletions(-) create mode 100644 src/components/profile/TopEightSpace.svelte create mode 100644 src/lib/util/top-eight.test.ts create mode 100644 src/lib/util/top-eight.ts diff --git a/src/components/profile/PublicProfileEditor.svelte b/src/components/profile/PublicProfileEditor.svelte index 9b1f320..2f5f21c 100644 --- a/src/components/profile/PublicProfileEditor.svelte +++ b/src/components/profile/PublicProfileEditor.svelte @@ -4,11 +4,19 @@ * in a separate settings editor. */ import { untrack } from 'svelte' - import type { CredentialAccount } from '$lib/api/types' + import type { Account, CredentialAccount } from '$lib/api/types' import { isEgregoros, publicProfileCapabilities } from '$lib/api/capabilities' import { useAppServices } from '$lib/app-services' import { profileFieldLimits } from '$lib/stores/theme.svelte' import { toPlainText } from '$lib/util/html' + import { fullHandle } from '$lib/util/profile' + import { instanceDomain } from '$lib/api/endpoints' + import { + parseTopEightText, + profileBioLimit, + TOP_EIGHT_MAX, + withTopEight, + } from '$lib/util/top-eight' interface EditableField { id: number @@ -40,8 +48,14 @@ } const initial = untrack(() => session.me) + const initialBio = parseTopEightText(initial?.source?.note ?? toPlainText(initial?.note ?? '')) let displayName = $state(initial?.display_name ?? '') - let note = $state(initial?.source?.note ?? toPlainText(initial?.note ?? '')) + let note = $state(initialBio.bio) + let topEightHandles = $state(initialBio.handles) + let topEightQuery = $state('') + let topEightResults = $state([]) + let topEightSearching = $state(false) + let topEightSearchError = $state(null) let fields = $state(publicFields(initial)) let actorType = $state<'Person' | 'Service' | 'Group'>( initial?.source?.pleroma?.actor_type ?? (initial?.bot ? 'Service' : 'Person'), @@ -69,6 +83,12 @@ const isEgregorosServer = $derived(isEgregoros(session.instance)) const capabilities = $derived(publicProfileCapabilities(session.instance)) const limits = $derived(profileFieldLimits(session.instance)) + const bioLimit = $derived(profileBioLimit(session.instance)) + const savedNote = $derived(withTopEight(note, topEightHandles)) + const bioCharactersLeft = $derived(bioLimit.value - savedNote.length) + const topEightAvailable = $derived( + topEightHandles.length > 0 || bioLimit.value - note.length >= 30, + ) const reservedFields = $derived(internalFields(session.me).length) const availablePublicFields = $derived( capabilities.fields ? Math.max(0, limits.maxFields - reservedFields) : 0, @@ -82,7 +102,12 @@ field.name.length <= limits.nameLength && field.value.length <= limits.valueLength, )), ) - const canSave = $derived(Boolean(displayName.trim()) && fieldsValid && !busy) + const canSave = $derived( + Boolean(displayName.trim()) && + fieldsValid && + (bioLimit.estimated || bioCharactersLeft >= 0) && + !busy, + ) function addField(): void { if (!canAddField) return @@ -93,6 +118,49 @@ fields = fields.filter((field) => field.id !== id) } + async function searchTopEight(): Promise { + const query = topEightQuery.trim() + if (!query || topEightSearching) return + topEightSearching = true + topEightSearchError = null + try { + const found = await endpoints.search(session.api, query, { type: 'accounts', limit: 5 }) + topEightResults = found.accounts.filter( + (candidate) => !topEightHandles.some( + (handle) => handle.toLowerCase() === fullHandle(candidate, instanceDomain(session.instance, session.host)).toLowerCase(), + ), + ) + if (topEightResults.length === 0) topEightSearchError = 'No matching people found.' + } catch (cause) { + topEightSearchError = cause instanceof Error ? cause.message : 'Could not search for that person.' + } finally { + topEightSearching = false + } + } + + function addTopEight(candidate: Account): void { + if (topEightHandles.length >= TOP_EIGHT_MAX) return + const handle = fullHandle(candidate, instanceDomain(session.instance, session.host)) + if (!topEightHandles.some((item) => item.toLowerCase() === handle.toLowerCase())) { + topEightHandles = [...topEightHandles, handle] + } + topEightQuery = '' + topEightResults = [] + topEightSearchError = null + } + + function removeTopEight(index: number): void { + topEightHandles = topEightHandles.filter((_, itemIndex) => itemIndex !== index) + } + + function moveTopEight(index: number, direction: -1 | 1): void { + const destination = index + direction + if (destination < 0 || destination >= topEightHandles.length) return + const reordered = [...topEightHandles] + ;[reordered[index], reordered[destination]] = [reordered[destination], reordered[index]] + topEightHandles = reordered + } + function chooseImage( kind: 'avatar' | 'header' | 'background', event: Event, @@ -118,8 +186,12 @@ } function resetFrom(account: CredentialAccount): void { + const parsedBio = parseTopEightText(account.source?.note ?? toPlainText(account.note)) displayName = account.display_name - note = account.source?.note ?? toPlainText(account.note) + note = parsedBio.bio + topEightHandles = parsedBio.handles + topEightQuery = '' + topEightResults = [] fields = publicFields(account) actorType = account.source?.pleroma?.actor_type ?? (account.bot ? 'Service' : 'Person') birthday = account.pleroma?.birthday ?? '' @@ -154,7 +226,7 @@ : [] const updated = await endpoints.updatePublicProfile(session.api, { displayName: displayName.trim(), - note, + note: savedNote, fields: capabilities.fields ? [...visible, ...hidden] : undefined, avatar: capabilities.avatar ? imageValue(avatarMode, avatarFile) : undefined, header: capabilities.header ? imageValue(headerMode, headerFile) : undefined, @@ -208,9 +280,84 @@
-

Your server may support plain text, Markdown or other formatting here.

+

+ Your server may support plain text, Markdown or other formatting here. + {savedNote.length.toLocaleString()} of about {bioLimit.value.toLocaleString()} characters used{bioLimit.estimated ? ' (estimated)' : ''}. +

+ {#if topEightAvailable} +
+ My Top 8 +

+ plspace stores this as a readable My top 8: section in your public bio. + Other clients will see the list as text; plspace visitors get the full picture grid. + Saving this form preserves your published CSS fields. + {#if reservedFields > 0} + Your plspace CSS currently uses {reservedFields} of {limits.maxFields} profile-field slots, + but it does not consume bio characters. + {/if} +

+ + {#if topEightHandles.length > 0} +
    + {#each topEightHandles as handle, index (handle)} +
  1. + {handle} + + + + + +
  2. + {/each} +
+ {:else} +

You have not picked a Top 8 yet.

+ {/if} + + {#if topEightHandles.length < TOP_EIGHT_MAX} + + {#if topEightSearchError}{/if} + {#if topEightResults.length > 0} +
    + {#each topEightResults as candidate (candidate.id)} +
  • + +
  • + {/each} +
+ {/if} + {/if} + +

+ {topEightHandles.length} of {TOP_EIGHT_MAX} selected. + {#if bioCharactersLeft >= 0} + About {bioCharactersLeft.toLocaleString()} bio characters remain. + {:else} + {#if bioLimit.estimated} + This is about {Math.abs(bioCharactersLeft).toLocaleString()} characters over plspace's estimate; + your server will make the final decision when you save. + {:else} + Shorten your bio or Top 8 by {Math.abs(bioCharactersLeft).toLocaleString()} characters before saving. + {/if} + {/if} +

+
+ {:else} +

+ Top 8 editing is unavailable because your existing bio leaves too little room under this server's estimated profile limit. +

+ {/if} +
Profile images diff --git a/src/components/profile/PublicProfileEditor.test.ts b/src/components/profile/PublicProfileEditor.test.ts index f3b70c5..add19e0 100644 --- a/src/components/profile/PublicProfileEditor.test.ts +++ b/src/components/profile/PublicProfileEditor.test.ts @@ -136,6 +136,41 @@ describe('PublicProfileEditor', () => { expect(await view.findByText('Your public profile was updated.')).toBeInTheDocument() }) + it('searches for Top 8 people and stores the portable list in the bio', async () => { + const me = credential() + const candidate = account({ + id: 'friend', + username: 'friend', + acct: 'friend@remote.test', + display_name: 'Best Friend', + avatar_static: 'https://media.example/friend.png', + }) + const search = vi.fn().mockResolvedValue({ accounts: [candidate], statuses: [], hashtags: [] }) + const updatePublicProfile = vi.fn(async (_api, update: PublicProfileUpdate) => ({ + ...me, + source: { ...me.source!, note: update.note ?? '', fields: update.fields ?? me.source!.fields }, + })) + const services = testServices({ + session: session({ token: 'token', me, signedIn: true }), + endpoints: { search, updatePublicProfile }, + }) + const view = render(PublicProfileEditor, { + context: new Map([[APP_SERVICES, services]]), + }) + + await fireEvent.input(view.getByLabelText('Find someone for your Top 8'), { + target: { value: '@friend@remote.test' }, + }) + await fireEvent.click(view.getByRole('button', { name: 'Find person' })) + await fireEvent.click(await view.findByRole('button', { name: /Best Friend/ })) + await fireEvent.click(view.getByRole('button', { name: 'Save public profile' })) + + await waitFor(() => expect(updatePublicProfile).toHaveBeenCalledOnce()) + expect(updatePublicProfile.mock.calls[0][1].note).toBe( + 'Old bio\n\nMy top 8:\n1. @friend@remote.test', + ) + }) + it('shows and submits only the profile fields Egregoros exposes through its API', async () => { const me = credential() const updatePublicProfile = vi.fn().mockResolvedValue(me) diff --git a/src/components/profile/TopEightSpace.svelte b/src/components/profile/TopEightSpace.svelte new file mode 100644 index 0000000..1be8d17 --- /dev/null +++ b/src/components/profile/TopEightSpace.svelte @@ -0,0 +1,51 @@ + + + + {#if loading && accounts.length === 0} +

Putting the Top 8 together…

+ {:else} +
    + {#each accounts as friend (friend.id)} +
  • + + + + +
  • + {/each} +
+ {#if missing.length > 0} +

+ Could not find {missing.join(', ')} from this server. +

+ {/if} + {/if} + +

+ View All of 's Friends +

+
diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 8e4dc7e..365b047 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -350,6 +350,8 @@ export interface InstanceInfo { } accounts?: { max_profile_fields?: number + /** Mastodon 4.6+ bio limit. */ + max_note_length?: number /** Pleroma v2 names. */ profile_field_name_limit?: number profile_field_value_limit?: number diff --git a/src/lib/util/profile.ts b/src/lib/util/profile.ts index d155870..eeb0a50 100644 --- a/src/lib/util/profile.ts +++ b/src/lib/util/profile.ts @@ -15,6 +15,7 @@ import type { Account } from '../api/types' import { renderHtml, toPlainText } from './html' +import { topEightFromHtml } from './top-eight' /** The interest rows a MySpace profile shipped with, in their original order. */ export const INTEREST_ROWS = ['General', 'Music', 'Movies', 'Television', 'Books', 'Heroes'] as const @@ -70,6 +71,8 @@ export interface ProfileView { about: string /** "Who I'd like to meet" blurb, sanitized HTML. Empty when the user wrote none. */ wantsToMeet: string + /** Fully-qualified handles declared in the portable bio section. */ + topEightHandles: string[] interests: InterestEntry[] details: ProfileField[] } @@ -124,7 +127,9 @@ export function fallbackMood(seed: string): string { } export function buildProfileView(account: Account): ProfileView { - const noteHtml = renderHtml(account.note, { emojis: account.emojis }) + const renderedNote = renderHtml(account.note, { emojis: account.emojis }) + const topEight = topEightFromHtml(renderedNote) + const noteHtml = topEight.html const { about, meet } = splitBio(noteHtml) const interests: InterestEntry[] = [] @@ -155,7 +160,7 @@ export function buildProfileView(account: Account): ProfileView { interests.sort((a, b) => INTEREST_ROWS.indexOf(a.row) - INTEREST_ROWS.indexOf(b.row)) const headlineField = findField(account, ['headline', 'status']) - const headline = headlineField ?? firstSentence(toPlainText(account.note)) ?? '"..."' + const headline = headlineField ?? firstSentence(toPlainText(noteHtml)) ?? '"..."' const ageField = findField(account, ['age']) const parsedAge = ageField ? Number.parseInt(ageField, 10) : Number.NaN @@ -169,6 +174,7 @@ export function buildProfileView(account: Account): ProfileView { age: Number.isFinite(parsedAge) ? parsedAge : null, about, wantsToMeet: meet, + topEightHandles: topEight.handles, interests, details, } diff --git a/src/lib/util/top-eight.test.ts b/src/lib/util/top-eight.test.ts new file mode 100644 index 0000000..06ac39f --- /dev/null +++ b/src/lib/util/top-eight.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { parseTopEightText, topEightFromHtml, withTopEight } from './top-eight' + +describe('Top 8 profile bio format', () => { + it('detects numbered and unnumbered fully-qualified handles', () => { + expect(parseTopEightText([ + 'I like old websites.', + '', + 'My top 8:', + '1. @alice@example.test', + 'bob@remote.test', + '3. @carol@social.example', + '', + 'This remains in the bio.', + ].join('\n'))).toEqual({ + handles: ['@alice@example.test', '@bob@remote.test', '@carol@social.example'], + bio: 'I like old websites.\n\nThis remains in the bio.', + }) + }) + + it('accepts local-only @handles in manually written profile lists', () => { + expect(parseTopEightText([ + 'About me', + 'My top 8:', + '1. @localfriend', + '2. @remote@social.example', + '3. @another_local', + ].join('\n'))).toEqual({ + handles: ['@localfriend', '@remote@social.example', '@another_local'], + bio: 'About me', + }) + }) + + it('ignores headings without at least one qualified handle', () => { + expect(parseTopEightText('My top 8:\nAlice\nBob')).toEqual({ + handles: [], + bio: 'My top 8:\nAlice\nBob', + }) + }) + + it('replaces an existing section and caps it at eight unique handles', () => { + const next = withTopEight( + 'Bio\n\nMy top 8:\n@old@example.test', + Array.from({ length: 10 }, (_, index) => `friend${index}@example.test`), + ) + expect(next).toContain('Bio\n\nMy top 8:\n1. @friend0@example.test') + expect(next).toContain('8. @friend7@example.test') + expect(next).not.toContain('friend8') + expect(next).not.toContain('@old@example.test') + }) + + it('removes the section from HTML without flattening the rest of the bio', () => { + const result = topEightFromHtml( + '

Hello!
My top 8:
1. @alice@example.test
@bob@remote.test

Still here.

', + ) + expect(result.handles).toEqual(['@alice@example.test', '@bob@remote.test']) + expect(result.html).toContain('Hello!') + expect(result.html).toContain('Still here.') + expect(result.html).not.toContain('My top 8') + expect(result.html).not.toContain('@alice@example.test') + }) +}) diff --git a/src/lib/util/top-eight.ts b/src/lib/util/top-eight.ts new file mode 100644 index 0000000..3934bbb --- /dev/null +++ b/src/lib/util/top-eight.ts @@ -0,0 +1,180 @@ +import DOMPurify from 'dompurify' +import type { Account, InstanceInfo } from '../api/types' + +export const TOP_EIGHT_HEADING = 'My top 8:' +export const TOP_EIGHT_MAX = 8 + +const HEADING_PATTERN = /^\s*my\s+top\s+8\s*:\s*$/i +const HANDLE_PATTERN = /^\s*(?:[1-8]\.\s*)?((?:@[a-z0-9_][a-z0-9_.-]*)(?:@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d+)?)?|(?:[a-z0-9_][a-z0-9_.-]*@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d+)?))\s*$/i + +export interface ParsedTopEight { + handles: string[] + /** The bio with only the recognized Top 8 section removed. */ + bio: string +} + +function normalizedHandle(value: string): string | null { + const match = HANDLE_PATTERN.exec(value) + if (!match) return null + return `@${match[1].replace(/^@/, '')}` +} + +/** Local-only handles are accepted when reading, but picker-written handles are qualified. */ +export function topEightHandleMatchesAccount(handle: string, account: Account, localHost: string): boolean { + const normalized = handle.toLowerCase() + return normalized === `@${account.acct}`.toLowerCase() || + normalized === `@${account.username}`.toLowerCase() || + normalized === `@${account.username}@${localHost}`.toLowerCase() +} + +/** Parse the portable, human-readable representation used in profile bios. */ +export function parseTopEightText(source: string): ParsedTopEight { + const normalized = source.replace(/\r\n?/g, '\n') + const lines = normalized.split('\n') + + for (let heading = 0; heading < lines.length; heading += 1) { + if (!HEADING_PATTERN.test(lines[heading])) continue + const handles: string[] = [] + let end = heading + 1 + while (end < lines.length && handles.length < TOP_EIGHT_MAX) { + const handle = normalizedHandle(lines[end]) + if (!handle) break + handles.push(handle) + end += 1 + } + if (handles.length === 0) continue + + const before = lines.slice(0, heading) + const after = lines.slice(end) + return { + handles, + bio: [...before, ...after].join('\n').replace(/\n{3,}/g, '\n\n').trim(), + } + } + + return { handles: [], bio: normalized.trim() } +} + +/** Replace an existing section without disturbing the user's ordinary bio. */ +export function withTopEight(source: string, handles: string[]): string { + const base = parseTopEightText(source).bio + const unique = Array.from( + new Set(handles.map((handle) => normalizedHandle(handle)).filter((handle): handle is string => Boolean(handle))), + ).slice(0, TOP_EIGHT_MAX) + if (unique.length === 0) return base + const section = [TOP_EIGHT_HEADING, ...unique.map((handle, index) => `${index + 1}. ${handle}`)].join('\n') + return base ? `${base}\n\n${section}` : section +} + +interface ProjectedLine { + text: string + textNodes: Text[] + breaks: HTMLBRElement[] +} + +/** + * Project an HTML bio into lines while retaining the nodes that formed them. + * This lets us remove the portable section without flattening links, emphasis, + * custom emoji, or any other formatting in the rest of the bio. + */ +function projectedLines(container: HTMLElement): ProjectedLine[] { + const lines: ProjectedLine[] = [{ text: '', textNodes: [], breaks: [] }] + const current = () => lines[lines.length - 1] + const newline = (br?: HTMLBRElement) => { + if (br) current().breaks.push(br) + if (current().text || current().textNodes.length || current().breaks.length) { + lines.push({ text: '', textNodes: [], breaks: [] }) + } + } + const blocks = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'PRE', 'H1', 'H2', 'H3', 'H4']) + + const visit = (node: Node): void => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? '' + const parts = text.split('\n') + parts.forEach((part, index) => { + if (part) { + current().text += part + current().textNodes.push(node as Text) + } + if (index < parts.length - 1) newline() + }) + return + } + if (!(node instanceof HTMLElement)) return + if (node.tagName === 'BR') { + newline(node as HTMLBRElement) + return + } + const block = blocks.has(node.tagName) + if (block && current().text.trim()) newline() + for (const child of Array.from(node.childNodes)) visit(child) + if (block && current().text.trim()) newline() + } + + for (const child of Array.from(container.childNodes)) visit(child) + return lines +} + +export function topEightFromHtml(source: string): { handles: string[]; html: string } { + const container = document.createElement('div') + container.innerHTML = DOMPurify.sanitize(source) + const lines = projectedLines(container) + + for (let heading = 0; heading < lines.length; heading += 1) { + if (!HEADING_PATTERN.test(lines[heading].text)) continue + const handles: string[] = [] + let end = heading + 1 + while (end < lines.length && handles.length < TOP_EIGHT_MAX) { + const handle = normalizedHandle(lines[end].text) + if (!handle) break + handles.push(handle) + end += 1 + } + if (handles.length === 0) continue + + for (const line of lines.slice(heading, end)) { + for (const node of new Set(line.textNodes)) node.textContent = '' + for (const br of line.breaks) br.remove() + } + for (const empty of Array.from(container.querySelectorAll('p, div, li, blockquote, pre'))) { + if (!(empty.textContent ?? '').trim() && !empty.querySelector('img')) empty.remove() + } + return { handles, html: container.innerHTML.trim() } + } + return { handles: [], html: container.innerHTML.trim() } +} + +/** Best available limit. Pleroma does not currently advertise user_bio_length. */ +export function profileBioLimit(instance: InstanceInfo | null): { value: number; estimated: boolean } { + const advertised = instance?.configuration?.accounts?.max_note_length + if (typeof advertised === 'number' && advertised > 0) return { value: advertised, estimated: false } + if (instance?.pleroma) return { value: 5000, estimated: true } + return { value: 500, estimated: true } +} + +const CACHE_PREFIX = 'plspace:top-eight:v1:' +const CACHE_TTL = 24 * 60 * 60 * 1000 + +export function readTopEightCache(host: string, ownerId: string, handles: string[]): Account[] | null { + try { + const raw = localStorage.getItem(`${CACHE_PREFIX}${host}:${ownerId}`) + if (!raw) return null + const cached = JSON.parse(raw) as { savedAt: number; handles: string[]; accounts: Account[] } + if (Date.now() - cached.savedAt > CACHE_TTL || cached.handles.join('\n') !== handles.join('\n')) return null + return cached.accounts + } catch { + return null + } +} + +export function writeTopEightCache(host: string, ownerId: string, handles: string[], accounts: Account[]): void { + try { + localStorage.setItem( + `${CACHE_PREFIX}${host}:${ownerId}`, + JSON.stringify({ savedAt: Date.now(), handles, accounts }), + ) + } catch { + // Private browsing and storage quotas should never break a public profile. + } +} diff --git a/src/routes/Profile.svelte b/src/routes/Profile.svelte index ae3e060..a3aafe1 100644 --- a/src/routes/Profile.svelte +++ b/src/routes/Profile.svelte @@ -24,6 +24,11 @@ formatCount, } from '$lib/util/profile' import { toPlainText } from '$lib/util/html' + import { + readTopEightCache, + topEightHandleMatchesAccount, + writeTopEightCache, + } from '$lib/util/top-eight' import Module from '$components/common/Module.svelte' import EmojiText from '$components/common/EmojiText.svelte' import ProfileIdentity from '$components/profile/ProfileIdentity.svelte' @@ -31,6 +36,7 @@ import InterestsTable from '$components/profile/InterestsTable.svelte' import DetailsTable from '$components/profile/DetailsTable.svelte' import FriendSpace from '$components/profile/FriendSpace.svelte' + import TopEightSpace from '$components/profile/TopEightSpace.svelte' import PicStream from '$components/profile/PicStream.svelte' import BlogEntry from '$components/blog/BlogEntry.svelte' import Pager from '$components/common/Pager.svelte' @@ -52,6 +58,9 @@ let friends = $state([]) let friendsLoading = $state(false) + let topEightAccounts = $state([]) + let topEightMissing = $state([]) + let topEightLoading = $state(false) let loadGeneration = 0 // Recreated whenever the account changes, so the feed never shows one @@ -105,6 +114,9 @@ relationship = null friends = [] friendsLoading = false + topEightAccounts = [] + topEightMissing = [] + topEightLoading = false try { const found = await endpoints.lookupAccount(session.api, handle) @@ -145,6 +157,7 @@ ) void entries.reload() + void loadTopEight(found, generation) void loadFriends(found, currentView, generation) void loadRelationship(found, generation) } catch (cause) { @@ -155,6 +168,30 @@ } } + async function loadTopEight(target: Account, generation: number): Promise { + const handles = buildProfileView(target).topEightHandles + if (handles.length === 0) return + + const cached = readTopEightCache(session.host, target.id, handles) + if (cached) { + topEightAccounts = cached + topEightMissing = handles.filter( + (handle) => !cached.some((item) => topEightHandleMatchesAccount(handle, item, session.host)), + ) + return + } + + topEightLoading = true + const settled = await Promise.allSettled( + handles.map((handle) => endpoints.lookupAccount(session.api, handle)), + ) + if (generation !== loadGeneration || account?.id !== target.id) return + topEightAccounts = settled.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []) + topEightMissing = handles.filter((_, index) => settled[index].status === 'rejected') + writeTopEightCache(session.host, target.id, handles, topEightAccounts) + topEightLoading = false + } + async function loadFriends( target: Account, currentView: Props['view'], @@ -394,6 +431,17 @@ {/if} + {#if profile.topEightHandles.length > 0} + + {/if} + { expect(view.queryByText('A reply')).not.toBeInTheDocument() }) + it('turns a portable bio list into a Top 8 grid and hides its source text', async () => { + const owner = account({ + id: 'owner', + note: '

Hello from my profile.
My top 8:
1. @bob
@carol@social.test

', + }) + const bob = account({ id: 'bob', username: 'bob', acct: 'bob@remote.test', display_name: 'Bob' }) + const carol = account({ id: 'carol', username: 'carol', acct: 'carol@social.test', display_name: 'Carol' }) + const lookupAccount = vi.fn(async (_api, handle: string) => { + if (handle === 'alice') return owner + if (handle === '@bob') return bob + if (handle === '@carol@social.test') return carol + throw new Error('not found') + }) + const services = testServices({ + session: session(), + theme: theme(), + endpoints: { + lookupAccount, + fetchAccountStatuses: vi.fn().mockResolvedValue({ items: [], links: {} }), + fetchFollowers: vi.fn().mockResolvedValue({ items: [], links: {} }), + }, + }) + const view = render(Profile, { + props: { acct: 'alice' }, + context: new Map([[APP_SERVICES, services]]), + }) + + expect(await view.findByRole('heading', { name: "Alice's Top 8" })).toBeInTheDocument() + expect(await view.findByText('Bob')).toBeInTheDocument() + expect(await view.findByText('Carol')).toBeInTheDocument() + expect(view.container.querySelector('.top-eight-space .friend-count')).not.toBeInTheDocument() + expect(view.getByRole('link', { name: "View All of Alice's Friends" })).toHaveAttribute( + 'href', + '#/@alice/friends', + ) + expect(view.getAllByText('Hello from my profile.').length).toBeGreaterThan(0) + expect(view.queryByText('My top 8:')).not.toBeInTheDocument() + expect(view.queryByText('@bob@remote.test')).not.toBeInTheDocument() + }) + it('builds Pics from the account’s own image attachments', async () => { const ownPicture = status({ id: 'picture-entry', diff --git a/src/styles/forms.css b/src/styles/forms.css index ec356f5..a3a0a05 100644 --- a/src/styles/forms.css +++ b/src/styles/forms.css @@ -321,6 +321,71 @@ textarea { gap: 5px; } +.profile-editor-top-eight { + display: grid; + gap: 7px; +} + +.top-eight-editor-list, +.top-eight-search-results { + margin: 0; + padding-left: 28px; +} + +.top-eight-editor-list li { + min-height: 28px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.top-eight-editor-actions { + display: flex; + gap: 3px; +} + +.top-eight-search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 5px; +} + +.top-eight-search-results { + list-style: none; + padding: 0; +} + +.top-eight-result { + width: 100%; + display: grid; + grid-template-columns: 42px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 4px; + border: 1px solid var(--ms-module-border); + background: var(--ms-canvas-bg); + color: var(--ms-page-fg); + text-align: left; + cursor: pointer; +} + +.top-eight-result:hover, +.top-eight-result:focus-visible { + background: var(--ms-table-stripe-bg); +} + +.top-eight-result img { + width: 42px; + height: 42px; + object-fit: cover; +} + +.field-error { + color: var(--ms-error-fg, #a00000); + font-weight: 700; +} + .profile-editor-field { display: grid; grid-template-columns: minmax(100px, 1fr) minmax(160px, 2fr) auto; diff --git a/src/styles/profile.css b/src/styles/profile.css index 21e4893..69de457 100644 --- a/src/styles/profile.css +++ b/src/styles/profile.css @@ -232,6 +232,46 @@ white-space: nowrap; } +.top-eight-space { + margin-bottom: var(--ms-module-gap); +} + +.top-eight-grid { + grid-template-columns: repeat(4, minmax(64px, 1fr)); + gap: 28px 18px; + padding: 12px 0 4px; +} + +.top-eight-grid .friend-card-name { + min-height: 2.4em; + display: flex; + align-items: flex-end; + justify-content: center; + white-space: normal; + line-height: 1.15; +} + +.top-eight-grid .friend-card-photo { + border: 2px solid var(--ms-link-color); +} + +.top-eight-missing { + margin: 8px 0 0; + font-size: var(--ms-font-size-small); +} + +.top-eight-view-all { + margin: 22px 0 2px; + text-align: right; + font-weight: 700; +} + +@media (max-width: 520px) { + .top-eight-grid { + grid-template-columns: repeat(2, minmax(64px, 1fr)); + } +} + /* ------------------------------------------------------------- pics stream */ .pic-stream-intro {