···
2
2
/* eslint-disable svelte/no-navigation-without-resolve */
3
3
import { goto } from '$app/navigation';
4
4
import { Avatar } from '@foxui/core';
5
5
-
import { Heart, MessageCircle, Repeat2 } from '@lucide/svelte';
5
5
+
import { Heart, MessageCircle, Repeat2, Repeat } from '@lucide/svelte';
6
6
import { wireEmbedClicks } from '$lib/components/embed';
7
7
import { blueskyPostToPostData } from '$lib/components';
8
8
import { Post } from '$lib/components';
···
11
11
import { loginModalState } from '$lib/LoginModal.svelte';
12
12
import { isAccentColor } from './accent-colors';
13
13
import type { PostRow, PostWithCommunity } from './db';
14
14
-
type PostView = Record<string, unknown> & {
14
14
+
15
15
+
// `getQuotedPosts` uses the authenticated appview client, so this view
16
16
+
// already includes per-viewer state like `viewer.like` plus the live
17
17
+
// like/reply/repost counts. We treat the QUOTED post as the canonical
18
18
+
// thing users interact with — the community's wrapping quote post is
19
19
+
// just a forwarder.
20
20
+
type PostView = {
15
21
uri: string;
22
22
+
cid: string;
16
23
author: { handle: string };
24
24
+
likeCount?: number;
25
25
+
replyCount?: number;
26
26
+
repostCount?: number;
27
27
+
viewer?: { like?: string; repost?: string };
28
28
+
[key: string]: unknown;
17
29
};
18
30
19
31
type CardRow = PostRow | PostWithCommunity;
···
25
37
/** Tailwind color label, e.g. "pink". Only needed when `row` is a bare
26
38
* PostRow (no community_accent_color). On a PostWithCommunity the row
27
39
* already carries the cached accent color. */
28
28
-
accentColor: accentColorProp,
29
29
-
/** Initial viewer like URI (from getPostsViewerState) — null = not liked. */
30
30
-
likeUri: initialLikeUri = null
40
40
+
accentColor: accentColorProp
31
41
}: {
32
42
row: CardRow;
33
43
quoted?: PostView | null;
34
44
showCommunity?: boolean;
35
45
accentColor?: string | null;
36
36
-
likeUri?: string | null;
37
46
} = $props();
38
47
39
48
const communityHandle = $derived(
···
82
91
return `${Math.floor(s / 86400)}d`;
83
92
}
84
93
85
85
-
// Optimistic like state. `likeUri` comes in from the parent (seeded by
86
86
-
// getPostsViewerState); we also track an optimistic delta on like_count
87
87
-
// so the visible number flips immediately on click.
94
94
+
// All counts + viewer state come straight from the quoted PostView.
95
95
+
// We track optimistic state locally so the heart flips instantly on
96
96
+
// click without waiting for a server roundtrip.
88
97
let likeUri = $state<string | null>(null);
89
98
let likeDelta = $state(0);
90
99
let likeBusy = $state(false);
91
100
const isLiked = $derived(likeUri !== null);
92
92
-
const displayLikeCount = $derived(row.like_count + likeDelta);
101
101
+
const baseLikeCount = $derived(quoted?.likeCount ?? 0);
102
102
+
const displayLikeCount = $derived(baseLikeCount + likeDelta);
103
103
+
const replyCount = $derived(quoted?.replyCount ?? 0);
104
104
+
const repostCount = $derived(quoted?.repostCount ?? 0);
93
105
94
106
$effect(() => {
95
95
-
// Reset optimistic state when the parent swaps in fresh viewer data.
96
96
-
// Reading initialLikeUri inside the effect makes it reactive.
97
97
-
likeUri = initialLikeUri;
107
107
+
// Re-seed optimistic state whenever the parent swaps in a fresh
108
108
+
// PostView. Reading inside the effect keeps it reactive.
109
109
+
likeUri = quoted?.viewer?.like ?? null;
98
110
likeDelta = 0;
99
111
});
100
112
···
105
117
loginModalState.open = true;
106
118
return;
107
119
}
108
108
-
if (likeBusy) return;
120
120
+
if (likeBusy || !quoted) return;
109
121
likeBusy = true;
110
122
const wasLiked = isLiked;
111
123
// Optimistic flip.
···
123
135
} else {
124
136
likeDelta = 1;
125
137
try {
126
126
-
const result = await likePost({ uri: row.uri, cid: row.cid });
138
138
+
const result = await likePost({ uri: quoted.uri, cid: quoted.cid });
127
139
likeUri = result.uri;
128
140
} catch (err) {
129
141
console.error('[RedditPostCard] like failed', err);
···
153
165
</div>
154
166
{/if}
155
167
156
156
-
<h2 class="mb-2 mt-1 text-lg font-semibold leading-tight">{row.title}</h2>
168
168
+
{#if row.title}
169
169
+
<h2 class="mb-2 mt-1 text-lg font-semibold leading-tight">{row.title}</h2>
170
170
+
{:else}
171
171
+
<div class="text-base-500 dark:text-base-400 mt-1 mb-2 flex items-center gap-1 text-xs">
172
172
+
<Repeat size={12} />
173
173
+
<span>reposted</span>
174
174
+
</div>
175
175
+
{/if}
157
176
158
177
{#if quoted && quotedPostData}
159
159
-
<div class="border-base-300 dark:border-base-600/30 bg-base-500/10 dark:bg-black/30 mb-2 rounded-2xl border p-3">
178
178
+
<div class="border-base-300 dark:border-base-600/30 bg-base-500/10 dark:bg-black/30 rounded-2xl border p-3">
160
179
<Post
161
180
data={quotedPostData}
162
181
embeds={quotedEmbeds}
···
165
184
handleHref={(handle) => `/profile/${handle}`}
166
185
onclickhandle={(handle) => goto(`/profile/${handle}`)}
167
186
/>
187
187
+
188
188
+
<div class="mt-4 flex items-center gap-4 text-xs text-base-500 dark:text-base-400">
189
189
+
<button
190
190
+
type="button"
191
191
+
onclick={onLikeClick}
192
192
+
disabled={likeBusy}
193
193
+
class="hover:text-accent-500 flex cursor-pointer items-center gap-1 transition-colors {isLiked ? 'text-accent-500' : ''}"
194
194
+
aria-label={isLiked ? 'Unlike' : 'Like'}
195
195
+
>
196
196
+
<Heart size={14} fill={isLiked ? 'currentColor' : 'none'} />
197
197
+
{displayLikeCount}
198
198
+
</button>
199
199
+
<span class="flex items-center gap-1">
200
200
+
<MessageCircle size={14} /> {replyCount}
201
201
+
</span>
202
202
+
<span class="flex items-center gap-1">
203
203
+
<Repeat2 size={14} /> {repostCount}
204
204
+
</span>
205
205
+
</div>
168
206
</div>
169
207
{:else if quoted === null}
170
170
-
<div class="text-base-500 dark:text-base-400 mb-2 text-xs italic">
208
208
+
<div class="text-base-500 dark:text-base-400 text-xs italic">
171
209
(quoted post unavailable)
172
210
</div>
173
211
{:else}
174
174
-
<div class="border-base-300 dark:border-base-700 bg-base-200/50 dark:bg-base-800/50 mb-2 animate-pulse rounded-2xl border p-3">
212
212
+
<div class="border-base-300 dark:border-base-700 bg-base-200/50 dark:bg-base-800/50 animate-pulse rounded-2xl border p-3">
175
213
<div class="h-3 w-32 rounded bg-base-300 dark:bg-base-700"></div>
176
214
<div class="mt-2 h-3 w-full rounded bg-base-300 dark:bg-base-700"></div>
177
215
<div class="mt-1 h-3 w-3/4 rounded bg-base-300 dark:bg-base-700"></div>
178
216
</div>
179
217
{/if}
180
180
-
181
181
-
<div class="flex items-center gap-4 text-xs text-base-500 dark:text-base-400">
182
182
-
<button
183
183
-
type="button"
184
184
-
onclick={onLikeClick}
185
185
-
disabled={likeBusy}
186
186
-
class="hover:text-accent-500 flex cursor-pointer items-center gap-1 transition-colors {isLiked ? 'text-accent-500' : ''}"
187
187
-
aria-label={isLiked ? 'Unlike' : 'Like'}
188
188
-
>
189
189
-
<Heart size={14} fill={isLiked ? 'currentColor' : 'none'} />
190
190
-
{displayLikeCount}
191
191
-
</button>
192
192
-
<span class="flex items-center gap-1">
193
193
-
<MessageCircle size={14} /> {row.reply_count}
194
194
-
</span>
195
195
-
<span class="flex items-center gap-1">
196
196
-
<Repeat2 size={14} /> {row.repost_count}
197
197
-
</span>
198
198
-
</div>
199
218
</article>
···
470
470
if (!quotedCid) return false;
471
471
472
472
const createdAt = new Date().toISOString();
473
473
+
const isRepost = submission.title.length === 0;
474
474
+
473
475
let result: { uri: string; cid: string };
474
476
try {
475
475
-
result = await client.createRecord('app.bsky.feed.post', {
476
476
-
$type: 'app.bsky.feed.post',
477
477
-
text: submission.title,
478
478
-
createdAt,
479
479
-
embed: {
480
480
-
$type: 'app.bsky.embed.record',
481
481
-
record: {
482
482
-
uri: submission.postUri,
483
483
-
cid: quotedCid
477
477
+
if (isRepost) {
478
478
+
// No title → straight repost. The community account boosts the
479
479
+
// original post, no commentary added.
480
480
+
result = await client.createRecord('app.bsky.feed.repost', {
481
481
+
$type: 'app.bsky.feed.repost',
482
482
+
subject: { uri: submission.postUri, cid: quotedCid },
483
483
+
createdAt
484
484
+
});
485
485
+
} else {
486
486
+
result = await client.createRecord('app.bsky.feed.post', {
487
487
+
$type: 'app.bsky.feed.post',
488
488
+
text: submission.title,
489
489
+
createdAt,
490
490
+
embed: {
491
491
+
$type: 'app.bsky.embed.record',
492
492
+
record: { uri: submission.postUri, cid: quotedCid }
484
493
}
485
485
-
}
486
486
-
});
494
494
+
});
495
495
+
}
487
496
} catch (e) {
488
497
console.error('[createSubmissionPost] rookery createRecord failed', e);
489
498
return false;
···
548
557
export async function runCronTick(env: App.Platform['env']): Promise<{
549
558
communitiesChecked: number;
550
559
postsCreated: number;
551
551
-
postsRefreshed: number;
552
560
errors: string[];
553
561
}> {
554
562
const db = env.DB;
···
592
600
}
593
601
}
594
602
595
595
-
const postsRefreshed = await refreshPostMetrics(db).catch((e) => {
596
596
-
errors.push(`refresh: ${String(e)}`);
597
597
-
return 0;
598
598
-
});
599
599
-
600
603
return {
601
604
communitiesChecked: communities.length,
602
605
postsCreated,
603
603
-
postsRefreshed,
604
606
errors
605
607
};
606
608
}
···
31
31
* (`app.bsky.embed.record`).
32
32
*
33
33
* Returns null if the message doesn't reference a recognizable Bluesky post.
34
34
+
* Empty title is allowed — the bot turns title-less submissions into reposts
35
35
+
* instead of quote posts.
34
36
*/
35
37
export async function parseSubmission(msg: DmInput): Promise<ParsedSubmission | null> {
36
38
const text = msg.text ?? '';
···
39
41
// flow, which attaches an app.bsky.embed.record to the message.
40
42
const embedUri = msg.embed?.record?.uri;
41
43
if (embedUri && /^at:\/\/did:[^/]+\/app\.bsky\.feed\.post\//.test(embedUri)) {
42
42
-
const title = text.trim();
43
43
-
if (!title) return null;
44
44
-
return { title, postUri: embedUri as ResourceUri };
44
44
+
return { title: text.trim(), postUri: embedUri as ResourceUri };
45
45
}
46
46
47
47
if (!text) return null;
···
51
51
if (atMatch) {
52
52
const uri = atMatch[0] as ResourceUri;
53
53
const title = stripRange(text, atMatch.index!, atMatch[0].length);
54
54
-
if (!title) return null;
55
54
return { title, postUri: uri };
56
55
}
57
56
···
75
74
76
75
const postUri = `at://${did}/app.bsky.feed.post/${rkey}` as ResourceUri;
77
76
const title = stripRange(text, urlMatch.index!, urlMatch[0].length);
78
78
-
if (!title) return null;
79
77
80
78
return { title, postUri };
81
79
}
···
3
3
import { Loader2 } from '@lucide/svelte';
4
4
import { getHomeFeed } from '$lib/reddit/server/communities.remote';
5
5
import { getQuotedPosts } from '$lib/reddit/server/quoted-posts.remote';
6
6
-
import { getPostsViewerState } from '$lib/atproto/server/feed.remote';
7
7
-
import { user } from '$lib/atproto/auth.svelte';
8
6
import RedditPostCard from '$lib/reddit/RedditPostCard.svelte';
9
7
import type { PostWithCommunity } from '$lib/reddit/db';
10
8
11
9
let loading = $state(true);
12
10
let feed = $state<PostWithCommunity[]>([]);
13
11
let quoted = $state<Record<string, unknown>>({});
14
14
-
let viewerStates = $state<Record<string, { likeUri: string | null }>>({});
15
12
16
13
onMount(async () => {
17
14
try {
18
15
const f = await getHomeFeed({ limit: 50 });
19
16
feed = f;
20
17
if (f.length > 0) {
21
21
-
const uris = f.map((p) => p.uri);
22
22
-
const [quotedRes, viewerRes] = await Promise.all([
23
23
-
getQuotedPosts({ uris: f.map((p) => p.quoted_post_uri) }),
24
24
-
user.did
25
25
-
? getPostsViewerState({ uris })
26
26
-
: Promise.resolve({ states: {} })
27
27
-
]);
28
28
-
quoted = quotedRes.posts;
29
29
-
viewerStates = viewerRes.states;
18
18
+
const res = await getQuotedPosts({ uris: f.map((p) => p.quoted_post_uri) });
19
19
+
quoted = res.posts;
30
20
}
31
21
} catch (e) {
32
22
console.error(e);
···
54
44
row={p}
55
45
quoted={(quoted[p.quoted_post_uri] ?? undefined) as never}
56
46
showCommunity
57
57
-
likeUri={viewerStates[p.uri]?.likeUri ?? null}
58
47
/>
59
48
{/each}
60
49
</div>
···
5
5
import { Loader2, Check, UserPlus } from '@lucide/svelte';
6
6
import { getCommunity, getCommunityPosts } from '$lib/reddit/server/communities.remote';
7
7
import { getQuotedPosts } from '$lib/reddit/server/quoted-posts.remote';
8
8
-
import {
9
9
-
followUser,
10
10
-
unfollowUser,
11
11
-
getProfile,
12
12
-
getPostsViewerState
13
13
-
} from '$lib/atproto/server/feed.remote';
8
8
+
import { followUser, unfollowUser, getProfile } from '$lib/atproto/server/feed.remote';
14
9
import { user } from '$lib/atproto/auth.svelte';
15
10
import { loginModalState } from '$lib/LoginModal.svelte';
16
11
import RedditPostCard from '$lib/reddit/RedditPostCard.svelte';
···
23
18
let community = $state<CommunityInfo>(null);
24
19
let posts = $state<PostRow[]>([]);
25
20
let quoted = $state<Record<string, unknown>>({});
26
26
-
let viewerStates = $state<Record<string, { likeUri: string | null }>>({});
27
21
28
22
// Follow state — populated after the community loads, if the user is
29
23
// signed in. `followUri` is the AT-URI of the user's follow record
···
38
32
community = null;
39
33
posts = [];
40
34
quoted = {};
41
41
-
viewerStates = {};
42
35
followUri = null;
43
36
try {
44
37
const info = await getCommunity({ handle });
···
52
45
posts = rows;
53
46
54
47
if (rows.length > 0) {
55
55
-
const postUris = rows.map((r) => r.uri);
56
56
-
const [quotedRes, viewerRes] = await Promise.all([
57
57
-
getQuotedPosts({ uris: rows.map((r) => r.quoted_post_uri) }),
58
58
-
user.did
59
59
-
? getPostsViewerState({ uris: postUris })
60
60
-
: Promise.resolve({ states: {} })
61
61
-
]);
62
62
-
quoted = quotedRes.posts;
63
63
-
viewerStates = viewerRes.states;
48
48
+
const res = await getQuotedPosts({ uris: rows.map((r) => r.quoted_post_uri) });
49
49
+
quoted = res.posts;
64
50
}
65
51
66
52
// If the viewer is signed in, fetch viewer.following from the
···
175
161
row={p}
176
162
quoted={(quoted[p.quoted_post_uri] ?? undefined) as never}
177
163
accentColor={community.accentColor}
178
178
-
likeUri={viewerStates[p.uri]?.likeUri ?? null}
179
164
/>
180
165
{/each}
181
166
</div>