@atmo-dev/kit 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +99 -0
  2. package/dist/components/ExternalEmbed.svelte +174 -0
  3. package/dist/components/ExternalEmbed.svelte.d.ts +8 -0
  4. package/dist/components/GrainAlbumPicker.svelte +218 -0
  5. package/dist/components/GrainAlbumPicker.svelte.d.ts +14 -0
  6. package/dist/components/GrainAlbumView.svelte +94 -0
  7. package/dist/components/GrainAlbumView.svelte.d.ts +10 -0
  8. package/dist/components/NestedComments.svelte +31 -20
  9. package/dist/components/NestedComments.svelte.d.ts +4 -0
  10. package/dist/components/PhotoGrid.svelte +53 -0
  11. package/dist/components/PhotoGrid.svelte.d.ts +9 -0
  12. package/dist/components/PhotoLightbox.svelte +332 -0
  13. package/dist/components/PhotoLightbox.svelte.d.ts +12 -0
  14. package/dist/components/PostContent.svelte +78 -20
  15. package/dist/components/PostContent.svelte.d.ts +5 -0
  16. package/dist/components/SocialClientPicker.svelte +50 -0
  17. package/dist/components/SocialClientPicker.svelte.d.ts +8 -0
  18. package/dist/components/StreamplacePicker.svelte +94 -0
  19. package/dist/components/StreamplacePicker.svelte.d.ts +12 -0
  20. package/dist/components/UserProfile.svelte +151 -0
  21. package/dist/components/UserProfile.svelte.d.ts +20 -0
  22. package/dist/grain/types.d.ts +41 -0
  23. package/dist/grain/types.js +1 -0
  24. package/dist/grain/urls.d.ts +7 -0
  25. package/dist/grain/urls.js +74 -0
  26. package/dist/grain/xrpc.d.ts +3 -0
  27. package/dist/grain/xrpc.js +129 -0
  28. package/dist/grain.d.ts +3 -0
  29. package/dist/grain.js +2 -0
  30. package/dist/index.d.ts +23 -0
  31. package/dist/index.js +13 -0
  32. package/dist/lexicons/social.atmo.actor.preferredClient.json +29 -0
  33. package/dist/media.d.ts +10 -0
  34. package/dist/media.js +1 -0
  35. package/dist/social/external-embeds.d.ts +39 -0
  36. package/dist/social/external-embeds.js +107 -0
  37. package/dist/social/posts.d.ts +59 -17
  38. package/dist/social/posts.js +99 -12
  39. package/dist/social/preferred-client.d.ts +36 -0
  40. package/dist/social/preferred-client.js +48 -0
  41. package/dist/social/profile.d.ts +36 -0
  42. package/dist/social/profile.js +78 -0
  43. package/dist/social/urls.d.ts +7 -0
  44. package/dist/social/urls.js +18 -2
  45. package/dist/social.d.ts +5 -0
  46. package/dist/social.js +3 -0
  47. package/dist/streamplace.d.ts +6 -0
  48. package/dist/streamplace.js +1 -0
  49. package/package.json +18 -1
@@ -0,0 +1,50 @@
1
+ <script lang="ts" module>
2
+ import type { PreferredSocialClient } from '../social/preferred-client.js';
3
+ export type SocialClientPickerProps = {
4
+ value?: PreferredSocialClient | null;
5
+ disabled?: boolean;
6
+ };
7
+ </script>
8
+
9
+ <script lang="ts">
10
+ import { socialClients } from '../social/preferred-client.js';
11
+ import Select from './Select.svelte';
12
+ import Input from './Input.svelte';
13
+ import Label from './Label.svelte';
14
+
15
+ let { value = null, disabled = false }: SocialClientPickerProps = $props();
16
+ const id = $props.id();
17
+ let client: string = $derived(value?.client ?? 'bluesky');
18
+ let customUrl = $derived(value?.client === 'custom' ? value.customUrl : '');
19
+ </script>
20
+
21
+ <div class="grid gap-4">
22
+ <div class="grid gap-2">
23
+ <Label for={`${id}-client`}>Preferred social app</Label>
24
+ <Select id={`${id}-client`} name="client" bind:value={client} {disabled}>
25
+ {#each socialClients as option (option.id)}
26
+ <option value={option.id}>{option.label}</option>
27
+ {/each}
28
+ <option value="custom">Custom</option>
29
+ </Select>
30
+ </div>
31
+ {#if client === 'custom'}
32
+ <div class="grid gap-2">
33
+ <Label for={`${id}-url`}>Client URL</Label>
34
+ <Input
35
+ id={`${id}-url`}
36
+ name="customUrl"
37
+ type="url"
38
+ bind:value={customUrl}
39
+ {disabled}
40
+ required
41
+ maxlength={2048}
42
+ placeholder="https://your-client.example"
43
+ aria-describedby={`${id}-hint`}
44
+ />
45
+ <p id={`${id}-hint`} class="text-sm text-muted">
46
+ Use a web client with Bluesky-style profile links.
47
+ </p>
48
+ </div>
49
+ {/if}
50
+ </div>
@@ -0,0 +1,8 @@
1
+ import type { PreferredSocialClient } from '../social/preferred-client.js';
2
+ export type SocialClientPickerProps = {
3
+ value?: PreferredSocialClient | null;
4
+ disabled?: boolean;
5
+ };
6
+ declare const SocialClientPicker: import("svelte").Component<SocialClientPickerProps, {}, "">;
7
+ type SocialClientPicker = ReturnType<typeof SocialClientPicker>;
8
+ export default SocialClientPicker;
@@ -0,0 +1,94 @@
1
+ <script lang="ts">
2
+ import Button from './Button.svelte';
3
+ import Input from './Input.svelte';
4
+ import Modal from './Modal.svelte';
5
+ import { parseStreamplaceLink } from '../streamplace.js';
6
+ import type { StreamplaceSelection } from '../streamplace.js';
7
+
8
+ export type StreamplacePickerProps = {
9
+ open?: boolean;
10
+ actor?: string | null;
11
+ replacing?: boolean;
12
+ resolveHandle: (handle: string) => Promise<string>;
13
+ onselect: StreamplaceSelection;
14
+ class?: string;
15
+ };
16
+ let {
17
+ open = $bindable(false),
18
+ actor,
19
+ replacing = false,
20
+ resolveHandle,
21
+ onselect,
22
+ class: className
23
+ }: StreamplacePickerProps = $props();
24
+ let input = $state('');
25
+ let error = $state('');
26
+ let busy = $state(false);
27
+ let generation = 0;
28
+ const id = $props.id();
29
+ $effect(() => {
30
+ void open;
31
+ void actor;
32
+ generation++;
33
+ input = '';
34
+ error = '';
35
+ busy = false;
36
+ return () => {
37
+ generation++;
38
+ };
39
+ });
40
+ async function addStream(value: string) {
41
+ if (busy) return;
42
+ const stream = parseStreamplaceLink(value);
43
+ if (!stream) {
44
+ error = 'Enter a handle or Streamplace URL.';
45
+ return;
46
+ }
47
+ const session = generation;
48
+ busy = true;
49
+ error = '';
50
+ try {
51
+ if (!stream.actor.startsWith('did:')) await resolveHandle(stream.actor);
52
+ if (session !== generation) return;
53
+ const message = await onselect(stream);
54
+ if (session !== generation) return;
55
+ if (message) error = message;
56
+ else open = false;
57
+ } catch {
58
+ if (session === generation)
59
+ error = 'Couldn’t add that stream. Check the handle and try again.';
60
+ } finally {
61
+ if (session === generation) busy = false;
62
+ }
63
+ }
64
+ </script>
65
+
66
+ <Modal bind:open title="Add stream" class={className}>
67
+ {#if actor}<Button class="mb-5" disabled={busy} onclick={() => addStream(actor!)}
68
+ >{replacing ? 'Use my Streamplace link instead' : 'Add my Streamplace link'}</Button
69
+ >{/if}
70
+ <label for={`${id}-handle`} class="mb-2 block text-sm"
71
+ >Or enter another person’s handle or Streamplace URL</label
72
+ >
73
+ <div class="flex flex-col gap-2 sm:flex-row">
74
+ <Input
75
+ id={`${id}-handle`}
76
+ placeholder="handle.example"
77
+ bind:value={input}
78
+ class="min-w-0 flex-1"
79
+ onkeydown={(event) => {
80
+ if (event.key === 'Enter') {
81
+ event.preventDefault();
82
+ void addStream(input);
83
+ }
84
+ }}
85
+ />
86
+ <Button disabled={busy || !input.trim()} onclick={() => addStream(input)}
87
+ >{replacing ? 'Replace stream' : 'Add stream'}</Button
88
+ >
89
+ </div>
90
+ {#if replacing}<p class="mt-3 text-sm text-muted">
91
+ This replaces the current Streamplace link.
92
+ </p>{/if}
93
+ {#if error}<p class="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>{/if}
94
+ </Modal>
@@ -0,0 +1,12 @@
1
+ import type { StreamplaceSelection } from '../streamplace.js';
2
+ export type StreamplacePickerProps = {
3
+ open?: boolean;
4
+ actor?: string | null;
5
+ replacing?: boolean;
6
+ resolveHandle: (handle: string) => Promise<string>;
7
+ onselect: StreamplaceSelection;
8
+ class?: string;
9
+ };
10
+ declare const StreamplacePicker: import("svelte").Component<StreamplacePickerProps, {}, "open">;
11
+ type StreamplacePicker = ReturnType<typeof StreamplacePicker>;
12
+ export default StreamplacePicker;
@@ -0,0 +1,151 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils.js';
4
+ import {
5
+ profileDescriptionSegments,
6
+ safeProfileUrl,
7
+ visibleProfileLinks,
8
+ type ProfileData
9
+ } from '../social/profile.js';
10
+ import { socialProfileUrl } from '../social/urls.js';
11
+ import { socialClientInfo, type PreferredSocialClient } from '../social/preferred-client.js';
12
+ import Avatar from './Avatar.svelte';
13
+ import Button from './Button.svelte';
14
+
15
+ export type UserProfileProps = {
16
+ profile: ProfileData;
17
+ /** The viewer's choice, not the profile owner's. Defaults to Bluesky. */
18
+ socialClient?: PreferredSocialClient | null;
19
+ /** Route bio mentions to an app-owned profile instead of the social client. */
20
+ mentionUrl?: (identifier: string) => string;
21
+ actions?: Snippet;
22
+ /** App-owned metrics replace the default Bluesky counts, including an empty array. */
23
+ metrics?: { label: string; value: number }[];
24
+ class?: string;
25
+ };
26
+ let {
27
+ profile,
28
+ socialClient,
29
+ mentionUrl,
30
+ actions,
31
+ metrics,
32
+ class: className
33
+ }: UserProfileProps = $props();
34
+ let displayedMetrics = $derived(
35
+ metrics ??
36
+ [
37
+ { value: profile.followersCount, label: 'followers' },
38
+ { value: profile.followsCount, label: 'following' },
39
+ { value: profile.postsCount, label: 'posts' }
40
+ ].filter((metric) => metric.value !== undefined)
41
+ );
42
+ let name = $derived(profile.displayName || profile.handle || profile.did);
43
+ let href = $derived(socialProfileUrl(profile.did, socialClient));
44
+ let clientLabel = $derived(socialClientInfo(socialClient).label);
45
+ let failedBanner = $state<string>();
46
+ let bannerSrc = $derived(safeProfileUrl(profile.banner));
47
+ let banner = $derived(bannerSrc !== failedBanner ? bannerSrc : undefined);
48
+ let links = $derived(visibleProfileLinks(profile.links));
49
+ let failedLinkIcons = $state<Record<string, string>>({});
50
+ let pronouns = $derived(profile.pronouns?.trim().toLowerCase());
51
+ </script>
52
+
53
+ <section class={cn('min-w-0 text-ink', className)} aria-label="Profile">
54
+ {#if banner}
55
+ <div
56
+ data-slot="profile-banner"
57
+ class="relative aspect-3/1 w-full overflow-hidden rounded-panel bg-recess"
58
+ >
59
+ <img
60
+ src={banner}
61
+ alt=""
62
+ width="1500"
63
+ height="500"
64
+ class="absolute inset-0 size-full object-cover"
65
+ loading="eager"
66
+ fetchpriority="high"
67
+ decoding="async"
68
+ referrerpolicy="no-referrer"
69
+ onerror={(event) => (failedBanner = event.currentTarget.getAttribute('src') ?? undefined)}
70
+ />
71
+ </div>
72
+ {/if}
73
+ <div class={cn('flex flex-wrap items-start justify-between gap-4', banner ? 'px-4' : '')}>
74
+ <Avatar
75
+ {name}
76
+ src={safeProfileUrl(profile.avatar)}
77
+ size={96}
78
+ class={cn('relative size-20 sm:size-24', banner && '-mt-10 ring-4 ring-page sm:-mt-12')}
79
+ />
80
+ <div class={cn('flex flex-wrap items-center gap-2', banner && 'pt-3')}>
81
+ <Button {href} target="_blank" rel="noopener noreferrer" variant="secondary" size="sm">
82
+ View on {clientLabel}
83
+ </Button>
84
+ {@render actions?.()}
85
+ </div>
86
+ </div>
87
+ <div class="mt-4 min-w-0">
88
+ <h1 class="text-2xl font-semibold tracking-tight wrap-anywhere sm:text-3xl">
89
+ <bdi>{name}</bdi>
90
+ </h1>
91
+ <div class="mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted">
92
+ {#if profile.handle}<a
93
+ {href}
94
+ target="_blank"
95
+ rel="noopener noreferrer"
96
+ class="min-w-0 wrap-anywhere hover:underline"><bdi>@{profile.handle}</bdi></a
97
+ >{/if}
98
+ {#if pronouns}<span aria-label="Pronouns"><bdi>{pronouns}</bdi></span>{/if}
99
+ </div>
100
+ </div>
101
+ {#if displayedMetrics.length}
102
+ <dl aria-label="Profile statistics" class="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm">
103
+ {#each displayedMetrics as metric (metric.label)}
104
+ {#if metric.value !== undefined}<div class="flex gap-1">
105
+ <dd class="font-semibold">{metric.value.toLocaleString('en')}</dd>
106
+ <dt class="text-muted">{metric.label}</dt>
107
+ </div>{/if}
108
+ {/each}
109
+ </dl>
110
+ {/if}
111
+ {#if profile.description}
112
+ <p class="mt-4 text-sm leading-relaxed wrap-anywhere whitespace-pre-wrap">
113
+ {#each profileDescriptionSegments( profile.description, { socialClient, mentionUrl } ) as segment, index (index)}{#if segment.href}<a
114
+ href={segment.href}
115
+ target={segment.href.startsWith('/') ? undefined : '_blank'}
116
+ rel={segment.href.startsWith('/') ? undefined : 'noopener noreferrer nofollow ugc'}
117
+ class="text-action underline decoration-action/40 underline-offset-2 hover:decoration-action"
118
+ >{segment.text}</a
119
+ >{:else}{segment.text}{/if}{/each}
120
+ </p>
121
+ {/if}
122
+ {#if links.length}
123
+ <ul aria-label="Profile links" class="mt-4 flex flex-wrap gap-2">
124
+ {#each links as link (link.url)}
125
+ <li class="max-w-full min-w-0">
126
+ <a
127
+ href={link.url}
128
+ target="_blank"
129
+ rel="noopener noreferrer nofollow ugc"
130
+ class="inline-flex max-w-full items-center gap-2 rounded-ui border border-line px-3 py-1.5 text-sm hover:bg-recess"
131
+ >
132
+ {#if link.icon && failedLinkIcons[link.url] !== link.icon}
133
+ <img
134
+ src={link.icon}
135
+ alt=""
136
+ width="16"
137
+ height="16"
138
+ class="size-4 shrink-0 rounded-sm object-contain"
139
+ loading="lazy"
140
+ decoding="async"
141
+ referrerpolicy="no-referrer"
142
+ onerror={() => (failedLinkIcons[link.url] = link.icon ?? '')}
143
+ />
144
+ {/if}
145
+ <span class="min-w-0 wrap-anywhere">{link.title || new URL(link.url).hostname}</span>
146
+ </a>
147
+ </li>
148
+ {/each}
149
+ </ul>
150
+ {/if}
151
+ </section>
@@ -0,0 +1,20 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type ProfileData } from '../social/profile.js';
3
+ import { type PreferredSocialClient } from '../social/preferred-client.js';
4
+ export type UserProfileProps = {
5
+ profile: ProfileData;
6
+ /** The viewer's choice, not the profile owner's. Defaults to Bluesky. */
7
+ socialClient?: PreferredSocialClient | null;
8
+ /** Route bio mentions to an app-owned profile instead of the social client. */
9
+ mentionUrl?: (identifier: string) => string;
10
+ actions?: Snippet;
11
+ /** App-owned metrics replace the default Bluesky counts, including an empty array. */
12
+ metrics?: {
13
+ label: string;
14
+ value: number;
15
+ }[];
16
+ class?: string;
17
+ };
18
+ declare const UserProfile: import("svelte").Component<UserProfileProps, {}, "">;
19
+ type UserProfile = ReturnType<typeof UserProfile>;
20
+ export default UserProfile;
@@ -0,0 +1,41 @@
1
+ export type GrainReference = {
2
+ actor: string;
3
+ rkey: string;
4
+ atUri: string;
5
+ url: string;
6
+ };
7
+ export type GrainAlbum = {
8
+ uri: string;
9
+ url: string;
10
+ title: string;
11
+ creator: {
12
+ did: string;
13
+ handle: string;
14
+ displayName?: string;
15
+ };
16
+ photoCount: number;
17
+ cover?: string;
18
+ contentWarning: boolean;
19
+ };
20
+ export type GrainPhoto = {
21
+ uri: string;
22
+ thumb: string;
23
+ fullsize: string;
24
+ alt: string;
25
+ width?: number;
26
+ height?: number;
27
+ contentWarning: boolean;
28
+ };
29
+ export type GrainGallery = GrainAlbum & {
30
+ photos: GrainPhoto[];
31
+ };
32
+ export type GrainAlbumPage = {
33
+ albums: GrainAlbum[];
34
+ cursor?: string;
35
+ };
36
+ /** Normalized public reads. Implement with Grain XRPC today, or atmo.pics/Contrail later. */
37
+ export type GrainClient = {
38
+ getGallery(reference: string): Promise<GrainGallery>;
39
+ listAlbums(actor: string, cursor?: string): Promise<GrainAlbumPage>;
40
+ };
41
+ export type GrainSelection = (album: GrainAlbum) => void | string | Promise<void | string>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { GrainReference } from './types.js';
2
+ export declare function isLinkedActor(value: string): boolean;
3
+ export declare function parseGrainLink(input: string): GrainReference | null;
4
+ export declare function parseStreamplaceLink(input: string): {
5
+ actor: string;
6
+ url: string;
7
+ } | null;
@@ -0,0 +1,74 @@
1
+ export function isLinkedActor(value) {
2
+ if (value.length > 253)
3
+ return false;
4
+ return (/^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(value) ||
5
+ /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z][a-zA-Z0-9-]*$/.test(value));
6
+ }
7
+ export function parseGrainLink(input) {
8
+ try {
9
+ const raw = input.trim();
10
+ let actor;
11
+ let rkey;
12
+ if (raw.startsWith('at://')) {
13
+ const match = /^at:\/\/([^/]+)\/social\.grain\.gallery\/([^/?#]+)$/.exec(raw);
14
+ if (!match)
15
+ return null;
16
+ [, actor, rkey] = match;
17
+ }
18
+ else {
19
+ const url = new URL(/^grain\.social\//i.test(raw) ? `https://${raw}` : raw);
20
+ if (!['https:', 'http:'].includes(url.protocol) ||
21
+ url.hostname !== 'grain.social' ||
22
+ url.username ||
23
+ url.password ||
24
+ url.port)
25
+ return null;
26
+ const match = /^\/profile\/([^/]+)\/gallery\/([^/]+)\/?$/.exec(url.pathname);
27
+ if (!match)
28
+ return null;
29
+ actor = decodeURIComponent(match[1]);
30
+ rkey = decodeURIComponent(match[2]);
31
+ }
32
+ if (!isLinkedActor(actor) ||
33
+ !/^[A-Za-z0-9._~:-]{1,512}$/.test(rkey) ||
34
+ ['.', '..'].includes(rkey))
35
+ return null;
36
+ if (!actor.startsWith('did:'))
37
+ actor = actor.toLowerCase();
38
+ return {
39
+ actor,
40
+ rkey,
41
+ atUri: `at://${actor}/social.grain.gallery/${rkey}`,
42
+ url: `https://grain.social/profile/${actor.replaceAll('%', '%25')}/gallery/${rkey}`
43
+ };
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ export function parseStreamplaceLink(input) {
50
+ try {
51
+ let actor = input.trim().replace(/^@/, '');
52
+ if (/^(?:https?:\/\/|stream\.place\/)/i.test(actor)) {
53
+ const url = new URL(/^stream\.place\//i.test(actor) ? `https://${actor}` : actor);
54
+ if (!['https:', 'http:'].includes(url.protocol) ||
55
+ url.hostname !== 'stream.place' ||
56
+ url.username ||
57
+ url.password ||
58
+ url.port)
59
+ return null;
60
+ const match = /^\/([^/]+)\/?$/.exec(url.pathname);
61
+ if (!match)
62
+ return null;
63
+ actor = decodeURIComponent(match[1]);
64
+ }
65
+ if (!isLinkedActor(actor))
66
+ return null;
67
+ if (!actor.startsWith('did:'))
68
+ actor = actor.toLowerCase();
69
+ return { actor, url: `https://stream.place/${actor.replaceAll('%', '%25')}` };
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
@@ -0,0 +1,3 @@
1
+ import type { GrainClient } from './types.js';
2
+ /** Current Grain.social XRPC implementation; not a configurable proxy or an app session. */
3
+ export declare function createGrainXrpcClient(fetchImpl?: typeof fetch): GrainClient;
@@ -0,0 +1,129 @@
1
+ import { isLinkedActor, parseGrainLink } from './urls.js';
2
+ // Grain's unspecced API is deliberately isolated here; never proxy a user-supplied host.
3
+ const ORIGIN = 'https://grain.social';
4
+ function object(value) {
5
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
6
+ }
7
+ function text(value, fallback = '') {
8
+ return typeof value === 'string' ? value : fallback;
9
+ }
10
+ function image(value) {
11
+ try {
12
+ const url = new URL(text(value));
13
+ return url.protocol === 'https:' &&
14
+ url.hostname === 'cdn.grain.social' &&
15
+ !url.username &&
16
+ !url.password &&
17
+ !url.port
18
+ ? url.href
19
+ : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ function warned(value) {
26
+ if (value == null)
27
+ return false;
28
+ if (typeof value === 'string')
29
+ return value.length > 0;
30
+ const labels = Array.isArray(value) ? value : object(value).values;
31
+ // Unknown nonempty label shapes are treated conservatively too.
32
+ if (!Array.isArray(labels))
33
+ return Object.keys(object(value)).length > 0;
34
+ return labels.some((label) => object(label).neg !== true);
35
+ }
36
+ function photo(value) {
37
+ const p = object(value);
38
+ const thumb = image(p.thumb), fullsize = image(p.fullsize);
39
+ if (!thumb || !fullsize || !text(p.uri).startsWith('at://'))
40
+ return null;
41
+ const ratio = object(p.aspectRatio);
42
+ return {
43
+ uri: text(p.uri),
44
+ thumb,
45
+ fullsize,
46
+ alt: text(p.alt),
47
+ ...(typeof ratio.width === 'number' && ratio.width > 0 ? { width: ratio.width } : {}),
48
+ ...(typeof ratio.height === 'number' && ratio.height > 0 ? { height: ratio.height } : {}),
49
+ contentWarning: warned(p.labels) || warned(object(p.record).labels)
50
+ };
51
+ }
52
+ function album(value) {
53
+ const g = object(value), record = object(g.record), creator = object(g.creator);
54
+ const ref = parseGrainLink(text(g.uri));
55
+ const did = text(creator.did);
56
+ if (!ref ||
57
+ !ref.actor.startsWith('did:') ||
58
+ ref.actor !== did ||
59
+ !isLinkedActor(did) ||
60
+ !Array.isArray(g.items)) {
61
+ throw new Error('Invalid Grain gallery');
62
+ }
63
+ const contentWarning = warned(record.labels) || warned(g.labels) || warned(creator.labels);
64
+ const cover = g.items.map(photo).find((p) => p && !p.contentWarning)?.thumb;
65
+ return {
66
+ uri: ref.atUri,
67
+ url: ref.url,
68
+ title: text(g.title, text(record.title, 'Untitled album')),
69
+ creator: {
70
+ did,
71
+ handle: text(creator.handle, did),
72
+ displayName: text(creator.displayName) || undefined
73
+ },
74
+ photoCount: g.items.length,
75
+ contentWarning,
76
+ ...(!contentWarning && cover ? { cover } : {})
77
+ };
78
+ }
79
+ /** Current Grain.social XRPC implementation; not a configurable proxy or an app session. */
80
+ export function createGrainXrpcClient(fetchImpl = fetch) {
81
+ async function request(method, params) {
82
+ const url = new URL(`/xrpc/${method}`, ORIGIN);
83
+ url.search = new URLSearchParams(params).toString();
84
+ const response = await fetchImpl(url, {
85
+ headers: { accept: 'application/json' },
86
+ signal: AbortSignal.timeout(5000),
87
+ redirect: 'error'
88
+ });
89
+ if (!response.ok)
90
+ throw new Error(`Grain unavailable (${response.status})`);
91
+ return object(await response.json());
92
+ }
93
+ return {
94
+ async getGallery(reference) {
95
+ const ref = parseGrainLink(reference);
96
+ if (!ref)
97
+ throw new Error('Invalid Grain reference');
98
+ const data = await request('social.grain.unspecced.getGallery', { gallery: ref.atUri });
99
+ const result = album(data.gallery);
100
+ const canonical = parseGrainLink(result.uri);
101
+ if (canonical.rkey !== ref.rkey ||
102
+ (ref.actor.startsWith('did:') && canonical.actor !== ref.actor))
103
+ throw new Error('Mismatched Grain gallery');
104
+ const photos = object(data.gallery).items
105
+ .map(photo)
106
+ .filter((p) => p !== null);
107
+ return { ...result, photos };
108
+ },
109
+ async listAlbums(actor, cursor) {
110
+ if (!isLinkedActor(actor))
111
+ throw new Error('Invalid actor');
112
+ const data = await request('dev.hatk.getFeed', {
113
+ feed: 'actor',
114
+ actor,
115
+ limit: '24',
116
+ ...(cursor ? { cursor } : {})
117
+ });
118
+ if (!Array.isArray(data.items))
119
+ throw new Error('Invalid Grain feed');
120
+ const albums = data.items
121
+ .map(album)
122
+ .filter((g) => !actor.startsWith('did:') || g.creator.did === actor);
123
+ return {
124
+ albums,
125
+ ...(typeof data.cursor === 'string' && data.cursor ? { cursor: data.cursor } : {})
126
+ };
127
+ }
128
+ };
129
+ }
@@ -0,0 +1,3 @@
1
+ export type { GrainClient, GrainAlbum, GrainGallery, GrainPhoto, GrainAlbumPage, GrainReference, GrainSelection } from './grain/types.js';
2
+ export { parseGrainLink, isLinkedActor } from './grain/urls.js';
3
+ export { createGrainXrpcClient } from './grain/xrpc.js';
package/dist/grain.js ADDED
@@ -0,0 +1,2 @@
1
+ export { parseGrainLink, isLinkedActor } from './grain/urls.js';
2
+ export { createGrainXrpcClient } from './grain/xrpc.js';
package/dist/index.d.ts CHANGED
@@ -5,6 +5,9 @@ export { default as Input } from './components/Input.svelte';
5
5
  export type { InputProps } from './components/Input.svelte';
6
6
  export { default as Textarea } from './components/Textarea.svelte';
7
7
  export { default as Select } from './components/Select.svelte';
8
+ export { default as SocialClientPicker } from './components/SocialClientPicker.svelte';
9
+ export type { SocialClientPickerProps } from './components/SocialClientPicker.svelte';
10
+ export * from './social.js';
8
11
  export { default as Checkbox } from './components/Checkbox.svelte';
9
12
  export { default as Label } from './components/Label.svelte';
10
13
  export { default as PageTitle } from './components/PageTitle.svelte';
@@ -12,6 +15,23 @@ export { default as Heading } from './components/Heading.svelte';
12
15
  export { default as Subheading } from './components/Subheading.svelte';
13
16
  export { default as Surface } from './components/Surface.svelte';
14
17
  export { default as Avatar } from './components/Avatar.svelte';
18
+ export { default as UserProfile } from './components/UserProfile.svelte';
19
+ export type { UserProfileProps } from './components/UserProfile.svelte';
20
+ export { blueskyProfileToProfileData } from './social/profile.js';
21
+ export type { ProfileData, ProfileLink } from './social/profile.js';
22
+ export { default as PhotoLightbox } from './components/PhotoLightbox.svelte';
23
+ export type { PhotoLightboxProps } from './components/PhotoLightbox.svelte';
24
+ export { default as PhotoGrid } from './components/PhotoGrid.svelte';
25
+ export type { PhotoGridProps } from './components/PhotoGrid.svelte';
26
+ export type { Photo } from './media.js';
27
+ export { default as GrainAlbumPicker } from './components/GrainAlbumPicker.svelte';
28
+ export type { GrainAlbumPickerProps } from './components/GrainAlbumPicker.svelte';
29
+ export { default as GrainAlbumView } from './components/GrainAlbumView.svelte';
30
+ export type { GrainAlbumViewProps } from './components/GrainAlbumView.svelte';
31
+ export { default as StreamplacePicker } from './components/StreamplacePicker.svelte';
32
+ export type { StreamplacePickerProps } from './components/StreamplacePicker.svelte';
33
+ export * from './grain.js';
34
+ export * from './streamplace.js';
15
35
  export { default as InsetImage } from './components/InsetImage.svelte';
16
36
  export { default as Badge } from './components/Badge.svelte';
17
37
  export { default as Modal } from './components/Modal.svelte';
@@ -53,6 +73,9 @@ export { default as InlineEditor } from './components/InlineEditor.svelte';
53
73
  export { default as PostComposer } from './components/PostComposer.svelte';
54
74
  export { default as NestedComments } from './components/NestedComments.svelte';
55
75
  export { default as PostContent } from './components/PostContent.svelte';
76
+ export { default as ExternalEmbed } from './components/ExternalEmbed.svelte';
77
+ export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
78
+ export type { ExternalPostEmbed, ExternalEmbedProvider, ExternalEmbedPlayer } from './social/external-embeds.js';
56
79
  export { blueskyPostToPostData } from './social/posts.js';
57
80
  export type { PostData, PostEmbed, BlueskyPostView } from './social/posts.js';
58
81
  export { editorJsonToBlueskyPost, textToDoc, graphemeLength } from './social/richtext.js';