tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
add bluesky feed
Florian
1 week ago
08870a6b
4feffd02
+218
-13
11 changed files
expand all
collapse all
unified
split
src
lib
atproto
methods.ts
cards
BlueskyFeedCard
BlueskyFeedCard.svelte
index.ts
BlueskyPostCard
BlueskyPostCard.svelte
index.ts
LatestBlueskyPostCard
LatestBlueskyPostCard.svelte
index.ts
index.ts
components
post
embeds
QuotedPost.svelte
website
Account.svelte
routes
(auth)
oauth
callback
+page.svelte
+7
-1
src/lib/atproto/methods.ts
···
450
450
client?: Client;
451
451
filter?: string;
452
452
limit?: number;
453
453
+
cursor?: string;
453
454
}) {
454
455
data ??= {};
455
456
data.did ??= user.did;
···
461
462
});
462
463
463
464
const response = await data.client.get('app.bsky.feed.getAuthorFeed', {
464
464
-
params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 }
465
465
+
params: {
466
466
+
actor: data.did,
467
467
+
filter: data.filter ?? 'posts_with_media',
468
468
+
limit: data.limit || 100,
469
469
+
cursor: data.cursor
470
470
+
}
465
471
});
466
472
467
473
if (!response.ok) return;
+96
src/lib/cards/BlueskyFeedCard/BlueskyFeedCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { Item } from '$lib/types';
3
3
+
import { onMount } from 'svelte';
4
4
+
import { BlueskyPost } from '../../components/bluesky-post';
5
5
+
import { getAdditionalUserData, getDidContext } from '$lib/website/context';
6
6
+
import { resolveHandle, getAuthorFeed } from '$lib/atproto/methods';
7
7
+
import type { Did, Handle } from '@atcute/lexicons';
8
8
+
9
9
+
let { item }: { item: Item } = $props();
10
10
+
11
11
+
const data = getAdditionalUserData();
12
12
+
const did = getDidContext();
13
13
+
14
14
+
// svelte-ignore state_referenced_locally
15
15
+
const lookupKey = (item.cardData.did as string) || (item.cardData.handle as string) || did;
16
16
+
// svelte-ignore state_referenced_locally
17
17
+
const preloaded = (data[item.cardType] as Record<string, any>)?.[lookupKey];
18
18
+
let feed: any[] | undefined = $state(preloaded?.feed);
19
19
+
let cursor = $state<string | undefined>(preloaded?.cursor);
20
20
+
// svelte-ignore state_referenced_locally
21
21
+
let targetDid = $state<Did | undefined>(item.cardData.did ? (item.cardData.did as Did) : did);
22
22
+
let loading = $state(false);
23
23
+
24
24
+
async function loadMore() {
25
25
+
if (loading || !cursor || !targetDid) return;
26
26
+
loading = true;
27
27
+
try {
28
28
+
const result = await getAuthorFeed({
29
29
+
did: targetDid,
30
30
+
filter: 'posts_no_replies',
31
31
+
limit: 20,
32
32
+
cursor
33
33
+
});
34
34
+
if (result?.feed) {
35
35
+
feed = [...(feed ?? []), ...result.feed];
36
36
+
}
37
37
+
cursor = result?.cursor;
38
38
+
} finally {
39
39
+
loading = false;
40
40
+
}
41
41
+
}
42
42
+
43
43
+
function handleScroll(e: Event) {
44
44
+
const el = e.currentTarget as HTMLElement;
45
45
+
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
46
46
+
loadMore();
47
47
+
}
48
48
+
}
49
49
+
50
50
+
onMount(async () => {
51
51
+
if (feed) return;
52
52
+
53
53
+
// Resolve handle to DID if needed
54
54
+
if (item.cardData.handle && !item.cardData.did) {
55
55
+
try {
56
56
+
targetDid = await resolveHandle({ handle: item.cardData.handle as Handle });
57
57
+
} catch {
58
58
+
// fall back to context did
59
59
+
}
60
60
+
}
61
61
+
62
62
+
try {
63
63
+
const result = await getAuthorFeed({
64
64
+
did: targetDid,
65
65
+
filter: 'posts_no_replies',
66
66
+
limit: 20
67
67
+
});
68
68
+
feed = result?.feed;
69
69
+
cursor = result?.cursor;
70
70
+
} catch {
71
71
+
// failed to fetch feed
72
72
+
}
73
73
+
});
74
74
+
</script>
75
75
+
76
76
+
<div class="flex h-full flex-col overflow-x-hidden overflow-y-auto p-3" onscroll={handleScroll}>
77
77
+
{#if feed && feed.length > 0}
78
78
+
<div class={[item.cardData.label ? 'pt-8' : '']}>
79
79
+
{#each feed as feedItem, i (feedItem.post?.uri ?? i)}
80
80
+
<BlueskyPost showAvatar compact feedViewPost={feedItem.post} />
81
81
+
{#if i < feed.length - 1}
82
82
+
<div
83
83
+
class="border-base-200 dark:border-base-800 accent:border-base-50/5 my-3 border-t"
84
84
+
></div>
85
85
+
{/if}
86
86
+
{/each}
87
87
+
</div>
88
88
+
{#if loading}
89
89
+
<div class="text-base-400 py-2 text-center text-xs">Loading...</div>
90
90
+
{/if}
91
91
+
{:else}
92
92
+
<div class="text-base-500 flex h-full items-center justify-center text-sm">
93
93
+
No posts to show
94
94
+
</div>
95
95
+
{/if}
96
96
+
</div>
+99
src/lib/cards/BlueskyFeedCard/index.ts
···
1
1
+
import type { CardDefinition } from '../types';
2
2
+
import BlueskyFeedCard from './BlueskyFeedCard.svelte';
3
3
+
import { getAuthorFeed, resolveHandle } from '$lib/atproto/methods';
4
4
+
import type { Did, Handle } from '@atcute/lexicons';
5
5
+
import { isDid } from '@atcute/lexicons/syntax';
6
6
+
7
7
+
export const BlueskyFeedCardDefinition = {
8
8
+
type: 'blueskyFeed',
9
9
+
contentComponent: BlueskyFeedCard,
10
10
+
createNew: (card) => {
11
11
+
card.cardType = 'blueskyFeed';
12
12
+
card.w = 4;
13
13
+
card.mobileW = 8;
14
14
+
card.h = 6;
15
15
+
card.mobileH = 10;
16
16
+
},
17
17
+
18
18
+
onUrlHandler: (url, item) => {
19
19
+
const match = url.match(/bsky\.app\/profile\/([^/]+)\/?$/);
20
20
+
if (!match) return null;
21
21
+
22
22
+
const actor = match[1];
23
23
+
if (isDid(actor)) {
24
24
+
item.cardData.did = actor;
25
25
+
} else {
26
26
+
item.cardData.handle = actor;
27
27
+
}
28
28
+
29
29
+
item.w = 4;
30
30
+
item.mobileW = 8;
31
31
+
item.h = 6;
32
32
+
item.mobileH = 10;
33
33
+
34
34
+
return item;
35
35
+
},
36
36
+
urlHandlerPriority: 1,
37
37
+
38
38
+
loadData: async (items, { did }) => {
39
39
+
// Map from original key (handle or did from cardData) to resolved DID
40
40
+
const keysToDid = new Map<string, Did>();
41
41
+
42
42
+
for (const item of items) {
43
43
+
if (item.cardData?.did) {
44
44
+
const d = item.cardData.did as Did;
45
45
+
keysToDid.set(d, d);
46
46
+
} else if (item.cardData?.handle) {
47
47
+
try {
48
48
+
const resolved = await resolveHandle({ handle: item.cardData.handle as Handle });
49
49
+
keysToDid.set(item.cardData.handle as string, resolved);
50
50
+
} catch {
51
51
+
// skip unresolvable handles
52
52
+
}
53
53
+
} else {
54
54
+
keysToDid.set(did, did);
55
55
+
}
56
56
+
}
57
57
+
58
58
+
const result: Record<string, unknown> = {};
59
59
+
const fetched = new Set<string>();
60
60
+
61
61
+
await Promise.all(
62
62
+
Array.from(keysToDid.entries()).map(async ([key, fetchDid]) => {
63
63
+
try {
64
64
+
let feedData;
65
65
+
if (!fetched.has(fetchDid)) {
66
66
+
feedData = await getAuthorFeed({
67
67
+
did: fetchDid,
68
68
+
filter: 'posts_no_replies',
69
69
+
limit: 20
70
70
+
});
71
71
+
result[fetchDid] = feedData;
72
72
+
fetched.add(fetchDid);
73
73
+
} else {
74
74
+
feedData = result[fetchDid];
75
75
+
}
76
76
+
// Also store under original key so the component can look it up
77
77
+
if (key !== fetchDid) {
78
78
+
result[key] = feedData;
79
79
+
}
80
80
+
} catch {
81
81
+
// skip failed fetches
82
82
+
}
83
83
+
})
84
84
+
);
85
85
+
86
86
+
return result;
87
87
+
},
88
88
+
89
89
+
minW: 4,
90
90
+
minH: 4,
91
91
+
92
92
+
name: 'Bluesky Feed',
93
93
+
94
94
+
canHaveLabel: true,
95
95
+
96
96
+
keywords: ['bsky', 'atproto', 'feed', 'timeline', 'posts'],
97
97
+
groups: ['Social'],
98
98
+
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M6.335 3.836a47.2 47.2 0 0 1 5.354 4.94c.088.093.165.18.232.26a18 18 0 0 1 .232-.26 47.2 47.2 0 0 1 5.355-4.94C18.882 2.687 21.46 1.37 22.553 2.483c.986 1.003.616 4.264.305 5.857-.567 2.902-2.018 4.274-3.703 4.542 2.348.386 4.678 1.96 3.13 5.602-1.97 4.636-7.065 1.763-9.795-.418a3 3 0 0 1-.18-.15 3 3 0 0 1-.18.15c-2.73 2.18-7.825 5.054-9.795.418-1.548-3.643.782-5.216 3.13-5.602C3.98 12.631 2.529 11.26 1.962 8.357c-.311-1.593-.681-4.854.305-5.857C3.361 1.37 5.94 2.687 6.335 3.836Z" /></svg>`
99
99
+
} as CardDefinition & { type: 'blueskyFeed' };
+3
-1
src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte
···
39
39
40
40
<div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4">
41
41
{#if post}
42
42
-
<BlueskyPost showLogo feedViewPost={post}></BlueskyPost>
42
42
+
<div class={[item.cardData.label ? 'pt-8' : '']}>
43
43
+
<BlueskyPost showLogo feedViewPost={post}></BlueskyPost>
44
44
+
</div>
43
45
<div class="h-4 w-full"></div>
44
46
{:else}
45
47
<p class="text-base-600 dark:text-base-400 text-center">A bluesky post will appear here</p>
+2
src/lib/cards/BlueskyPostCard/index.ts
···
64
64
minW: 4,
65
65
name: 'Bluesky Post',
66
66
67
67
+
canHaveLabel: true,
68
68
+
67
69
keywords: ['skeet', 'bsky', 'atproto', 'post'],
68
70
groups: ['Social'],
69
71
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /></svg>`
+3
-6
src/lib/cards/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte
···
29
29
</script>
30
30
31
31
<div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4">
32
32
-
<div
33
33
-
class="accent:text-base-950 bg-base-200/50 dark:bg-base-700/30 mx-auto mb-6 w-fit rounded-xl p-1 px-2 text-2xl font-semibold"
34
34
-
>
35
35
-
My latest bluesky post
36
36
-
</div>
37
32
{#if feed?.[0]?.post}
38
38
-
<BlueskyPost showLogo feedViewPost={feed?.[0].post}></BlueskyPost>
33
33
+
<div class={[item.cardData.label ? "pt-8" : '']}>
34
34
+
<BlueskyPost showLogo feedViewPost={feed?.[0].post}></BlueskyPost>
35
35
+
</div>
39
36
<div class="h-4 w-full"></div>
40
37
{:else}
41
38
Your latest bluesky post will appear here.
+2
src/lib/cards/LatestBlueskyPostCard/index.ts
···
21
21
22
22
name: 'Latest Bluesky Post',
23
23
24
24
+
canHaveLabel: true,
25
25
+
24
26
keywords: ['bsky', 'atproto', 'recent', 'feed'],
25
27
groups: ['Social'],
26
28
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M6.335 3.836a47.2 47.2 0 0 1 5.354 4.94c.088.093.165.18.232.26a18 18 0 0 1 .232-.26 47.2 47.2 0 0 1 5.355-4.94C18.882 2.687 21.46 1.37 22.553 2.483c.986 1.003.616 4.264.305 5.857-.567 2.902-2.018 4.274-3.703 4.542 2.348.386 4.678 1.96 3.13 5.602-1.97 4.636-7.065 1.763-9.795-.418a3 3 0 0 1-.18-.15 3 3 0 0 1-.18.15c-2.73 2.18-7.825 5.054-9.795.418-1.548-3.643.782-5.216 3.13-5.602C3.98 12.631 2.529 11.26 1.962 8.357c-.311-1.593-.681-4.854.305-5.857C3.361 1.37 5.94 2.687 6.335 3.836Z" /></svg>`
+2
src/lib/cards/index.ts
···
3
3
import { BigSocialCardDefinition } from './BigSocialCard';
4
4
import { BlueskyMediaCardDefinition } from './BlueskyMediaCard';
5
5
import { BlueskyPostCardDefinition } from './BlueskyPostCard';
6
6
+
import { BlueskyFeedCardDefinition } from './BlueskyFeedCard';
6
7
import { LatestBlueskyPostCardDefinition } from './LatestBlueskyPostCard';
7
8
import { DinoGameCardDefinition } from './GameCards/DinoGameCard';
8
9
import { EmbedCardDefinition } from './EmbedCard';
···
50
51
YoutubeCardDefinition,
51
52
BlueskyPostCardDefinition,
52
53
LatestBlueskyPostCardDefinition,
54
54
+
BlueskyFeedCardDefinition,
53
55
LivestreamCardDefitition,
54
56
LivestreamEmbedCardDefitition,
55
57
// EmbedCardDefinition,
+2
-2
src/lib/components/post/embeds/QuotedPost.svelte
···
9
9
</script>
10
10
11
11
<div
12
12
-
class="border-base-300 dark:border-base-600/30 bg-base-950/5 dark:bg-base-950/20 overflow-hidden rounded-2xl border text-sm"
12
12
+
class="border-base-300 dark:border-base-600/30 accent:border-accent-300/20 accent:bg-accent-100/10 bg-base-950/5 dark:bg-base-950/20 overflow-hidden rounded-2xl border text-sm"
13
13
>
14
14
<div class="p-3">
15
15
<div class="flex items-center gap-2">
···
22
22
{record.author.displayName}
23
23
</span>
24
24
{/if}
25
25
-
<span class="text-base-500 dark:text-base-400 truncate">
25
25
+
<span class="text-base-500 dark:text-base-400 accent:text-accent-950 truncate">
26
26
@{record.author.handle}
27
27
</span>
28
28
</div>
+1
-1
src/lib/website/Account.svelte
···
29
29
<Button
30
30
variant="ghost"
31
31
onclick={() => {
32
32
-
goto('/' + getHandleOrDid(user.profile), {});
32
32
+
if (user.profile) goto('/' + getHandleOrDid(user.profile), {});
33
33
}}>Leave edit mode</Button
34
34
>
35
35
{/if}
+1
-2
src/routes/(auth)/oauth/callback/+page.svelte
···
12
12
goto('/' + getHandleOrDid(user.profile) + '/edit', {});
13
13
}
14
14
15
15
-
if(!user.isInitializing && !startedErrorTimer) {
15
15
+
if (!user.isInitializing && !startedErrorTimer) {
16
16
startedErrorTimer = true;
17
17
18
18
setTimeout(() => {
···
30
30
>There was an error signing you in, please go back to the
31
31
<a class="text-accent-600 dark:text-accent-400" href="/">homepage</a>
32
32
and try again.
33
33
-
34
33
</span>
35
34
</div>
36
35
{/if}