@atmo-dev/kit 0.3.0 → 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.
- package/README.md +59 -1
- package/dist/components/ExternalEmbed.svelte +174 -0
- package/dist/components/ExternalEmbed.svelte.d.ts +8 -0
- package/dist/components/GrainAlbumPicker.svelte +218 -0
- package/dist/components/GrainAlbumPicker.svelte.d.ts +14 -0
- package/dist/components/GrainAlbumView.svelte +94 -0
- package/dist/components/GrainAlbumView.svelte.d.ts +10 -0
- package/dist/components/NestedComments.svelte +20 -10
- package/dist/components/NestedComments.svelte.d.ts +2 -0
- package/dist/components/PhotoGrid.svelte +53 -0
- package/dist/components/PhotoGrid.svelte.d.ts +9 -0
- package/dist/components/PhotoLightbox.svelte +332 -0
- package/dist/components/PhotoLightbox.svelte.d.ts +12 -0
- package/dist/components/PostContent.svelte +78 -20
- package/dist/components/PostContent.svelte.d.ts +5 -0
- package/dist/components/StreamplacePicker.svelte +94 -0
- package/dist/components/StreamplacePicker.svelte.d.ts +12 -0
- package/dist/grain/types.d.ts +41 -0
- package/dist/grain/types.js +1 -0
- package/dist/grain/urls.d.ts +7 -0
- package/dist/grain/urls.js +74 -0
- package/dist/grain/xrpc.d.ts +3 -0
- package/dist/grain/xrpc.js +129 -0
- package/dist/grain.d.ts +3 -0
- package/dist/grain.js +2 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/media.d.ts +10 -0
- package/dist/media.js +1 -0
- package/dist/social/external-embeds.d.ts +39 -0
- package/dist/social/external-embeds.js +107 -0
- package/dist/social/posts.d.ts +59 -17
- package/dist/social/posts.js +99 -12
- package/dist/social.d.ts +2 -0
- package/dist/social.js +1 -0
- package/dist/streamplace.d.ts +6 -0
- package/dist/streamplace.js +1 -0
- package/package.json +13 -1
|
@@ -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,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
|
+
}
|
package/dist/grain.d.ts
ADDED
package/dist/grain.js
ADDED
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';
|
package/dist/media.d.ts
ADDED
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
|
+
}
|
package/dist/social/posts.d.ts
CHANGED
|
@@ -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
|
|
84
|
+
handle?: string;
|
|
50
85
|
displayName?: string;
|
|
51
86
|
avatar?: string;
|
|
52
87
|
};
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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?:
|
|
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;
|