+18
-16
src/components/BskyPost.svelte
+18
-16
src/components/BskyPost.svelte
···
31
31
router
32
32
} from '$lib/state.svelte';
33
33
import type { PostWithUri } from '$lib/at/fetch';
34
-
import { onMount } from 'svelte';
34
+
import { onMount, type Snippet } from 'svelte';
35
35
import { derived } from 'svelte/store';
36
36
import Device from 'svelte-device-info';
37
37
import Dropdown from './Dropdown.svelte';
···
54
54
isOnPostComposer?: boolean;
55
55
onQuote?: (quote: PostWithUri) => void;
56
56
onReply?: (reply: PostWithUri) => void;
57
+
cornerFragment?: Snippet;
57
58
}
58
59
59
60
const {
···
65
66
mini,
66
67
onQuote,
67
68
onReply,
68
-
isOnPostComposer = false /* replyBacklinks */
69
+
isOnPostComposer = false /* replyBacklinks */,
70
+
cornerFragment
69
71
}: Props = $props();
70
72
71
73
const selectedDid = $derived(client.user?.did ?? null);
···
93
95
const postId = $derived(`timeline-post-${aturi}-${quoteDepth}`);
94
96
const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
95
97
98
+
// todo: this fucking sucks
96
99
const scrollToAndPulse = (targetUri: ResourceUri) => {
97
100
const targetId = `timeline-post-${targetUri}-0`;
98
101
// console.log(`Scrolling to ${targetId}`);
···
274
277
border-color: {color}{isOnPostComposer ? '99' : '66'};
275
278
"
276
279
>
277
-
<div
278
-
class="
279
-
mb-3 flex w-fit max-w-full items-center gap-1 rounded-sm pr-1
280
-
"
281
-
style="background: {color}33;"
282
-
>
283
-
{@render profilePopout()}
284
-
<span>·</span>
285
-
<span
286
-
title={new Date(record.createdAt).toLocaleString()}
287
-
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
288
-
>
289
-
{getRelativeTime(new Date(record.createdAt), currentTime)}
290
-
</span>
280
+
<div class="mb-3 flex max-w-full items-center justify-between">
281
+
<div class="flex items-center gap-1 rounded-sm pr-1" style="background: {color}33;">
282
+
{@render profilePopout()}
283
+
<span>·</span>
284
+
<span
285
+
title={new Date(record.createdAt).toLocaleString()}
286
+
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
287
+
>
288
+
{getRelativeTime(new Date(record.createdAt), currentTime)}
289
+
</span>
290
+
</div>
291
+
{@render cornerFragment?.()}
291
292
</div>
293
+
292
294
<p class="leading-normal text-wrap wrap-break-word">
293
295
<RichText text={record.text} facets={record.facets ?? []} />
294
296
{#if isOnPostComposer && record.embed}
+29
-31
src/components/PostComposer.svelte
+29
-31
src/components/PostComposer.svelte
···
9
9
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
10
10
import { parseToRichText } from '$lib/richtext';
11
11
import { tokenize } from '$lib/richtext/parser';
12
+
import Icon from '@iconify/svelte';
12
13
13
-
export type FocusState =
14
-
| { type: 'null' }
15
-
| { type: 'focused'; quoting?: PostWithUri; replying?: PostWithUri };
14
+
export type FocusState = 'null' | 'focused';
16
15
export type State = {
17
16
focus: FocusState;
18
17
text: string;
18
+
quoting?: PostWithUri;
19
+
replying?: PostWithUri;
19
20
};
20
21
21
22
interface Props {
···
24
25
_state: State;
25
26
}
26
27
27
-
let {
28
-
client,
29
-
onPostSent,
30
-
_state = $bindable({ focus: { type: 'null' }, text: '' })
31
-
}: Props = $props();
28
+
let { client, onPostSent, _state = $bindable({ focus: 'null', text: '' }) }: Props = $props();
32
29
33
-
const isFocused = $derived(_state.focus.type === 'focused');
30
+
const isFocused = $derived(_state.focus === 'focused');
34
31
35
32
const color = $derived(
36
33
client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)'
···
46
43
// Parse rich text (mentions, links, tags)
47
44
const rt = await parseToRichText(text);
48
45
49
-
const focus = _state.focus;
50
46
const record: AppBskyFeedPost.Main = {
51
47
$type: 'app.bsky.feed.post',
52
48
text: rt.text,
53
49
facets: rt.facets,
54
50
reply:
55
-
focus.type === 'focused' && focus.replying
51
+
_state.focus === 'focused' && _state.replying
56
52
? {
57
-
root: focus.replying.record.reply?.root ?? strongRef(focus.replying),
58
-
parent: strongRef(focus.replying)
53
+
root: _state.replying.record.reply?.root ?? strongRef(_state.replying),
54
+
parent: strongRef(_state.replying)
59
55
}
60
56
: undefined,
61
57
embed:
62
-
focus.type === 'focused' && focus.quoting
58
+
_state.focus === 'focused' && _state.quoting
63
59
? {
64
60
$type: 'app.bsky.embed.record',
65
-
record: strongRef(focus.quoting)
61
+
record: strongRef(_state.quoting)
66
62
}
67
63
: undefined,
68
64
createdAt: new Date().toISOString()
···
91
87
let info = $state('');
92
88
let textareaEl: HTMLTextAreaElement | undefined = $state();
93
89
94
-
const unfocus = () => (_state.focus.type = 'null');
90
+
const unfocus = () => (_state.focus = 'null');
95
91
96
92
const doPost = () => {
97
93
if (_state.text.length === 0 || _state.text.length > 300) return;
···
117
113
});
118
114
</script>
119
115
120
-
{#snippet renderPost(post: PostWithUri)}
116
+
{#snippet attachedPost(post: PostWithUri, type: 'quoting' | 'replying')}
121
117
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
122
-
<BskyPost
123
-
{client}
124
-
did={parsedUri.repo}
125
-
rkey={parsedUri.rkey}
126
-
data={post}
127
-
isOnPostComposer={true}
128
-
/>
118
+
<BskyPost {client} did={parsedUri.repo} rkey={parsedUri.rkey} data={post} isOnPostComposer={true}>
119
+
{#snippet cornerFragment()}
120
+
<button
121
+
class="transition-transform hover:scale-150"
122
+
onclick={() => {
123
+
if (_state.focus === 'focused') _state[type] = undefined;
124
+
}}><Icon width={24} icon="heroicons:x-mark-16-solid" /></button
125
+
>
126
+
{/snippet}
127
+
</BskyPost>
129
128
{/snippet}
130
129
131
130
{#snippet highlighter(text: string)}
···
166
165
</button>
167
166
</div>
168
167
{#if replying}
169
-
{@render renderPost(replying)}
168
+
{@render attachedPost(replying, 'replying')}
170
169
{/if}
171
170
<div class="composer space-y-2">
172
171
<div class="relative grid">
···
181
180
<textarea
182
181
bind:this={textareaEl}
183
182
bind:value={_state.text}
184
-
onfocus={() => (_state.focus.type = 'focused')}
183
+
onfocus={() => (_state.focus = 'focused')}
185
184
onblur={unfocus}
186
185
onkeydown={(event) => {
187
186
if (event.key === 'Escape') unfocus();
···
192
191
class="col-start-1 row-start-1 field-sizing-content min-h-[5lh] w-full resize-none overflow-hidden bg-transparent text-wrap break-all whitespace-pre-wrap text-transparent caret-(--nucleus-fg) placeholder:text-(--nucleus-fg)/45"
193
192
></textarea>
194
193
</div>
195
-
196
194
{#if quoting}
197
-
{@render renderPost(quoting)}
195
+
{@render attachedPost(quoting, 'quoting')}
198
196
{/if}
199
197
</div>
200
198
{/snippet}
···
228
226
</div>
229
227
{:else}
230
228
<div class="flex flex-col gap-2">
231
-
{#if _state.focus.type === 'focused'}
232
-
{@render composer(_state.focus.replying, _state.focus.quoting)}
229
+
{#if _state.focus === 'focused'}
230
+
{@render composer(_state.replying, _state.quoting)}
233
231
{:else}
234
232
<input
235
233
bind:value={_state.text}
236
-
onfocus={() => (_state.focus.type = 'focused')}
234
+
onfocus={() => (_state.focus = 'focused')}
237
235
type="text"
238
236
placeholder="what's on your mind?"
239
237
class="flex-1"
+8
-2
src/components/TimelineView.svelte
+8
-2
src/components/TimelineView.svelte
···
120
120
<div class="mb-1.5">
121
121
<BskyPost
122
122
client={client!}
123
-
onQuote={(post) => (postComposerState.focus = { type: 'focused', quoting: post })}
124
-
onReply={(post) => (postComposerState.focus = { type: 'focused', replying: post })}
123
+
onQuote={(post) => {
124
+
postComposerState.focus = 'focused';
125
+
postComposerState.quoting = post;
126
+
}}
127
+
onReply={(post) => {
128
+
postComposerState.focus = 'focused';
129
+
postComposerState.replying = post;
130
+
}}
125
131
{...post}
126
132
/>
127
133
</div>
+2
-2
src/routes/[...catchall]/+page.svelte
+2
-2
src/routes/[...catchall]/+page.svelte
···
83
83
else animClass = 'animate-fade-in-scale';
84
84
});
85
85
86
-
let postComposerState = $state<PostComposerState>({ focus: { type: 'null' }, text: '' });
86
+
let postComposerState = $state<PostComposerState>({ focus: 'null', text: '' });
87
87
let showScrollToTop = $state(false);
88
88
const handleScroll = () => {
89
89
if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor')
···
306
306
</div>
307
307
{/if}
308
308
309
-
{#if postComposerState.focus.type === 'null' && showScrollToTop}
309
+
{#if postComposerState.focus === 'null' && showScrollToTop}
310
310
{@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top', false)}
311
311
{/if}
312
312
</div>