@atmo-dev/kit 0.3.0 → 0.5.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 (180) hide show
  1. package/README.md +94 -1
  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 +20 -10
  9. package/dist/components/NestedComments.svelte.d.ts +2 -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/StreamplacePicker.svelte +94 -0
  17. package/dist/components/StreamplacePicker.svelte.d.ts +12 -0
  18. package/dist/events/DatePicker.svelte +222 -0
  19. package/dist/events/DatePicker.svelte.d.ts +12 -0
  20. package/dist/events/DateTimePicker.svelte +112 -0
  21. package/dist/events/DateTimePicker.svelte.d.ts +10 -0
  22. package/dist/events/EventAttendees.svelte +199 -0
  23. package/dist/events/EventAttendees.svelte.d.ts +19 -0
  24. package/dist/events/EventCard.svelte +148 -0
  25. package/dist/events/EventCard.svelte.d.ts +8 -0
  26. package/dist/events/EventComments.svelte +98 -0
  27. package/dist/events/EventComments.svelte.d.ts +8 -0
  28. package/dist/events/EventEditor.svelte +540 -0
  29. package/dist/events/EventEditor.svelte.d.ts +18 -0
  30. package/dist/events/EventRsvp.svelte +178 -0
  31. package/dist/events/EventRsvp.svelte.d.ts +16 -0
  32. package/dist/events/EventView.svelte +488 -0
  33. package/dist/events/EventView.svelte.d.ts +23 -0
  34. package/dist/events/ImageDropper.svelte +66 -0
  35. package/dist/events/ImageDropper.svelte.d.ts +7 -0
  36. package/dist/events/Map.svelte +36 -0
  37. package/dist/events/Map.svelte.d.ts +8 -0
  38. package/dist/events/PostToBlueskyModal.svelte +246 -0
  39. package/dist/events/PostToBlueskyModal.svelte.d.ts +22 -0
  40. package/dist/events/ShareModal.svelte +174 -0
  41. package/dist/events/ShareModal.svelte.d.ts +23 -0
  42. package/dist/events/StarterPackButton.svelte +72 -0
  43. package/dist/events/StarterPackButton.svelte.d.ts +10 -0
  44. package/dist/events/ThemeApply.svelte +30 -0
  45. package/dist/events/ThemeApply.svelte.d.ts +7 -0
  46. package/dist/events/ThemeBackground.svelte +33 -0
  47. package/dist/events/ThemeBackground.svelte.d.ts +7 -0
  48. package/dist/events/ThemePicker.svelte +104 -0
  49. package/dist/events/ThemePicker.svelte.d.ts +7 -0
  50. package/dist/events/ThumbnailPresets.svelte +71 -0
  51. package/dist/events/ThumbnailPresets.svelte.d.ts +11 -0
  52. package/dist/events/TimePicker.svelte +117 -0
  53. package/dist/events/TimePicker.svelte.d.ts +10 -0
  54. package/dist/events/TimezonePicker.svelte +67 -0
  55. package/dist/events/TimezonePicker.svelte.d.ts +6 -0
  56. package/dist/events/VodPlayer.svelte +154 -0
  57. package/dist/events/VodPlayer.svelte.d.ts +14 -0
  58. package/dist/events/VodTranscript.svelte +75 -0
  59. package/dist/events/VodTranscript.svelte.d.ts +8 -0
  60. package/dist/events/atproto-helpers.d.ts +21 -0
  61. package/dist/events/atproto-helpers.js +61 -0
  62. package/dist/events/cal/helper.d.ts +1 -0
  63. package/dist/events/cal/helper.js +20 -0
  64. package/dist/events/cal/ical.d.ts +22 -0
  65. package/dist/events/cal/ical.js +188 -0
  66. package/dist/events/cal/sanitize.d.ts +3 -0
  67. package/dist/events/cal/sanitize.js +25 -0
  68. package/dist/events/conference.d.ts +65 -0
  69. package/dist/events/conference.js +61 -0
  70. package/dist/events/contrail.d.ts +49 -0
  71. package/dist/events/contrail.js +12 -0
  72. package/dist/events/date-format.d.ts +22 -0
  73. package/dist/events/date-format.js +43 -0
  74. package/dist/events/editor/LinkedRecordsPicker.svelte +104 -0
  75. package/dist/events/editor/LinkedRecordsPicker.svelte.d.ts +12 -0
  76. package/dist/events/editor/LinksSection.svelte +154 -0
  77. package/dist/events/editor/LinksSection.svelte.d.ts +13 -0
  78. package/dist/events/editor/LocationSection.svelte +163 -0
  79. package/dist/events/editor/LocationSection.svelte.d.ts +8 -0
  80. package/dist/events/editor/RecurringModal.svelte +313 -0
  81. package/dist/events/editor/RecurringModal.svelte.d.ts +30 -0
  82. package/dist/events/editor/ThemeSection.svelte +24 -0
  83. package/dist/events/editor/ThemeSection.svelte.d.ts +7 -0
  84. package/dist/events/editor/ThumbnailSection.svelte +181 -0
  85. package/dist/events/editor/ThumbnailSection.svelte.d.ts +13 -0
  86. package/dist/events/editor/adapter.d.ts +73 -0
  87. package/dist/events/editor/adapter.js +9 -0
  88. package/dist/events/editor/location.d.ts +16 -0
  89. package/dist/events/editor/location.js +50 -0
  90. package/dist/events/editor/save.d.ts +43 -0
  91. package/dist/events/editor/save.js +168 -0
  92. package/dist/events/editor/types.d.ts +39 -0
  93. package/dist/events/editor/types.js +9 -0
  94. package/dist/events/event-media.d.ts +4 -0
  95. package/dist/events/event-media.js +4 -0
  96. package/dist/events/event-types.d.ts +79 -0
  97. package/dist/events/event-types.js +11 -0
  98. package/dist/events/event-view/AddToCalendarButton.svelte +31 -0
  99. package/dist/events/event-view/AddToCalendarButton.svelte.d.ts +9 -0
  100. package/dist/events/event-view/EventBadges.svelte +20 -0
  101. package/dist/events/event-view/EventBadges.svelte.d.ts +7 -0
  102. package/dist/events/event-view/EventDateBlock.svelte +43 -0
  103. package/dist/events/event-view/EventDateBlock.svelte.d.ts +7 -0
  104. package/dist/events/event-view/EventHostedBy.svelte +61 -0
  105. package/dist/events/event-view/EventHostedBy.svelte.d.ts +16 -0
  106. package/dist/events/event-view/EventLinksList.svelte +24 -0
  107. package/dist/events/event-view/EventLinksList.svelte.d.ts +9 -0
  108. package/dist/events/event-view/EventLocationBlock.svelte +62 -0
  109. package/dist/events/event-view/EventLocationBlock.svelte.d.ts +7 -0
  110. package/dist/events/event-view/EventLocationMap.svelte +66 -0
  111. package/dist/events/event-view/EventLocationMap.svelte.d.ts +7 -0
  112. package/dist/events/event-view/ExternalRsvpNotice.svelte +34 -0
  113. package/dist/events/event-view/ExternalRsvpNotice.svelte.d.ts +6 -0
  114. package/dist/events/event-view/StreamPlacePlayer.svelte +209 -0
  115. package/dist/events/event-view/StreamPlacePlayer.svelte.d.ts +8 -0
  116. package/dist/events/event-view/format.d.ts +10 -0
  117. package/dist/events/event-view/format.js +112 -0
  118. package/dist/events/event-view/locations.d.ts +36 -0
  119. package/dist/events/event-view/locations.js +243 -0
  120. package/dist/events/event-view/plyr-icons.d.ts +3 -0
  121. package/dist/events/event-view/plyr-icons.js +46 -0
  122. package/dist/events/linked-records.d.ts +18 -0
  123. package/dist/events/linked-records.js +11 -0
  124. package/dist/events/profile-url.d.ts +3 -0
  125. package/dist/events/profile-url.js +11 -0
  126. package/dist/events/schedule/ConferenceTimetable.svelte +340 -0
  127. package/dist/events/schedule/ConferenceTimetable.svelte.d.ts +22 -0
  128. package/dist/events/schedule/DaySchedule.svelte +298 -0
  129. package/dist/events/schedule/DaySchedule.svelte.d.ts +26 -0
  130. package/dist/events/schedule/ScheduleEventCell.svelte +166 -0
  131. package/dist/events/schedule/ScheduleEventCell.svelte.d.ts +25 -0
  132. package/dist/events/schedule/schedule-utils.d.ts +44 -0
  133. package/dist/events/schedule/schedule-utils.js +166 -0
  134. package/dist/events/starter-pack-url.d.ts +3 -0
  135. package/dist/events/starter-pack-url.js +16 -0
  136. package/dist/events/theme.d.ts +9 -0
  137. package/dist/events/theme.js +36 -0
  138. package/dist/events/themes/Blobs.svelte +64 -0
  139. package/dist/events/themes/Blobs.svelte.d.ts +18 -0
  140. package/dist/events/themes/Butterflies.svelte +183 -0
  141. package/dist/events/themes/Butterflies.svelte.d.ts +3 -0
  142. package/dist/events/themes/Fireflies.svelte +134 -0
  143. package/dist/events/themes/Fireflies.svelte.d.ts +3 -0
  144. package/dist/events/themes/Kaleidoscope.svelte +183 -0
  145. package/dist/events/themes/Kaleidoscope.svelte.d.ts +3 -0
  146. package/dist/events/themes/Matrix.svelte +153 -0
  147. package/dist/events/themes/Matrix.svelte.d.ts +3 -0
  148. package/dist/events/themes/Stars.svelte +98 -0
  149. package/dist/events/themes/Stars.svelte.d.ts +3 -0
  150. package/dist/events/thumbnails/designs.d.ts +18 -0
  151. package/dist/events/thumbnails/designs.js +316 -0
  152. package/dist/events/view-data.d.ts +38 -0
  153. package/dist/events/view-data.js +1 -0
  154. package/dist/events-data.d.ts +15 -0
  155. package/dist/events-data.js +9 -0
  156. package/dist/events-editor.d.ts +2 -0
  157. package/dist/events-editor.js +2 -0
  158. package/dist/events.d.ts +19 -0
  159. package/dist/events.js +17 -0
  160. package/dist/grain/types.d.ts +41 -0
  161. package/dist/grain/types.js +1 -0
  162. package/dist/grain/urls.d.ts +7 -0
  163. package/dist/grain/urls.js +74 -0
  164. package/dist/grain/xrpc.d.ts +3 -0
  165. package/dist/grain/xrpc.js +129 -0
  166. package/dist/grain.d.ts +3 -0
  167. package/dist/grain.js +2 -0
  168. package/dist/index.d.ts +16 -0
  169. package/dist/index.js +9 -0
  170. package/dist/media.d.ts +10 -0
  171. package/dist/media.js +1 -0
  172. package/dist/social/external-embeds.d.ts +39 -0
  173. package/dist/social/external-embeds.js +107 -0
  174. package/dist/social/posts.d.ts +59 -17
  175. package/dist/social/posts.js +99 -12
  176. package/dist/social.d.ts +2 -0
  177. package/dist/social.js +1 -0
  178. package/dist/streamplace.d.ts +6 -0
  179. package/dist/streamplace.js +1 -0
  180. package/package.json +58 -4
@@ -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
@@ -19,6 +19,19 @@ export { default as UserProfile } from './components/UserProfile.svelte';
19
19
  export type { UserProfileProps } from './components/UserProfile.svelte';
20
20
  export { blueskyProfileToProfileData } from './social/profile.js';
21
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';
22
35
  export { default as InsetImage } from './components/InsetImage.svelte';
23
36
  export { default as Badge } from './components/Badge.svelte';
24
37
  export { default as Modal } from './components/Modal.svelte';
@@ -60,6 +73,9 @@ export { default as InlineEditor } from './components/InlineEditor.svelte';
60
73
  export { default as PostComposer } from './components/PostComposer.svelte';
61
74
  export { default as NestedComments } from './components/NestedComments.svelte';
62
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';
63
79
  export { blueskyPostToPostData } from './social/posts.js';
64
80
  export type { PostData, PostEmbed, BlueskyPostView } from './social/posts.js';
65
81
  export { editorJsonToBlueskyPost, textToDoc, graphemeLength } from './social/richtext.js';
package/dist/index.js CHANGED
@@ -14,6 +14,13 @@ export { default as Surface } from './components/Surface.svelte';
14
14
  export { default as Avatar } from './components/Avatar.svelte';
15
15
  export { default as UserProfile } from './components/UserProfile.svelte';
16
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';
17
24
  export { default as InsetImage } from './components/InsetImage.svelte';
18
25
  export { default as Badge } from './components/Badge.svelte';
19
26
  export { default as Modal } from './components/Modal.svelte';
@@ -42,6 +49,8 @@ export { default as InlineEditor } from './components/InlineEditor.svelte';
42
49
  export { default as PostComposer } from './components/PostComposer.svelte';
43
50
  export { default as NestedComments } from './components/NestedComments.svelte';
44
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';
45
54
  export { blueskyPostToPostData } from './social/posts.js';
46
55
  export { editorJsonToBlueskyPost, textToDoc, graphemeLength } from './social/richtext.js';
47
56
  export { launchConfetti } from './confetti.js';
@@ -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] : []))
package/dist/social.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { PREFERRED_CLIENT_COLLECTION, PREFERRED_CLIENT_RKEY, PREFERRED_CLIENT_SCOPE, socialClients, normalizeSocialClientUrl, parsePreferredSocialClient, preferredSocialClientRecord, socialClientInfo } from './social/preferred-client.js';
2
2
  export type { SocialClientId, PreferredSocialClient } from './social/preferred-client.js';
3
+ export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
4
+ export type { ExternalPostEmbed, ExternalEmbedProvider, ExternalEmbedPlayer } from './social/external-embeds.js';
3
5
  export { blueskyProfileUrl, socialProfileUrl, socialPostUrl, socialHashtagUrl } from './social/urls.js';
package/dist/social.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { PREFERRED_CLIENT_COLLECTION, PREFERRED_CLIENT_RKEY, PREFERRED_CLIENT_SCOPE, socialClients, normalizeSocialClientUrl, parsePreferredSocialClient, preferredSocialClientRecord, socialClientInfo } from './social/preferred-client.js';
2
+ export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
2
3
  export { blueskyProfileUrl, socialProfileUrl, socialPostUrl, socialHashtagUrl } from './social/urls.js';
@@ -0,0 +1,6 @@
1
+ export { parseStreamplaceLink } from './grain/urls.js';
2
+ export type StreamplaceLink = {
3
+ actor: string;
4
+ url: string;
5
+ };
6
+ export type StreamplaceSelection = (stream: StreamplaceLink) => void | string | Promise<void | string>;
@@ -0,0 +1 @@
1
+ export { parseStreamplaceLink } from './grain/urls.js';