@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
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ export { default as IconButton } from './components/IconButton.svelte';
3
3
  export { default as Input } from './components/Input.svelte';
4
4
  export { default as Textarea } from './components/Textarea.svelte';
5
5
  export { default as Select } from './components/Select.svelte';
6
+ export { default as SocialClientPicker } from './components/SocialClientPicker.svelte';
7
+ export * from './social.js';
6
8
  export { default as Checkbox } from './components/Checkbox.svelte';
7
9
  export { default as Label } from './components/Label.svelte';
8
10
  export { default as PageTitle } from './components/PageTitle.svelte';
@@ -10,6 +12,15 @@ export { default as Heading } from './components/Heading.svelte';
10
12
  export { default as Subheading } from './components/Subheading.svelte';
11
13
  export { default as Surface } from './components/Surface.svelte';
12
14
  export { default as Avatar } from './components/Avatar.svelte';
15
+ export { default as UserProfile } from './components/UserProfile.svelte';
16
+ export { blueskyProfileToProfileData } from './social/profile.js';
17
+ export { default as PhotoLightbox } from './components/PhotoLightbox.svelte';
18
+ export { default as PhotoGrid } from './components/PhotoGrid.svelte';
19
+ export { default as GrainAlbumPicker } from './components/GrainAlbumPicker.svelte';
20
+ export { default as GrainAlbumView } from './components/GrainAlbumView.svelte';
21
+ export { default as StreamplacePicker } from './components/StreamplacePicker.svelte';
22
+ export * from './grain.js';
23
+ export * from './streamplace.js';
13
24
  export { default as InsetImage } from './components/InsetImage.svelte';
14
25
  export { default as Badge } from './components/Badge.svelte';
15
26
  export { default as Modal } from './components/Modal.svelte';
@@ -38,6 +49,8 @@ export { default as InlineEditor } from './components/InlineEditor.svelte';
38
49
  export { default as PostComposer } from './components/PostComposer.svelte';
39
50
  export { default as NestedComments } from './components/NestedComments.svelte';
40
51
  export { default as PostContent } from './components/PostContent.svelte';
52
+ export { default as ExternalEmbed } from './components/ExternalEmbed.svelte';
53
+ export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
41
54
  export { blueskyPostToPostData } from './social/posts.js';
42
55
  export { editorJsonToBlueskyPost, textToDoc, graphemeLength } from './social/richtext.js';
43
56
  export { launchConfetti } from './confetti.js';
@@ -0,0 +1,29 @@
1
+ {
2
+ "lexicon": 1,
3
+ "id": "social.atmo.actor.preferredClient",
4
+ "defs": {
5
+ "main": {
6
+ "type": "record",
7
+ "description": "An actor's public preferred Bluesky-compatible social client. Other preferences belong in separate records.",
8
+ "key": "literal:self",
9
+ "record": {
10
+ "type": "object",
11
+ "required": ["client"],
12
+ "properties": {
13
+ "client": {
14
+ "type": "string",
15
+ "description": "The preferred social client. custom requires customUrl.",
16
+ "knownValues": ["bluesky", "mu", "blacksky", "witchsky", "custom"],
17
+ "maxLength": 64
18
+ },
19
+ "customUrl": {
20
+ "type": "string",
21
+ "format": "uri",
22
+ "maxLength": 2048,
23
+ "description": "For custom clients only: an absolute HTTP(S) base URL without credentials, query or fragment. Must support /profile/:actor links."
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,10 @@
1
+ /** Provider-neutral images; callers own URL validation, hydration and moderation policy. */
2
+ export type Photo = {
3
+ id?: string;
4
+ src: string;
5
+ thumbnail?: string;
6
+ alt?: string;
7
+ width?: number;
8
+ height?: number;
9
+ contentWarning?: boolean;
10
+ };
package/dist/media.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { PostEmbed } from './posts.js';
2
+ export type ExternalPostEmbed = Extract<PostEmbed, {
3
+ type: 'external';
4
+ }>;
5
+ /** Iframes are always click-to-load. Providers perform no network IO. */
6
+ export type ExternalEmbedPlayer = {
7
+ type: 'iframe';
8
+ src: string;
9
+ aspectRatio?: number;
10
+ } | {
11
+ type: 'video';
12
+ sources: readonly {
13
+ src: string;
14
+ type: string;
15
+ }[];
16
+ aspectRatio?: number;
17
+ autoplay?: boolean;
18
+ controls?: boolean;
19
+ } | {
20
+ type: 'image';
21
+ src: string;
22
+ aspectRatio?: number;
23
+ autoplay?: boolean;
24
+ };
25
+ export type ExternalEmbedProvider = {
26
+ id: string;
27
+ label: string;
28
+ /** Trusted app-owned code. Return null for unsupported URLs; never return arbitrary embed HTML. */
29
+ resolve: (url: URL, embed: ExternalPostEmbed) => ExternalEmbedPlayer | null;
30
+ };
31
+ export declare const youtubeEmbedProvider: ExternalEmbedProvider;
32
+ /** Bluesky's Klipy GIF links carry video slugs and dimensions in query parameters. */
33
+ export declare const klipyEmbedProvider: ExternalEmbedProvider;
34
+ /** Ordered, explicit registry. Pass [] to keep all external links as ordinary cards. */
35
+ export declare const defaultExternalEmbedProviders: readonly ExternalEmbedProvider[];
36
+ export declare function resolveExternalEmbed(embed: ExternalPostEmbed, providers?: readonly ExternalEmbedProvider[]): {
37
+ provider: ExternalEmbedProvider;
38
+ player: ExternalEmbedPlayer;
39
+ } | null;
@@ -0,0 +1,107 @@
1
+ import { safeWebUrl } from './richtext.js';
2
+ function youtubeStart(value) {
3
+ if (!value)
4
+ return 0;
5
+ if (/^\d+$/.test(value))
6
+ return Math.min(Number(value), 2147483647);
7
+ const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value);
8
+ return match
9
+ ? Math.min(Number(match[1] ?? 0) * 3600 + Number(match[2] ?? 0) * 60 + Number(match[3] ?? 0), 2147483647)
10
+ : 0;
11
+ }
12
+ export const youtubeEmbedProvider = {
13
+ id: 'youtube',
14
+ label: 'YouTube',
15
+ resolve(url) {
16
+ let id;
17
+ let shorts = false;
18
+ if (url.hostname === 'youtu.be') {
19
+ id = /^\/([\w-]{11})\/?$/.exec(url.pathname)?.[1];
20
+ }
21
+ else if ([
22
+ 'youtube.com',
23
+ 'www.youtube.com',
24
+ 'm.youtube.com',
25
+ 'www.youtube-nocookie.com',
26
+ 'youtube-nocookie.com'
27
+ ].includes(url.hostname)) {
28
+ if (url.pathname === '/watch')
29
+ id = url.searchParams.get('v') ?? undefined;
30
+ else {
31
+ const match = /^\/(shorts|live|embed)\/([\w-]{11})\/?$/.exec(url.pathname);
32
+ id = match?.[2];
33
+ shorts = match?.[1] === 'shorts';
34
+ }
35
+ }
36
+ if (!id || !/^[\w-]{11}$/.test(id))
37
+ return null;
38
+ const player = new URL(`https://www.youtube-nocookie.com/embed/${id}`);
39
+ player.searchParams.set('autoplay', '1');
40
+ const start = youtubeStart(url.searchParams.get('t') ?? url.searchParams.get('start'));
41
+ if (start)
42
+ player.searchParams.set('start', String(start));
43
+ return { type: 'iframe', src: player.href, aspectRatio: shorts ? 9 / 16 : 16 / 9 };
44
+ }
45
+ };
46
+ /** Bluesky's Klipy GIF links carry video slugs and dimensions in query parameters. */
47
+ export const klipyEmbedProvider = {
48
+ id: 'klipy',
49
+ label: 'Klipy',
50
+ resolve(url) {
51
+ if (!['static.klipy.com', 'k.gifs.bsky.app'].includes(url.hostname) ||
52
+ !/^\/ii\/(?:[\w-]+\/)+[\w-]+\.(gif|webp|png|jpe?g|mp4|webm)$/i.test(url.pathname))
53
+ return null;
54
+ const width = Number(url.searchParams.get('ww'));
55
+ const height = Number(url.searchParams.get('hh'));
56
+ const aspectRatio = Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
57
+ ? Math.max(0.25, Math.min(4, width / height))
58
+ : undefined;
59
+ // Use the same media proxy as Bluesky; never fetch Klipy metadata or scripts.
60
+ const src = `https://k.gifs.bsky.app${url.pathname}`;
61
+ const sources = [];
62
+ for (const format of ['webm', 'mp4']) {
63
+ const slug = url.searchParams.get(format);
64
+ if (slug && /^[\w-]+$/.test(slug))
65
+ sources.push({ src: src.replace(/[^/]+$/, `${slug}.${format}`), type: `video/${format}` });
66
+ }
67
+ if (sources.length)
68
+ return { type: 'video', sources, aspectRatio, autoplay: true, controls: false };
69
+ const format = /\.(mp4|webm)$/i.exec(url.pathname)?.[1].toLowerCase();
70
+ return format
71
+ ? {
72
+ type: 'video',
73
+ sources: [{ src, type: `video/${format}` }],
74
+ aspectRatio,
75
+ autoplay: true,
76
+ controls: false
77
+ }
78
+ : { type: 'image', src, aspectRatio, autoplay: true };
79
+ }
80
+ };
81
+ /** Ordered, explicit registry. Pass [] to keep all external links as ordinary cards. */
82
+ export const defaultExternalEmbedProviders = [
83
+ youtubeEmbedProvider,
84
+ klipyEmbedProvider
85
+ ];
86
+ export function resolveExternalEmbed(embed, providers = defaultExternalEmbedProviders) {
87
+ const href = safeWebUrl(embed.uri);
88
+ if (!href)
89
+ return null;
90
+ const url = new URL(href);
91
+ if (url.port || url.username || url.password)
92
+ return null;
93
+ for (const provider of providers) {
94
+ const player = provider.resolve(new URL(url), embed);
95
+ if (!player)
96
+ continue;
97
+ // Even custom providers cannot inject executable URL schemes.
98
+ if (player.type === 'video') {
99
+ if (!player.sources.length || player.sources.some((source) => !safeWebUrl(source.src)))
100
+ continue;
101
+ }
102
+ else if (!safeWebUrl(player.src))
103
+ continue;
104
+ return { provider, player };
105
+ }
106
+ return null;
107
+ }
@@ -23,6 +23,17 @@ export type PostEmbed = {
23
23
  type: 'quote';
24
24
  post?: PostData;
25
25
  unavailable?: boolean;
26
+ reason?: 'blocked' | 'detached';
27
+ } | {
28
+ type: 'record';
29
+ kind: 'feed' | 'list' | 'starter-pack' | 'labeler';
30
+ href: string;
31
+ title: string;
32
+ description?: string;
33
+ image?: string;
34
+ handle?: string;
35
+ } | {
36
+ type: 'unsupported';
26
37
  };
27
38
  export type PostData = {
28
39
  uri: string;
@@ -37,32 +48,56 @@ export type PostData = {
37
48
  facets?: Facet[];
38
49
  createdAt?: string;
39
50
  replyCount?: number;
51
+ repostCount?: number;
40
52
  likeCount?: number;
41
53
  embeds?: PostEmbed[];
42
54
  replies?: PostData[];
43
55
  };
56
+ type PostRecord = {
57
+ text?: string;
58
+ facets?: Facet[];
59
+ createdAt?: string;
60
+ };
61
+ type ActorView = {
62
+ did: string;
63
+ handle: string;
64
+ displayName?: string;
65
+ avatar?: string;
66
+ };
44
67
  /** The public AppView view shape; no session or network dependency. */
45
68
  export type BlueskyPostView = {
46
69
  uri: string;
47
- author: {
70
+ author: ActorView;
71
+ record?: PostRecord;
72
+ value?: PostRecord;
73
+ embed?: BlueskyEmbedView;
74
+ embeds?: BlueskyEmbedView[];
75
+ replyCount?: number;
76
+ repostCount?: number;
77
+ likeCount?: number;
78
+ };
79
+ type RecordView = {
80
+ $type?: string;
81
+ uri?: string;
82
+ author?: {
48
83
  did: string;
49
- handle: string;
84
+ handle?: string;
50
85
  displayName?: string;
51
86
  avatar?: string;
52
87
  };
53
- record?: {
54
- text?: string;
55
- facets?: Facet[];
56
- createdAt?: string;
57
- };
58
- value?: {
59
- text?: string;
60
- facets?: Facet[];
61
- createdAt?: string;
62
- };
63
- embed?: BlueskyEmbedView;
88
+ creator?: ActorView;
89
+ value?: PostRecord;
90
+ record?: unknown;
64
91
  embeds?: BlueskyEmbedView[];
92
+ displayName?: string;
93
+ name?: string;
94
+ description?: string;
95
+ avatar?: string;
96
+ blocked?: boolean;
97
+ detached?: boolean;
98
+ notFound?: boolean;
65
99
  replyCount?: number;
100
+ repostCount?: number;
66
101
  likeCount?: number;
67
102
  };
68
103
  type BlueskyEmbedView = {
@@ -76,6 +111,16 @@ type BlueskyEmbedView = {
76
111
  height: number;
77
112
  };
78
113
  }[];
114
+ items?: {
115
+ $type?: string;
116
+ thumbnail?: string;
117
+ fullsize?: string;
118
+ alt?: string;
119
+ aspectRatio?: {
120
+ width: number;
121
+ height: number;
122
+ };
123
+ }[];
79
124
  external?: {
80
125
  uri: string;
81
126
  title: string;
@@ -85,10 +130,7 @@ type BlueskyEmbedView = {
85
130
  playlist?: string;
86
131
  thumbnail?: string;
87
132
  alt?: string;
88
- record?: BlueskyPostView & {
89
- $type?: string;
90
- record?: BlueskyPostView;
91
- };
133
+ record?: RecordView;
92
134
  media?: BlueskyEmbedView;
93
135
  };
94
136
  export declare function blueskyPostToPostData(post: BlueskyPostView, depth?: number): PostData;
@@ -1,4 +1,69 @@
1
+ import { isActorIdentifier, isRecordKey } from '@atcute/lexicons/syntax';
1
2
  import { blueskyProfileUrl } from './urls.js';
3
+ /** Convert record-view variants without mistaking blocked authors or starter packs for posts. */
4
+ function recordEmbed(record, depth) {
5
+ const type = record.$type;
6
+ if (record.blocked || type === 'app.bsky.embed.record#viewBlocked')
7
+ return { type: 'quote', unavailable: true, reason: 'blocked' };
8
+ if (record.detached || type === 'app.bsky.embed.record#viewDetached')
9
+ return { type: 'quote', unavailable: true, reason: 'detached' };
10
+ if (record.notFound || type === 'app.bsky.embed.record#viewNotFound')
11
+ return { type: 'quote', unavailable: true };
12
+ const kind = {
13
+ 'app.bsky.feed.defs#generatorView': 'feed',
14
+ 'app.bsky.graph.defs#listView': 'list',
15
+ 'app.bsky.graph.defs#starterPackViewBasic': 'starter-pack',
16
+ 'app.bsky.labeler.defs#labelerView': 'labeler'
17
+ };
18
+ const recordKind = Object.hasOwn(kind, type ?? '') ? kind[type] : undefined;
19
+ if (recordKind) {
20
+ const match = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(record.uri ?? '');
21
+ if (!match)
22
+ return { type: 'unsupported' };
23
+ const [, actor, , rkey] = match;
24
+ if (!isActorIdentifier(actor) || !isRecordKey(rkey))
25
+ return { type: 'unsupported' };
26
+ const details = record.record;
27
+ const profile = blueskyProfileUrl(actor);
28
+ const href = recordKind === 'starter-pack'
29
+ ? `https://bsky.app/starter-pack/${actor}/${encodeURIComponent(rkey)}`
30
+ : recordKind === 'labeler'
31
+ ? profile
32
+ : `${profile}/${recordKind === 'feed' ? 'feed' : 'lists'}/${encodeURIComponent(rkey)}`;
33
+ return {
34
+ type: 'record',
35
+ kind: recordKind,
36
+ href,
37
+ title: record.displayName ||
38
+ record.name ||
39
+ (typeof details?.name === 'string' && details.name) ||
40
+ record.creator?.displayName ||
41
+ record.creator?.handle ||
42
+ 'Untitled',
43
+ description: record.description ||
44
+ (typeof details?.description === 'string' ? details.description : undefined),
45
+ image: record.avatar || (recordKind === 'labeler' ? record.creator?.avatar : undefined),
46
+ handle: record.creator?.handle
47
+ };
48
+ }
49
+ if (record.uri &&
50
+ record.author?.handle &&
51
+ depth < 3 &&
52
+ (!type || type === 'app.bsky.embed.record#viewRecord')) {
53
+ return {
54
+ type: 'quote',
55
+ post: blueskyPostToPostData({
56
+ ...record,
57
+ uri: record.uri,
58
+ author: { ...record.author, handle: record.author.handle },
59
+ record: record.record
60
+ }, depth + 1)
61
+ };
62
+ }
63
+ return type && type !== 'app.bsky.embed.record#viewRecord'
64
+ ? { type: 'unsupported' }
65
+ : { type: 'quote', unavailable: true };
66
+ }
2
67
  export function blueskyPostToPostData(post, depth = 0) {
3
68
  const record = post.record ?? post.value;
4
69
  const result = {
@@ -14,11 +79,14 @@ export function blueskyPostToPostData(post, depth = 0) {
14
79
  facets: record?.facets,
15
80
  createdAt: record?.createdAt,
16
81
  replyCount: post.replyCount,
82
+ repostCount: post.repostCount,
17
83
  likeCount: post.likeCount,
18
84
  embeds: []
19
85
  };
20
86
  function add(embed) {
21
- if (embed.images)
87
+ if (embed.media)
88
+ add(embed.media);
89
+ if (embed.images) {
22
90
  result.embeds.push({
23
91
  type: 'images',
24
92
  images: embed.images.map((image) => ({
@@ -28,7 +96,23 @@ export function blueskyPostToPostData(post, depth = 0) {
28
96
  ...image.aspectRatio
29
97
  }))
30
98
  });
31
- if (embed.external)
99
+ }
100
+ else if (embed.items) {
101
+ const images = embed.items.filter((item) => item.thumbnail && (!item.$type || item.$type === 'app.bsky.embed.gallery#viewImage'));
102
+ if (images.length)
103
+ result.embeds.push({
104
+ type: 'images',
105
+ images: images.map((image) => ({
106
+ src: image.thumbnail,
107
+ fullsize: image.fullsize,
108
+ alt: image.alt ?? '',
109
+ ...image.aspectRatio
110
+ }))
111
+ });
112
+ if (images.length !== embed.items.length)
113
+ result.embeds.push({ type: 'unsupported' });
114
+ }
115
+ else if (embed.external) {
32
116
  result.embeds.push({
33
117
  type: 'external',
34
118
  uri: embed.external.uri,
@@ -36,22 +120,25 @@ export function blueskyPostToPostData(post, depth = 0) {
36
120
  description: embed.external.description,
37
121
  image: embed.external.thumb
38
122
  });
39
- if (embed.playlist)
123
+ }
124
+ else if (embed.playlist) {
40
125
  result.embeds.push({
41
126
  type: 'video',
42
127
  playlist: embed.playlist,
43
128
  poster: embed.thumbnail,
44
129
  alt: embed.alt
45
130
  });
46
- if (embed.media)
47
- add(embed.media);
48
- if (embed.record && depth < 3) {
49
- const quote = embed.record.record ?? embed.record;
50
- result.embeds.push({
51
- type: 'quote',
52
- post: quote.author && quote.uri ? blueskyPostToPostData(quote, depth + 1) : undefined,
53
- unavailable: !quote.author
54
- });
131
+ }
132
+ else if (!embed.record && !embed.media) {
133
+ result.embeds.push({ type: 'unsupported' });
134
+ }
135
+ if (embed.record) {
136
+ // recordWithMedia wraps an embed.record#view; a plain record embed does not.
137
+ const quote = embed.record.$type === 'app.bsky.embed.record#view' ||
138
+ (!embed.record.uri && embed.record.record)
139
+ ? embed.record.record
140
+ : embed.record;
141
+ result.embeds.push(quote ? recordEmbed(quote, depth) : { type: 'quote', unavailable: true });
55
142
  }
56
143
  }
57
144
  for (const embed of post.embeds ?? (post.embed ? [post.embed] : []))
@@ -0,0 +1,36 @@
1
+ /** Public, cross-app social-client selection in the actor's repository. */
2
+ export declare const PREFERRED_CLIENT_COLLECTION = "social.atmo.actor.preferredClient";
3
+ export declare const PREFERRED_CLIENT_RKEY = "self";
4
+ export declare const PREFERRED_CLIENT_SCOPE = "repo:social.atmo.actor.preferredClient?action=create&action=update";
5
+ export declare const socialClients: readonly [{
6
+ readonly id: "bluesky";
7
+ readonly label: "Bluesky";
8
+ readonly url: "https://bsky.app";
9
+ }, {
10
+ readonly id: "mu";
11
+ readonly label: "mu";
12
+ readonly url: "https://mu.social";
13
+ }, {
14
+ readonly id: "blacksky";
15
+ readonly label: "Blacksky";
16
+ readonly url: "https://blacksky.community";
17
+ }, {
18
+ readonly id: "witchsky";
19
+ readonly label: "Witchsky";
20
+ readonly url: "https://witchsky.app";
21
+ }];
22
+ export type SocialClientId = (typeof socialClients)[number]['id'] | 'custom';
23
+ export type PreferredSocialClient = {
24
+ client: Exclude<SocialClientId, 'custom'>;
25
+ } | {
26
+ client: 'custom';
27
+ customUrl: string;
28
+ };
29
+ /** Require an absolute web URL. Keep path prefixes for self-hosted clients. */
30
+ export declare function normalizeSocialClientUrl(value: string): string | null;
31
+ export declare function parsePreferredSocialClient(value: unknown): PreferredSocialClient | null;
32
+ export declare function preferredSocialClientRecord(preference: PreferredSocialClient): Record<string, unknown>;
33
+ export declare function socialClientInfo(preference?: PreferredSocialClient | null): {
34
+ label: string;
35
+ url: string;
36
+ };
@@ -0,0 +1,48 @@
1
+ /** Public, cross-app social-client selection in the actor's repository. */
2
+ export const PREFERRED_CLIENT_COLLECTION = 'social.atmo.actor.preferredClient';
3
+ export const PREFERRED_CLIENT_RKEY = 'self';
4
+ export const PREFERRED_CLIENT_SCOPE = `repo:${PREFERRED_CLIENT_COLLECTION}?action=create&action=update`;
5
+ export const socialClients = [
6
+ { id: 'bluesky', label: 'Bluesky', url: 'https://bsky.app' },
7
+ { id: 'mu', label: 'mu', url: 'https://mu.social' },
8
+ { id: 'blacksky', label: 'Blacksky', url: 'https://blacksky.community' },
9
+ { id: 'witchsky', label: 'Witchsky', url: 'https://witchsky.app' }
10
+ ];
11
+ /** Require an absolute web URL. Keep path prefixes for self-hosted clients. */
12
+ export function normalizeSocialClientUrl(value) {
13
+ try {
14
+ const url = new URL(value.trim());
15
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
16
+ return null;
17
+ if (url.search || url.hash || url.href.length > 2048)
18
+ return null;
19
+ return url.href.replace(/\/+$/, '');
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ export function parsePreferredSocialClient(value) {
26
+ if (!value || typeof value !== 'object' || !('client' in value))
27
+ return null;
28
+ if (value.client === 'custom') {
29
+ const customUrl = 'customUrl' in value && typeof value.customUrl === 'string'
30
+ ? normalizeSocialClientUrl(value.customUrl)
31
+ : null;
32
+ return customUrl ? { client: 'custom', customUrl } : null;
33
+ }
34
+ const client = socialClients.find((client) => client.id === value.client);
35
+ return client ? { client: client.id } : null;
36
+ }
37
+ export function preferredSocialClientRecord(preference) {
38
+ const client = parsePreferredSocialClient(preference);
39
+ if (!client)
40
+ throw new Error('Choose a client and enter a valid client URL.');
41
+ return { $type: PREFERRED_CLIENT_COLLECTION, ...client };
42
+ }
43
+ export function socialClientInfo(preference) {
44
+ const client = parsePreferredSocialClient(preference) ?? { client: 'bluesky' };
45
+ return client.client === 'custom'
46
+ ? { label: 'Custom client', url: client.customUrl }
47
+ : socialClients.find((entry) => entry.id === client.client);
48
+ }
@@ -0,0 +1,36 @@
1
+ import type { PreferredSocialClient } from './preferred-client.js';
2
+ /** Hydrated app.bsky.actor.link data, not the legacy profile.website field. */
3
+ export type ProfileLink = {
4
+ url: string;
5
+ title?: string;
6
+ icon?: string;
7
+ };
8
+ export type ProfileData = {
9
+ did: string;
10
+ handle?: string;
11
+ displayName?: string;
12
+ avatar?: string;
13
+ banner?: string;
14
+ description?: string;
15
+ pronouns?: string;
16
+ links?: ProfileLink[];
17
+ followersCount?: number;
18
+ followsCount?: number;
19
+ postsCount?: number;
20
+ createdAt?: string;
21
+ };
22
+ /** Only web URLs; never accept executable schemes or disguised credentials. */
23
+ export declare function safeProfileUrl(value: string | undefined): string | undefined;
24
+ /** Bios are plain text with detected facets, never HTML or Markdown. No network requests. */
25
+ export type ProfileDescriptionOptions = {
26
+ socialClient?: PreferredSocialClient | null;
27
+ /** App-owned mention destination; omit to use the selected social client. */
28
+ mentionUrl?: (identifier: string) => string;
29
+ };
30
+ export declare function profileDescriptionSegments(text: string, options?: ProfileDescriptionOptions): {
31
+ text: string;
32
+ href: string | undefined;
33
+ }[];
34
+ export declare function visibleProfileLinks(links?: ProfileLink[]): ProfileLink[];
35
+ /** Validate public AppView data at the boundary, including newer optional fields. */
36
+ export declare function blueskyProfileToProfileData(value: unknown): ProfileData | undefined;