top 8 feature

This commit is contained in:
Moon.eth
2026-08-03 16:40:37 +09:00
parent 7d16969f02
commit 80c6134359
11 changed files with 684 additions and 8 deletions
@@ -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<string[]>(initialBio.handles)
let topEightQuery = $state('')
let topEightResults = $state<Account[]>([])
let topEightSearching = $state(false)
let topEightSearchError = $state<string | null>(null)
let fields = $state<EditableField[]>(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<void> {
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 @@
<div class="field">
<label class="field-label" for="profile-bio">About me / bio</label>
<textarea id="profile-bio" class="field-input profile-editor-bio" bind:value={note} rows="7"></textarea>
<p class="field-hint">Your server may support plain text, Markdown or other formatting here.</p>
<p class="field-hint">
Your server may support plain text, Markdown or other formatting here.
{savedNote.length.toLocaleString()} of about {bioLimit.value.toLocaleString()} characters used{bioLimit.estimated ? ' (estimated)' : ''}.
</p>
</div>
{#if topEightAvailable}
<fieldset class="profile-editor-top-eight">
<legend>My Top 8</legend>
<p class="field-hint">
plspace stores this as a readable <code>My top 8:</code> 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}
</p>
{#if topEightHandles.length > 0}
<ol class="top-eight-editor-list">
{#each topEightHandles as handle, index (handle)}
<li>
<code>{handle}</code>
<span class="top-eight-editor-actions">
<button type="button" class="button button--small" aria-label={`Move ${handle} up`} disabled={index === 0} onclick={() => moveTopEight(index, -1)}>Up</button>
<button type="button" class="button button--small" aria-label={`Move ${handle} down`} disabled={index === topEightHandles.length - 1} onclick={() => moveTopEight(index, 1)}>Down</button>
<button type="button" class="button button--small" aria-label={`Remove ${handle} from Top 8`} onclick={() => removeTopEight(index)}>Remove</button>
</span>
</li>
{/each}
</ol>
{:else}
<p class="empty-note">You have not picked a Top 8 yet.</p>
{/if}
{#if topEightHandles.length < TOP_EIGHT_MAX}
<div class="top-eight-search">
<label class="visually-hidden" for="top-eight-search">Find someone for your Top 8</label>
<input id="top-eight-search" class="field-input" type="search" bind:value={topEightQuery} placeholder="@friend@server.example" />
<button type="button" class="button" disabled={topEightSearching || !topEightQuery.trim()} onclick={() => void searchTopEight()}>{topEightSearching ? 'Finding…' : 'Find person'}</button>
</div>
{#if topEightSearchError}<p class="error-note" role="alert">{topEightSearchError}</p>{/if}
{#if topEightResults.length > 0}
<ul class="top-eight-search-results">
{#each topEightResults as candidate (candidate.id)}
<li>
<button type="button" class="top-eight-result" onclick={() => addTopEight(candidate)}>
<img src={candidate.avatar_static || candidate.avatar} alt="" />
<span><strong>{candidate.display_name || candidate.username}</strong><br /><code>{fullHandle(candidate, instanceDomain(session.instance, session.host))}</code></span>
<span>Add</span>
</button>
</li>
{/each}
</ul>
{/if}
{/if}
<p class:field-error={bioCharactersLeft < 0} class="field-hint">
{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}
</p>
</fieldset>
{:else}
<p class="notice">
Top 8 editing is unavailable because your existing bio leaves too little room under this server's estimated profile limit.
</p>
{/if}
<fieldset class="profile-editor-images">
<legend>Profile images</legend>
@@ -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)
@@ -0,0 +1,51 @@
<script lang="ts">
import type { Account, CustomEmoji } from '$lib/api/types'
import { displayNameOf, profilePath } from '$lib/util/profile'
import Module from '../common/Module.svelte'
import Avatar from '../common/Avatar.svelte'
import EmojiText from '../common/EmojiText.svelte'
interface Props {
ownerName: string
ownerEmojis?: CustomEmoji[]
accounts: Account[]
viewAllHref: string
missing?: string[]
loading?: boolean
}
let {
ownerName,
ownerEmojis,
accounts,
viewAllHref,
missing = [],
loading = false,
}: Props = $props()
</script>
<Module title={`${ownerName}'s Top 8`} titleEmojis={ownerEmojis} variant="band" class="top-eight-space">
{#if loading && accounts.length === 0}
<p class="loading-note">Putting the Top 8 together&hellip;</p>
{:else}
<ul class="friend-grid friend-grid--compact top-eight-grid">
{#each accounts as friend (friend.id)}
<li class="friend-card" data-account={friend.acct}>
<a class="friend-card-link" href={profilePath(friend)}>
<EmojiText class="friend-card-name" text={displayNameOf(friend)} emojis={friend.emojis} />
<Avatar account={friend} plain size="friend" class="friend-card-photo" />
</a>
</li>
{/each}
</ul>
{#if missing.length > 0}
<p class="top-eight-missing muted">
Could not find {missing.join(', ')} from this server.
</p>
{/if}
{/if}
<p class="top-eight-view-all">
<a href={viewAllHref}>View All of <EmojiText text={ownerName} emojis={ownerEmojis} />'s Friends</a>
</p>
</Module>
+2
View File
@@ -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
+8 -2
View File
@@ -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,
}
+62
View File
@@ -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(
'<p><strong>Hello!</strong><br>My top 8:<br>1. <a href="https://example.test/@alice">@alice@example.test</a><br>@bob@remote.test</p><p><em>Still here.</em></p>',
)
expect(result.handles).toEqual(['@alice@example.test', '@bob@remote.test'])
expect(result.html).toContain('<strong>Hello!</strong>')
expect(result.html).toContain('<em>Still here.</em>')
expect(result.html).not.toContain('My top 8')
expect(result.html).not.toContain('@alice@example.test')
})
})
+180
View File
@@ -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.
}
}
+48
View File
@@ -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<Account[]>([])
let friendsLoading = $state(false)
let topEightAccounts = $state<Account[]>([])
let topEightMissing = $state<string[]>([])
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<void> {
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}
</Module>
{#if profile.topEightHandles.length > 0}
<TopEightSpace
ownerName={firstName}
ownerEmojis={account.emojis}
accounts={topEightAccounts}
viewAllHref={`#/@${account.acct}/friends`}
missing={topEightMissing}
loading={topEightLoading}
/>
{/if}
<FriendSpace
title={`${firstName}'s Friend Space`}
ownerName={firstName}
+40
View File
@@ -113,6 +113,46 @@ describe('Profile', () => {
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: '<p>Hello from my profile.<br>My top 8:<br>1. @bob<br>@carol@social.test</p>',
})
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 accounts own image attachments', async () => {
const ownPicture = status({
id: 'picture-entry',
+65
View File
@@ -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;
+40
View File
@@ -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 {