+1
-1
src/components/BskyPost.svelte
+1
-1
src/components/BskyPost.svelte
···
185
185
{#snippet profileInline()}
186
186
<button
187
187
class="
188
-
flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-200' : ''}
188
+
flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-125' : ''}
189
189
rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10
190
190
"
191
191
style="color: {color};"
+1
-1
src/components/FollowingView.svelte
+1
-1
src/components/FollowingView.svelte
···
80
80
isLongCalculation = false;
81
81
};
82
82
83
-
// todo: there is a bug where
83
+
// todo: there is a bug where the view doesn't update and just gets stuck being loaded
84
84
$effect(() => {
85
85
// Dependencies that trigger a re-sort
86
86
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+31
-25
src/components/PostComposer.svelte
+31
-25
src/components/PostComposer.svelte
···
10
10
import { parseToRichText } from '$lib/richtext';
11
11
import { tokenize } from '$lib/richtext/parser';
12
12
13
-
export type State =
13
+
export type FocusState =
14
14
| { type: 'null' }
15
15
| { type: 'focused'; quoting?: PostWithUri; replying?: PostWithUri };
16
+
export type State = {
17
+
focus: FocusState;
18
+
text: string;
19
+
};
16
20
17
21
interface Props {
18
22
client: AtpClient;
···
20
24
_state: State;
21
25
}
22
26
23
-
let { client, onPostSent, _state = $bindable({ type: 'null' }) }: Props = $props();
27
+
let {
28
+
client,
29
+
onPostSent,
30
+
_state = $bindable({ focus: { type: 'null' }, text: '' })
31
+
}: Props = $props();
24
32
25
-
const isFocused = $derived(_state.type === 'focused');
33
+
const isFocused = $derived(_state.focus.type === 'focused');
26
34
27
35
const color = $derived(
28
36
client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)'
···
38
46
// Parse rich text (mentions, links, tags)
39
47
const rt = await parseToRichText(text);
40
48
49
+
const focus = _state.focus;
41
50
const record: AppBskyFeedPost.Main = {
42
51
$type: 'app.bsky.feed.post',
43
52
text: rt.text,
44
53
facets: rt.facets,
45
54
reply:
46
-
_state.type === 'focused' && _state.replying
55
+
focus.type === 'focused' && focus.replying
47
56
? {
48
-
root: _state.replying.record.reply?.root ?? strongRef(_state.replying),
49
-
parent: strongRef(_state.replying)
57
+
root: focus.replying.record.reply?.root ?? strongRef(focus.replying),
58
+
parent: strongRef(focus.replying)
50
59
}
51
60
: undefined,
52
61
embed:
53
-
_state.type === 'focused' && _state.quoting
62
+
focus.type === 'focused' && focus.quoting
54
63
? {
55
64
$type: 'app.bsky.embed.record',
56
-
record: strongRef(_state.quoting)
65
+
record: strongRef(focus.quoting)
57
66
}
58
67
: undefined,
59
68
createdAt: new Date().toISOString()
···
79
88
});
80
89
};
81
90
82
-
let postText = $state('');
83
91
let info = $state('');
84
92
let textareaEl: HTMLTextAreaElement | undefined = $state();
85
93
86
-
const unfocus = () => {
87
-
_state.type = 'null';
88
-
};
94
+
const unfocus = () => (_state.focus.type = 'null');
89
95
90
96
const doPost = () => {
91
-
if (postText.length === 0 || postText.length > 300) return;
97
+
if (_state.text.length === 0 || _state.text.length > 300) return;
92
98
93
-
post(postText).then((res) => {
99
+
post(_state.text).then((res) => {
94
100
if (res.ok) {
95
101
onPostSent(res.value);
96
-
postText = '';
102
+
_state.text = '';
97
103
info = 'posted!';
98
104
unfocus();
99
105
setTimeout(() => (info = ''), 800);
···
141
147
<div class="grow"></div>
142
148
<span
143
149
class="text-sm font-medium"
144
-
style="color: color-mix(in srgb, {postText.length > 300
150
+
style="color: color-mix(in srgb, {_state.text.length > 300
145
151
? '#ef4444'
146
152
: 'var(--nucleus-fg)'} 53%, transparent);"
147
153
>
148
-
{postText.length} / 300
154
+
{_state.text.length} / 300
149
155
</span>
150
156
<button
151
157
onmousedown={(e) => {
152
158
e.preventDefault();
153
159
doPost();
154
160
}}
155
-
disabled={postText.length === 0 || postText.length > 300}
161
+
disabled={_state.text.length === 0 || _state.text.length > 300}
156
162
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
157
163
style="background: color-mix(in srgb, {color} 87%, transparent);"
158
164
>
···
169
175
class="pointer-events-none col-start-1 row-start-1 min-h-[5lh] w-full bg-transparent text-wrap break-all whitespace-pre-wrap text-(--nucleus-fg)"
170
176
aria-hidden="true"
171
177
>
172
-
{@render highlighter(postText)}
178
+
{@render highlighter(_state.text)}
173
179
</div>
174
180
175
181
<textarea
176
182
bind:this={textareaEl}
177
-
bind:value={postText}
178
-
onfocus={() => (_state.type = 'focused')}
183
+
bind:value={_state.text}
184
+
onfocus={() => (_state.focus.type = 'focused')}
179
185
onblur={unfocus}
180
186
onkeydown={(event) => {
181
187
if (event.key === 'Escape') unfocus();
···
222
228
</div>
223
229
{:else}
224
230
<div class="flex flex-col gap-2">
225
-
{#if _state.type === 'focused'}
226
-
{@render composer(_state.replying, _state.quoting)}
231
+
{#if _state.focus.type === 'focused'}
232
+
{@render composer(_state.focus.replying, _state.focus.quoting)}
227
233
{:else}
228
234
<input
229
-
bind:value={postText}
230
-
onfocus={() => (_state = { type: 'focused' })}
235
+
bind:value={_state.text}
236
+
onfocus={() => (_state.focus.type = 'focused')}
231
237
type="text"
232
238
placeholder="what's on your mind?"
233
239
class="flex-1"
+2
-2
src/components/ProfileView.svelte
+2
-2
src/components/ProfileView.svelte
···
20
20
client: AtpClient;
21
21
actor: string;
22
22
onBack: () => void;
23
-
postComposerState?: PostComposerState;
23
+
postComposerState: PostComposerState;
24
24
}
25
25
26
-
let { client, actor, onBack, postComposerState = $bindable({ type: 'null' }) }: Props = $props();
26
+
let { client, actor, onBack, postComposerState = $bindable() }: Props = $props();
27
27
28
28
let profile = $state<AppBskyActorProfile.Main | null>(null);
29
29
const displayName = $derived(profile?.displayName ?? '');
+2
-2
src/components/TimelineView.svelte
+2
-2
src/components/TimelineView.svelte
···
120
120
<div class="mb-1.5">
121
121
<BskyPost
122
122
client={client!}
123
-
onQuote={(post) => (postComposerState = { type: 'focused', quoting: post })}
124
-
onReply={(post) => (postComposerState = { type: 'focused', replying: post })}
123
+
onQuote={(post) => (postComposerState.focus = { type: 'focused', quoting: post })}
124
+
onReply={(post) => (postComposerState.focus = { type: 'focused', replying: post })}
125
125
{...post}
126
126
/>
127
127
</div>
+2
-2
src/lib/at/fetch.ts
+2
-2
src/lib/at/fetch.ts
···
8
8
import { err, expect, ok, type Ok, type Result } from '$lib/result';
9
9
import type { Backlinks } from './constellation';
10
10
import { AppBskyFeedPost } from '@atcute/bluesky';
11
-
import type { AtprotoDid, Did, RecordKey } from '@atcute/lexicons/syntax';
11
+
import type { Did, RecordKey } from '@atcute/lexicons/syntax';
12
12
import { replySource, toCanonicalUri } from '$lib';
13
13
14
14
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
···
60
60
61
61
export const hydratePosts = async (
62
62
client: AtpClient,
63
-
repo: AtprotoDid,
63
+
repo: Did,
64
64
data: PostWithBacklinks[],
65
65
cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined
66
66
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
+16
-10
src/lib/state.svelte.ts
+16
-10
src/lib/state.svelte.ts
···
359
359
await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp)));
360
360
};
361
361
362
-
export const handleJetstreamEvent = (event: JetstreamEvent) => {
362
+
export const handleJetstreamEvent = async (event: JetstreamEvent) => {
363
363
if (event.kind !== 'commit') return;
364
364
365
365
const { did, commit } = event;
366
366
const uri: ResourceUri = toCanonicalUri({ did, ...commit });
367
367
if (commit.collection === 'app.bsky.feed.post') {
368
368
if (commit.operation === 'create') {
369
-
const { cid, record } = commit;
370
-
const post: PostWithUri = {
371
-
uri,
372
-
cid,
373
-
// assume record is valid, we trust the jetstream
374
-
record: record as AppBskyFeedPost.Main
375
-
};
376
-
addPosts([post]);
377
-
addTimeline(did, [uri]);
369
+
const posts = [
370
+
{
371
+
record: commit.record as AppBskyFeedPost.Main,
372
+
uri,
373
+
cid: commit.cid
374
+
}
375
+
];
376
+
const client = await getClient(did);
377
+
const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn);
378
+
if (!hydrated.ok) {
379
+
console.error(`cant hydrate posts ${did}: ${hydrated.error}`);
380
+
return;
381
+
}
382
+
addPosts(hydrated.value.values());
383
+
addTimeline(did, hydrated.value.keys());
378
384
} else if (commit.operation === 'delete') {
379
385
allPosts.get(did)?.delete(uri);
380
386
}
+4
-3
src/routes/[...catchall]/+page.svelte
+4
-3
src/routes/[...catchall]/+page.svelte
···
83
83
else animClass = 'animate-fade-in-scale';
84
84
});
85
85
86
-
let postComposerState = $state<PostComposerState>({ type: 'null' });
86
+
let postComposerState = $state<PostComposerState>({ focus: { type: 'null' }, text: '' });
87
87
let showScrollToTop = $state(false);
88
88
const handleScroll = () => {
89
-
if (router.current.path === '/') showScrollToTop = window.scrollY > 300;
89
+
if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor')
90
+
showScrollToTop = window.scrollY > 300;
90
91
};
91
92
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });
92
93
···
305
306
</div>
306
307
{/if}
307
308
308
-
{#if postComposerState.type === 'null' && showScrollToTop}
309
+
{#if postComposerState.focus.type === 'null' && showScrollToTop}
309
310
{@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top', false)}
310
311
{/if}
311
312
</div>