/ src / lib / components / AvatarListContainer.svelte
AvatarListContainer.svelte
  1  <script lang="ts">
  2  	import type { MastodonAccount } from '$lib/mastodon';
  3  	import Avatar from './Avatar.svelte';
  4  	import AvatarTooltip from './AvatarTooltip.svelte';
  5  	import {
  6  		groupByLastStatus,
  7  		groupByCreated,
  8  		groupByFollowers,
  9  		groupByServer,
 10  		groupByMoved
 11  	} from '$lib/utils/grouping';
 12  	import { tick } from 'svelte';
 13  	import { page } from '$app/state';
 14  	import { friendkit } from '$lib/store.svelte';
 15  	import { resolve } from '$app/paths';
 16  	import { getFullHandle } from '$lib/mastodon';
 17  	import { scale, fade } from 'svelte/transition';
 18  	import { createSwarm } from './useSwarm.svelte';
 19  	import { accessibility } from '$lib/accessibility.svelte';
 20  	import FilterOptOutModal from './FilterOptOutModal.svelte';
 21  
 22  	interface Props {
 23  		accounts: MastodonAccount[];
 24  		groupBy: 'none' | 'last_status' | 'created' | 'followers' | 'server' | 'moved';
 25  		sortDirection?: 'asc' | 'desc';
 26  		currentView: string;
 27  		emptyMessage?: string;
 28  	}
 29  
 30  	let {
 31  		accounts,
 32  		groupBy,
 33  		sortDirection = 'desc',
 34  		currentView,
 35  		emptyMessage = 'No accounts found.'
 36  	}: Props = $props();
 37  
 38  	// -------------------------------------------------------------------------
 39  	// Accessibility
 40  	// -------------------------------------------------------------------------
 41  	const isReducedMotion = $derived.by(
 42  		() => accessibility.prefersReducedMotion || friendkit.reducedMotion
 43  	);
 44  
 45  	// -------------------------------------------------------------------------
 46  	// Swarm Engine
 47  	// -------------------------------------------------------------------------
 48  	const swarm = createSwarm({
 49  		getAccounts: () => accounts,
 50  		getPrivacyBlur: () => friendkit.privacyBlur
 51  	});
 52  
 53  	let containerEl = $state<HTMLElement>();
 54  	$effect(() => {
 55  		swarm.containerEl = containerEl;
 56  	});
 57  
 58  	// Re-sync whenever accounts or layout-affecting props change.
 59  	$effect(() => {
 60  		void accounts;
 61  		void groupBy;
 62  		void sortDirection;
 63  		void swarm.avatarSize;
 64  		void friendkit.privacyBlur;
 65  
 66  		tick().then(() => swarm.sync());
 67  	});
 68  
 69  	// -------------------------------------------------------------------------
 70  	// Grouping
 71  	// -------------------------------------------------------------------------
 72  	const groups = $derived.by(() => {
 73  		if (groupBy === 'last_status') return groupByLastStatus(accounts, sortDirection);
 74  		if (groupBy === 'created') return groupByCreated(accounts, sortDirection);
 75  		if (groupBy === 'followers') return groupByFollowers(accounts, sortDirection);
 76  		if (groupBy === 'server') return groupByServer(accounts, sortDirection);
 77  		if (groupBy === 'moved') return groupByMoved(accounts, sortDirection);
 78  		return null;
 79  	});
 80  
 81  	// -------------------------------------------------------------------------
 82  	// Tooltip state
 83  	// -------------------------------------------------------------------------
 84  	let tooltipAccount = $state<MastodonAccount | null>(null);
 85  	let tooltipX = $state(0);
 86  	let tooltipY = $state(0);
 87  	let tooltipVisible = $state(false);
 88  
 89  	function handlePlaceholderEnter(account: MastodonAccount, el: HTMLElement) {
 90  		const rect = el.getBoundingClientRect();
 91  		tooltipX = rect.left + rect.width / 2;
 92  		tooltipY = rect.top - 10;
 93  		tooltipAccount = account;
 94  		tooltipVisible = true;
 95  	}
 96  
 97  	// -------------------------------------------------------------------------
 98  	// Filter Opt-out Modal state
 99  	// -------------------------------------------------------------------------
100  	let restrictedAccount = $state<MastodonAccount | null>(null);
101  
102  	function handleAccountClick(e: MouseEvent, account: MastodonAccount) {
103  		if ((account as any)._filterRestricted) {
104  			e.preventDefault();
105  			restrictedAccount = account;
106  		}
107  	}
108  
109  	function getAccountUrl(account: MastodonAccount) {
110  		if ((account as any)._filterRestricted) return '#';
111  		return resolve(
112  			`/${currentView}/${getFullHandle(account, friendkit.instance)}${page.url.search}`
113  		);
114  	}
115  </script>
116  
117  <!--
118    The container is `position:relative` so absolutely-positioned display icons
119    are placed relative to it.  Placeholder <a> elements are invisible but live
120    in normal flow — they define the layout, handle keyboard navigation, and fire
121    hover events for the tooltip.  Display icons are aria-hidden + pointer-events:none.
122  -->
123  <div class="fk-avatar-list" bind:this={containerEl}>
124  	{#if accounts.length === 0}
125  		<p class="fk-avatar-list__empty">{emptyMessage}</p>
126  	{:else if groups === null}
127  		<!-- Flat (ungrouped) grid — used for Name sort -->
128  		<ul class="fk-avatar-list__grid">
129  			{#each accounts as account (account.id)}
130  				<li>
131  					<a
132  						use:swarm.registerPlaceholder={account.id}
133  						href={getAccountUrl(account)}
134  						class="fk-avatar-list__placeholder"
135  						onmouseenter={(e: MouseEvent) =>
136  							handlePlaceholderEnter(account, e.currentTarget as HTMLElement)}
137  						onmouseleave={() => (tooltipVisible = false)}
138  						onclick={(e) => handleAccountClick(e, account)}
139  						aria-label={(account as any)._filterRestricted
140  							? 'Restricted filter result'
141  							: account.display_name || account.username}
142  					></a>
143  				</li>
144  			{/each}
145  		</ul>
146  	{:else}
147  		<!-- Grouped sections -->
148  		{#each groups as group (group.label)}
149  			<section class="fk-avatar-list__section">
150  				<h3 class="fk-avatar-list__section-label">{group.label}</h3>
151  				<ul class="fk-avatar-list__grid">
152  					{#each group.accounts as account (account.id)}
153  						<li>
154  							<a
155  								use:swarm.registerPlaceholder={account.id}
156  								href={getAccountUrl(account)}
157  								class="fk-avatar-list__placeholder"
158  								onmouseenter={(e: MouseEvent) =>
159  									handlePlaceholderEnter(account, e.currentTarget as HTMLElement)}
160  								onmouseleave={() => (tooltipVisible = false)}
161  								onclick={(e) => handleAccountClick(e, account)}
162  								aria-label={(account as any)._filterRestricted
163  									? 'Restricted filter result'
164  									: account.display_name || account.username}
165  							></a>
166  						</li>
167  					{/each}
168  				</ul>
169  			</section>
170  		{/each}
171  	{/if}
172  
173  	<!-- Display layer: absolutely-positioned avatar images. -->
174  	{#each swarm.icons.values() as icon, i (icon.account.id)}
175  		{#if icon.inViewport || icon.exiting}
176  			{#if isReducedMotion}
177  				<div
178  					transition:fade={{ duration: 400 }}
179  					class="fk-avatar-list__display-item"
180  					style="translate: {icon.x}px {icon.y}px;"
181  				>
182  					<Avatar
183  						src={icon.account.avatar}
184  						size={swarm.avatarSize}
185  						moved={!!icon.account.moved}
186  						blur={icon.blur}
187  						hue={(i * 137) % 360}
188  						restricted={(icon.account as any)._filterRestricted}
189  						class="fk-avatar-list__display-icon {icon.visible && !icon.exiting
190  							? 'fk-avatar-list__display-icon--visible'
191  							: ''}"
192  						aria-hidden="true"
193  					/>
194  				</div>
195  			{:else}
196  				<div
197  					transition:scale={{ duration: 400, start: 0.4, delay: (i % 20) * 25 }}
198  					class="fk-avatar-list__display-item"
199  					style="translate: {icon.x}px {icon.y}px;"
200  				>
201  					<Avatar
202  						src={icon.account.avatar}
203  						size={swarm.avatarSize}
204  						moved={!!icon.account.moved}
205  						blur={icon.blur}
206  						hue={(i * 137) % 360}
207  						restricted={(icon.account as any)._filterRestricted}
208  						seed={icon.account.id}
209  						class="fk-avatar-list__display-icon {icon.visible && !icon.exiting
210  							? 'fk-avatar-list__display-icon--visible'
211  							: ''}"
212  						aria-hidden="true"
213  					/>
214  				</div>
215  			{/if}
216  		{/if}
217  	{/each}
218  
219  	<AvatarTooltip account={tooltipAccount} x={tooltipX} y={tooltipY} bind:visible={tooltipVisible} />
220  
221  	{#if restrictedAccount}
222  		<FilterOptOutModal account={restrictedAccount} onClose={() => (restrictedAccount = null)} />
223  	{/if}
224  </div>
225  
226  <style>
227  	.fk-avatar-list {
228  		position: relative;
229  		margin-top: 0;
230  		display: flex;
231  		flex-direction: column;
232  		gap: 0;
233  		/* Allow tooltips + exiting icons to overflow the border. */
234  		overflow: visible;
235  
236  		--grid-gap: 1.5rem;
237  		--avatar-size: 80px;
238  	}
239  
240  	@media (max-width: 640px) {
241  		.fk-avatar-list {
242  			--grid-gap: 1rem;
243  			--avatar-size: calc((100% - (3 * var(--grid-gap))) / 4);
244  		}
245  	}
246  
247  	.fk-avatar-list__empty {
248  		text-align: center;
249  		padding: 3rem;
250  		opacity: 0.5;
251  	}
252  
253  	.fk-avatar-list__placeholder {
254  		display: block;
255  		width: 100%;
256  		aspect-ratio: 1 / 1;
257  		opacity: 0;
258  		border-radius: 50%;
259  		-webkit-tap-highlight-color: transparent;
260  		cursor: pointer;
261  	}
262  
263  	.fk-avatar-list__grid li {
264  		width: var(--avatar-size);
265  		aspect-ratio: 1 / 1;
266  		flex-shrink: 0;
267  	}
268  
269  	/* Make focus ring visible for keyboard users. */
270  	.fk-avatar-list__placeholder:focus-visible {
271  		opacity: 1;
272  		outline: 2px solid var(--akui-bg-accent);
273  		outline-offset: 3px;
274  	}
275  
276  	.fk-avatar-list__grid {
277  		display: flex;
278  		flex-wrap: wrap;
279  		align-items: flex-start;
280  		gap: var(--grid-gap);
281  		list-style: none;
282  		padding: 0;
283  		margin: 0;
284  	}
285  
286  	/* Desktop grid - auto-fill based on avatar size */
287  	@media (min-width: 640px) {
288  		.fk-avatar-list__grid {
289  			grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
290  		}
291  	}
292  
293  	/* Flat grid */
294  	.fk-avatar-list > .fk-avatar-list__grid {
295  		justify-content: center;
296  	}
297  
298  	/* Grouped section */
299  	.fk-avatar-list__section {
300  		padding: 1.5rem 0 2rem;
301  	}
302  
303  	.fk-avatar-list__section-label {
304  		font-size: 0.7rem;
305  		font-weight: 700;
306  		letter-spacing: 0.1em;
307  		text-transform: uppercase;
308  		opacity: 0.45;
309  		margin: 0 0 1.25rem;
310  		color: var(--akui-fg);
311  		padding-bottom: 0.5rem;
312  		border-bottom: 1px solid rgba(var(--akui-fg-rgb), 0.08);
313  	}
314  
315  	.fk-avatar-list__display-item {
316  		position: absolute !important;
317  		top: 0;
318  		left: 0;
319  		pointer-events: none;
320  		will-change: translate;
321  		transition: translate 0.35s cubic-bezier(0.4, 0, 0.2, 1);
322  	}
323  
324  	/* Disable movement transitions for reduced motion. Opacity is kept as per user request. */
325  	:global([data-reduced-motion='true']) .fk-avatar-list__display-item {
326  		transition: none !important;
327  	}
328  
329  	:global(.fk-avatar-list__display-icon) {
330  		pointer-events: none;
331  		/* box-shadow is significantly faster for the compositor to cache than drop-shadow filters */
332  		box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
333  		border-radius: 50%;
334  
335  		/* We rely on Svelte transitions for entrance/exit; CSS transitions here 
336  		   caused "layer flapping" and redundant Animation events in the trace. */
337  		will-change: opacity, transform;
338  
339  		/* Default (not visible): scaled down + transparent */
340  		opacity: 0;
341  		scale: 0.6;
342  		transition:
343  			opacity 0.3s ease,
344  			scale 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
345  	}
346  
347  	/* Disable scale transitions for reduced motion, keeping opacity. */
348  	:global([data-reduced-motion='true']) :global(.fk-avatar-list__display-icon) {
349  		transition: opacity 0.3s ease !important;
350  		scale: 1 !important;
351  	}
352  
353  	:global(.fk-avatar-list__display-icon--visible) {
354  		opacity: 1 !important;
355  		scale: 1 !important;
356  	}
357  </style>