@atmo-dev/kit 0.7.1 → 0.8.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 +41 -11
- package/dist/components/RichText.svelte +130 -17
- package/dist/components/RichText.svelte.d.ts +10 -3
- package/dist/events/EventComments.svelte +2 -1
- package/dist/events/EventEditor.svelte +11 -14
- package/dist/events/EventView.svelte +10 -3
- package/dist/events/PostToBlueskyModal.svelte +4 -3
- package/dist/events/TimezonePicker.svelte +2 -1
- package/dist/events/atproto-helpers.d.ts +6 -0
- package/dist/events/atproto-helpers.js +15 -1
- package/dist/events/conference.d.ts +3 -2
- package/dist/events/conference.js +4 -3
- package/dist/events/editor/RecurringModal.svelte +4 -0
- package/dist/events/editor/save.d.ts +1 -1
- package/dist/events/editor/save.js +2 -2
- package/dist/events/view-data.d.ts +9 -0
- package/dist/events-data.d.ts +1 -1
- package/dist/events-data.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/richtext/detect.d.ts +7 -0
- package/dist/richtext/detect.js +72 -0
- package/dist/richtext/document.d.ts +75 -0
- package/dist/richtext/document.js +105 -0
- package/dist/richtext/facets.d.ts +28 -0
- package/dist/richtext/facets.js +113 -0
- package/dist/richtext/markdown.d.ts +10 -0
- package/dist/richtext/markdown.js +236 -0
- package/dist/richtext/plain.d.ts +9 -0
- package/dist/richtext/plain.js +8 -0
- package/dist/richtext.d.ts +17 -0
- package/dist/richtext.js +12 -0
- package/dist/social/profile.js +2 -2
- package/dist/social/richtext.d.ts +5 -8
- package/dist/social/richtext.js +3 -94
- package/package.json +24 -16
package/README.md
CHANGED
|
@@ -121,7 +121,19 @@ The event UI formerly in `@atmo-dev/events-ui` lives in this package:
|
|
|
121
121
|
- `@atmo-dev/kit/events-data`: structural event types, adapter contracts, conference/date helpers, URL helpers, theme values, linked-record types and thumbnail selection. This entry is safe in plain Node and server routes: no Svelte, CSS, maps or players are loaded. Browser helpers such as `compressImage` only access browser APIs when called.
|
|
122
122
|
- `@atmo-dev/kit/events/editor`: `buildEventRecord` and `buildThumbnailMedia` for hosts that need explicit record construction. This separate entry includes browser-oriented thumbnail code; it is not part of the pure data API.
|
|
123
123
|
|
|
124
|
-
Event descriptions are
|
|
124
|
+
Event descriptions are Markdown with AT Protocol facets. Parse them on the server and pass the result as `data.description`, so the Markdown parser stays out of the client bundle:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { parseRichText } from '@atmo-dev/kit/richtext';
|
|
128
|
+
|
|
129
|
+
const description = event.description
|
|
130
|
+
? parseRichText(event.description, { facets: event.facets, markdown: true, detectLinks: true })
|
|
131
|
+
: null;
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Without `data.description`, `EventView` shows the description as plain text with facet links. Description headings render one level below the event title, and the Bluesky share card uses `richTextToPlainText(description)`. Conference schedules show talk descriptions as short previews: pass `conferenceTalkDescriptions`, plain text keyed by talk URI, so Markdown syntax does not appear there. See [Rich text](#rich-text) for what is supported.
|
|
135
|
+
|
|
136
|
+
Event components read Bluesky's public AppView and CDN by default. Apps using other services call `configureAtproto({ appview, cdn })` from `events-data` once, before rendering, on both the server and the client (for example at the top of the root layout).
|
|
125
137
|
|
|
126
138
|
Event cards and covers prefer native `media` images, then an HTTPS `poster.url`, then a URI named `Event Image` (laugh.town compatibility). `getEventExternalImage(event)` from `events-data` returns `{ url, alt, aspectRatio? }` or `null`, validating URLs and optional poster dimensions. It performs no IO; apps must apply their own allowlist/redirect policy before fetching an external image server-side.
|
|
127
139
|
|
|
@@ -194,25 +206,43 @@ For now, `createGrainXrpcClient(fetchImpl?)` provides the Grain.social implement
|
|
|
194
206
|
|
|
195
207
|
Use `blueskyPostToPostData(postView)` to convert public AppView posts, including `repostCount`. `PostContent` supports images, galleries, external link cards, HLS video, quoted posts (including their embeds), record-with-media combinations, feeds, lists, starter packs, and moderation-service cards. Missing, blocked, and detached quotes have readable placeholders; unknown embed variants show an unavailable fallback. Quote nesting is capped at three levels. Non-post record cards use Bluesky routes, since other clients may not implement them. Apps still own fetching and moderation.
|
|
196
208
|
|
|
197
|
-
###
|
|
209
|
+
### Rich text
|
|
198
210
|
|
|
199
|
-
`RichText`
|
|
211
|
+
`RichText` renders text shared by posts, event descriptions and other user content. Plain text can be passed directly:
|
|
200
212
|
|
|
201
213
|
```svelte
|
|
202
|
-
<script lang="ts">
|
|
203
|
-
import { RichText } from '@atmo-dev/kit';
|
|
204
|
-
</script>
|
|
205
|
-
|
|
206
214
|
<RichText
|
|
207
|
-
text={
|
|
208
|
-
{facets}
|
|
215
|
+
text={post.text}
|
|
216
|
+
facets={post.facets}
|
|
209
217
|
{socialClient}
|
|
210
218
|
mentionUrl={(actor) => `/p/${encodeURIComponent(actor)}`}
|
|
211
|
-
detectLinks
|
|
212
219
|
/>
|
|
213
220
|
```
|
|
214
221
|
|
|
215
|
-
|
|
222
|
+
For Markdown, parse with `@atmo-dev/kit/richtext` (no Svelte or DOM; works in Workers) and pass `content`:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
// +page.server.ts
|
|
226
|
+
import { parseRichText } from '@atmo-dev/kit/richtext';
|
|
227
|
+
|
|
228
|
+
const content = parseRichText(text, { facets, markdown: true, detectLinks: true });
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
```svelte
|
|
232
|
+
<RichText {content} {socialClient} headingOffset={1} />
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Each feature can be turned on separately:
|
|
236
|
+
|
|
237
|
+
- `markdown`: CommonMark plus `~~strikethrough~~`. Single newlines are line breaks. Raw HTML is shown as written. Images are never loaded; they become links labeled by their alt text. Tables and other extensions stay literal text. Text over 10,000 characters, with more than 1,000 `*`, `_`, `~` or `[` characters, or with more than 8 nested quote or list markers on a line, is rendered as plain text, because the parser slows down quadratically on some inputs. Without `markdown`, text is plain and whitespace is preserved.
|
|
238
|
+
- `facets`: record facets are validated at runtime. Malformed, overlapping or mid-character ranges are ignored without dropping text. In Markdown, facets apply to formatted text such as `**@alice**`; facets in code, inside Markdown links, across Markdown syntax or on text changed by escapes or entities are ignored.
|
|
239
|
+
- `detectLinks`: detects URLs, mentions and hashtags when there are no facets, outside code and existing links. Detection uses the mention, hashtag and URL rules of `@atcute/bluesky-richtext-parser` in one linear pass. Its `tokenize` also parses emphasis and backtracks badly on runs of `*` (a hundred take minutes), so do not run it on untrusted text; `detectTokens` from `@atmo-dev/kit/richtext` returns compatible tokens.
|
|
240
|
+
|
|
241
|
+
Links accept only HTTP(S) URLs without credentials. `socialClient` selects mention and hashtag destinations; `mentionUrl` overrides mention routing with an HTTP(S) URL or root-relative path. Local links stay in the same tab; external links use `noopener noreferrer nofollow ugc`. Post text keeps Bluesky destinations by default.
|
|
242
|
+
|
|
243
|
+
The parsed `RichTextDocument` is plain JSON with a `version`, independent of the viewer, so it can be sent in page data and cached publicly. `RichText` still treats it as untrusted: it renders only known elements, checks every link again and shows unknown nodes as text. Prefer parsing per request (a few milliseconds) over storing documents. `richTextToPlainText(content)` returns text without Markdown syntax for meta descriptions and previews.
|
|
244
|
+
|
|
245
|
+
To parse in the browser, for example in an editor preview, load the parser on demand with `await import('@atmo-dev/kit/richtext')` so it is not part of the initial bundle.
|
|
216
246
|
|
|
217
247
|
### Special link embeds
|
|
218
248
|
|
|
@@ -1,33 +1,146 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
inlineHref,
|
|
4
|
+
inlineText,
|
|
5
|
+
MAX_RENDER_DEPTH,
|
|
6
|
+
type RichTextDocument,
|
|
7
|
+
type RichTextLinkOptions
|
|
8
|
+
} from '../richtext/document.js';
|
|
9
|
+
import { parsePlainText } from '../richtext/plain.js';
|
|
3
10
|
import { cn } from '../utils.js';
|
|
4
11
|
|
|
5
|
-
export type RichTextProps =
|
|
6
|
-
text
|
|
12
|
+
export type RichTextProps = RichTextLinkOptions & {
|
|
13
|
+
/** From `parseRichText`, usually on the server. Takes precedence over `text`. */
|
|
14
|
+
content?: RichTextDocument | null;
|
|
15
|
+
/** Plain text, parsed here; Markdown requires `content`. */
|
|
16
|
+
text?: string;
|
|
7
17
|
/** Public record data is validated at runtime, including malformed facets. */
|
|
8
18
|
facets?: unknown;
|
|
19
|
+
/** Detect plain-text links, mentions and hashtags when there are no facets. */
|
|
20
|
+
detectLinks?: boolean;
|
|
21
|
+
/** Added to Markdown heading levels, e.g. 1 renders `#` as h2 below a page's h1. */
|
|
22
|
+
headingOffset?: number;
|
|
9
23
|
class?: string;
|
|
10
24
|
};
|
|
11
25
|
|
|
12
26
|
let {
|
|
13
|
-
|
|
27
|
+
content,
|
|
28
|
+
text = '',
|
|
14
29
|
facets,
|
|
30
|
+
detectLinks = false,
|
|
15
31
|
socialClient,
|
|
16
32
|
mentionUrl,
|
|
17
|
-
|
|
33
|
+
headingOffset = 0,
|
|
18
34
|
class: className
|
|
19
35
|
}: RichTextProps = $props();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
36
|
+
|
|
37
|
+
let doc = $derived(content ?? parsePlainText(text, { facets, detectLinks }));
|
|
38
|
+
|
|
39
|
+
type Node = Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
function isNode(value: unknown): value is Node {
|
|
42
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function nodes(value: unknown): Node[] {
|
|
45
|
+
return Array.isArray(value) ? value.filter(isNode) : [];
|
|
46
|
+
}
|
|
47
|
+
function items(value: unknown): unknown[] {
|
|
48
|
+
return Array.isArray(value) ? value.filter(Array.isArray) : [];
|
|
49
|
+
}
|
|
50
|
+
function string(value: unknown): string {
|
|
51
|
+
return typeof value === 'string' ? value : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function href(node: Node): string | undefined {
|
|
55
|
+
return inlineHref(node, { socialClient, mentionUrl });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function heading(depth: unknown): string {
|
|
59
|
+
const level = (Number.isInteger(depth) ? (depth as number) : 6) + Math.trunc(headingOffset);
|
|
60
|
+
return `h${Math.min(6, Math.max(1, level))}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const linkClass =
|
|
64
|
+
'text-action underline decoration-action/40 underline-offset-2 hover:decoration-action';
|
|
65
|
+
const codeClass = 'rounded bg-recess px-1 py-0.5 font-mono text-[0.875em]';
|
|
23
66
|
</script>
|
|
24
67
|
|
|
25
|
-
|
|
26
|
-
{#each
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
>{
|
|
32
|
-
>
|
|
33
|
-
</
|
|
68
|
+
{#snippet inlines(children: unknown, inLink: boolean, depth: number)}
|
|
69
|
+
{#each nodes(children) as node, index (index)}
|
|
70
|
+
{#if depth > MAX_RENDER_DEPTH}{inlineText(node, depth)}
|
|
71
|
+
{:else if node.type === 'text'}{string(node.value)}
|
|
72
|
+
{:else if node.type === 'break'}<br />
|
|
73
|
+
{:else if node.type === 'strong'}<strong class="font-semibold"
|
|
74
|
+
>{@render inlines(node.children, inLink, depth + 1)}</strong
|
|
75
|
+
>
|
|
76
|
+
{:else if node.type === 'emphasis'}<em>{@render inlines(node.children, inLink, depth + 1)}</em>
|
|
77
|
+
{:else if node.type === 'delete'}<del>{@render inlines(node.children, inLink, depth + 1)}</del>
|
|
78
|
+
{:else if node.type === 'code'}<code class={codeClass}>{string(node.value)}</code>
|
|
79
|
+
{:else if node.type === 'link' || node.type === 'mention' || node.type === 'tag'}
|
|
80
|
+
{@const url = inLink ? undefined : href(node)}
|
|
81
|
+
{#if url}<a
|
|
82
|
+
href={url}
|
|
83
|
+
target={url.startsWith('/') ? undefined : '_blank'}
|
|
84
|
+
rel={url.startsWith('/') ? undefined : 'noopener noreferrer nofollow ugc'}
|
|
85
|
+
class={linkClass}
|
|
86
|
+
>{#if node.type === 'link'}{@render inlines(
|
|
87
|
+
node.children,
|
|
88
|
+
true,
|
|
89
|
+
depth + 1
|
|
90
|
+
)}{:else}{string(node.text)}{/if}</a
|
|
91
|
+
>
|
|
92
|
+
{:else if node.type === 'link'}{@render inlines(node.children, inLink, depth + 1)}
|
|
93
|
+
{:else}{string(node.text)}{/if}
|
|
94
|
+
{:else}{inlineText(node, depth)}{/if}
|
|
95
|
+
{/each}
|
|
96
|
+
{/snippet}
|
|
97
|
+
|
|
98
|
+
{#snippet blocks(children: unknown, depth: number)}
|
|
99
|
+
{#each nodes(children) as node, index (index)}
|
|
100
|
+
{#if depth > MAX_RENDER_DEPTH}<p>{inlineText(node, depth)}</p>
|
|
101
|
+
{:else if node.type === 'paragraph'}<p>{@render inlines(node.children, false, depth + 1)}</p>
|
|
102
|
+
{:else if node.type === 'heading'}<svelte:element
|
|
103
|
+
this={heading(node.depth)}
|
|
104
|
+
class="{node.depth === 1
|
|
105
|
+
? 'text-xl'
|
|
106
|
+
: node.depth === 2
|
|
107
|
+
? 'text-lg'
|
|
108
|
+
: 'text-base'} leading-snug font-semibold"
|
|
109
|
+
>{@render inlines(node.children, false, depth + 1)}</svelte:element
|
|
110
|
+
>
|
|
111
|
+
{:else if node.type === 'blockquote'}<blockquote class="space-y-3 border-l-2 border-line pl-4">
|
|
112
|
+
{@render blocks(node.children, depth + 1)}
|
|
113
|
+
</blockquote>
|
|
114
|
+
{:else if node.type === 'list'}<svelte:element
|
|
115
|
+
this={node.ordered === true ? 'ol' : 'ul'}
|
|
116
|
+
start={node.ordered === true && Number.isInteger(node.start)
|
|
117
|
+
? (node.start as number)
|
|
118
|
+
: undefined}
|
|
119
|
+
class={cn('space-y-1 pl-6', node.ordered === true ? 'list-decimal' : 'list-disc')}
|
|
120
|
+
>
|
|
121
|
+
{#each items(node.items) as item, itemIndex (itemIndex)}
|
|
122
|
+
<li class="space-y-3">{@render blocks(item, depth + 1)}</li>
|
|
123
|
+
{/each}
|
|
124
|
+
</svelte:element>
|
|
125
|
+
{:else if node.type === 'codeBlock'}<pre
|
|
126
|
+
class="overflow-x-auto rounded-md bg-recess p-3 font-mono text-[0.875em]"><code
|
|
127
|
+
>{string(node.value)}</code
|
|
128
|
+
></pre>
|
|
129
|
+
{:else if node.type === 'thematicBreak'}<hr class="border-line" />
|
|
130
|
+
{:else}<p>{inlineText(node, depth)}</p>{/if}
|
|
131
|
+
{/each}
|
|
132
|
+
{/snippet}
|
|
133
|
+
|
|
134
|
+
{#if doc.markdown}
|
|
135
|
+
<div class={cn('space-y-3 leading-relaxed wrap-anywhere text-ink', className)}>
|
|
136
|
+
{@render blocks(doc.children, 0)}
|
|
137
|
+
</div>
|
|
138
|
+
{:else}
|
|
139
|
+
<p class={cn('leading-relaxed wrap-anywhere whitespace-pre-wrap text-ink', className)}>
|
|
140
|
+
{#each nodes(doc.children) as block, index (index)}{@render inlines(
|
|
141
|
+
block.children,
|
|
142
|
+
false,
|
|
143
|
+
1
|
|
144
|
+
)}{/each}
|
|
145
|
+
</p>
|
|
146
|
+
{/if}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
export type RichTextProps =
|
|
3
|
-
text
|
|
1
|
+
import { type RichTextDocument, type RichTextLinkOptions } from '../richtext/document.js';
|
|
2
|
+
export type RichTextProps = RichTextLinkOptions & {
|
|
3
|
+
/** From `parseRichText`, usually on the server. Takes precedence over `text`. */
|
|
4
|
+
content?: RichTextDocument | null;
|
|
5
|
+
/** Plain text, parsed here; Markdown requires `content`. */
|
|
6
|
+
text?: string;
|
|
4
7
|
/** Public record data is validated at runtime, including malformed facets. */
|
|
5
8
|
facets?: unknown;
|
|
9
|
+
/** Detect plain-text links, mentions and hashtags when there are no facets. */
|
|
10
|
+
detectLinks?: boolean;
|
|
11
|
+
/** Added to Markdown heading levels, e.g. 1 renders `#` as h2 below a page's h1. */
|
|
12
|
+
headingOffset?: number;
|
|
6
13
|
class?: string;
|
|
7
14
|
};
|
|
8
15
|
declare const RichText: import("svelte").Component<RichTextProps, {}, "">;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { NestedComments, blueskyPostToPostData, type PostData } from '../index.js';
|
|
3
|
+
import { appviewUrl } from './atproto-helpers.js';
|
|
3
4
|
|
|
4
5
|
import { socialClientInfo, socialPostUrl, type PreferredSocialClient } from '../social.js';
|
|
5
6
|
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
errorMessage = null;
|
|
35
36
|
try {
|
|
36
37
|
const res = await fetch(
|
|
37
|
-
|
|
38
|
+
`${appviewUrl()}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=6`,
|
|
38
39
|
{ signal }
|
|
39
40
|
);
|
|
40
41
|
if (!res.ok) throw new Error(`Failed to load thread (${res.status})`);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { getCDNImageBlobUrl } from './atproto-helpers.js';
|
|
3
3
|
import { getEventThumbnail } from './event-media.js';
|
|
4
4
|
import { Avatar, Button, ToggleGroup, ToggleGroupItem } from '../index.js';
|
|
5
|
-
import { onMount } from 'svelte';
|
|
5
|
+
import { onMount, untrack } from 'svelte';
|
|
6
6
|
import { InlineEditor, type Editor } from '../index.js';
|
|
7
7
|
import DateTimePicker from './DateTimePicker.svelte';
|
|
8
8
|
import TimezonePicker from './TimezonePicker.svelte';
|
|
@@ -185,22 +185,17 @@
|
|
|
185
185
|
return s.slice(0, 16);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
//
|
|
188
|
+
// Only a changed start should move the end. Watching endsAt as well would
|
|
189
|
+
// overwrite intermediate keystrokes (e.g. entering 6 before switching to PM).
|
|
189
190
|
$effect(() => {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
// Auto-adjust end date if start moves past it
|
|
196
|
-
$effect(() => {
|
|
197
|
-
if (startsAt && endsAt) {
|
|
198
|
-
const s = parseDateTime(startsAt);
|
|
199
|
-
const e = parseDateTime(endsAt);
|
|
200
|
-
if (s.compare(e) >= 0) {
|
|
191
|
+
const start = startsAt;
|
|
192
|
+
untrack(() => {
|
|
193
|
+
if (!start) return;
|
|
194
|
+
const s = parseDateTime(start);
|
|
195
|
+
if (!endsAt || s.compare(parseDateTime(endsAt)) >= 0) {
|
|
201
196
|
endsAt = cdtToDatetimeLocal(s.add({ hours: 1 }).toString());
|
|
202
197
|
}
|
|
203
|
-
}
|
|
198
|
+
});
|
|
204
199
|
});
|
|
205
200
|
|
|
206
201
|
async function handleSubmit() {
|
|
@@ -209,6 +204,8 @@
|
|
|
209
204
|
if (!name.trim()) return void (error = 'Name is required.');
|
|
210
205
|
if (!startsAt) return void (error = 'Start date is required.');
|
|
211
206
|
if (!endsAt) return void (error = 'End date is required.');
|
|
207
|
+
if (parseDateTime(endsAt).compare(parseDateTime(startsAt)) <= 0)
|
|
208
|
+
return void (error = 'End time must be after the start time.');
|
|
212
209
|
if (!viewer.isLoggedIn || !viewer.did) return void (error = 'You must be logged in.');
|
|
213
210
|
|
|
214
211
|
submitting = true;
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import AddToCalendarButton from './event-view/AddToCalendarButton.svelte';
|
|
33
33
|
import ExternalRsvpNotice from './event-view/ExternalRsvpNotice.svelte';
|
|
34
34
|
import RichText from '../components/RichText.svelte';
|
|
35
|
+
import { richTextToPlainText } from '../richtext/document.js';
|
|
35
36
|
import { getLocalProfileUrl } from './profile-url.js';
|
|
36
37
|
import { getLocations, resolveGeoLocation, type GeoLocation } from './event-view/locations.js';
|
|
37
38
|
|
|
@@ -109,7 +110,7 @@
|
|
|
109
110
|
let canSetEventComments = $state(false);
|
|
110
111
|
let isHost = $derived(!!viewer.did && viewer.did === did);
|
|
111
112
|
let hasComments = $derived(!!eventData.bskyPostRef?.showComments && !!eventData.bskyPostRef?.uri);
|
|
112
|
-
let hasDescription = $derived(!!eventData.description);
|
|
113
|
+
let hasDescription = $derived(!!eventData.description?.trim());
|
|
113
114
|
let aboutCommentsTab = $state<string>('about');
|
|
114
115
|
let photoLinks = $derived(eventData.uris?.filter((link) => parseGrainLink(link.uri)) ?? []);
|
|
115
116
|
let contentTabs = $derived([
|
|
@@ -203,7 +204,9 @@
|
|
|
203
204
|
let eventActor = $derived(hostProfile?.handle || did);
|
|
204
205
|
let conferenceRooms = $derived(getConferenceRooms(eventData));
|
|
205
206
|
let scheduleEvents = $derived(
|
|
206
|
-
isConference
|
|
207
|
+
isConference
|
|
208
|
+
? toScheduleEvents(data.conferenceTalks ?? [], eventUri, data.conferenceTalkDescriptions)
|
|
209
|
+
: []
|
|
207
210
|
);
|
|
208
211
|
|
|
209
212
|
// Imported events can opt out of atmo's own RSVPs (rsvpMode === 'external_only').
|
|
@@ -390,11 +393,13 @@
|
|
|
390
393
|
{/if}
|
|
391
394
|
{#if activeTab === 'about'}
|
|
392
395
|
<RichText
|
|
396
|
+
content={data.description}
|
|
393
397
|
text={eventData.description ?? ''}
|
|
394
398
|
facets={eventData.facets}
|
|
395
399
|
mentionUrl={getLocalProfileUrl}
|
|
396
400
|
socialClient={viewer.preferredSocialClient}
|
|
397
401
|
detectLinks
|
|
402
|
+
headingOffset={1}
|
|
398
403
|
class="text-base-700 dark:text-base-300"
|
|
399
404
|
/>
|
|
400
405
|
{:else if activeTab === 'comments' && eventData.bskyPostRef?.uri}
|
|
@@ -482,7 +487,9 @@
|
|
|
482
487
|
{canSetEventComments}
|
|
483
488
|
eventDid={did}
|
|
484
489
|
eventRkey={rkey}
|
|
485
|
-
eventDescription={
|
|
490
|
+
eventDescription={data.description
|
|
491
|
+
? richTextToPlainText(data.description)
|
|
492
|
+
: eventData.description}
|
|
486
493
|
{adapter}
|
|
487
494
|
{viewer}
|
|
488
495
|
/>
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
graphemeLength,
|
|
13
13
|
type Editor
|
|
14
14
|
} from '../index.js';
|
|
15
|
-
import {
|
|
15
|
+
import { detectTokens } from '../richtext/detect.js';
|
|
16
16
|
import { tokensToFacets } from './editor/save.js';
|
|
17
|
+
import { appviewUrl } from './atproto-helpers.js';
|
|
17
18
|
import type { EditorAdapter, EditorViewer } from './editor/adapter.js';
|
|
18
19
|
|
|
19
20
|
let {
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
onPosted?: (ref: { uri: string; cid: string; showComments: boolean }) => void;
|
|
45
46
|
} = $props();
|
|
46
47
|
|
|
47
|
-
const searchActors = createActorSearch();
|
|
48
|
+
const searchActors = createActorSearch({ host: appviewUrl() });
|
|
48
49
|
let text = $state('');
|
|
49
50
|
let editor = $state<Editor | null>(null);
|
|
50
51
|
let prefilledForOpen = $state(false);
|
|
@@ -104,7 +105,7 @@
|
|
|
104
105
|
return;
|
|
105
106
|
}
|
|
106
107
|
// Also recognize handles typed or pasted without selecting a suggestion.
|
|
107
|
-
const detected = await tokensToFacets(
|
|
108
|
+
const detected = await tokensToFacets(detectTokens(text), (handle) =>
|
|
108
109
|
adapter.resolveHandle(handle)
|
|
109
110
|
);
|
|
110
111
|
for (const facet of detected) {
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
let { value = $bindable('') }: { value: string } = $props();
|
|
5
5
|
let isOpen = $state(false);
|
|
6
6
|
let search = $state('');
|
|
7
|
-
|
|
7
|
+
// WebKit already includes UTC; duplicate keys crash the list when opened.
|
|
8
|
+
const allTimezones = [...new Set(['UTC', ...Intl.supportedValuesOf('timeZone')])];
|
|
8
9
|
// Memoized formatting results are not reactive state.
|
|
9
10
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
10
11
|
const offsets = new Map<string, string>();
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* Pure browser-safe atproto helpers used by the UI components. Does not depend
|
|
3
3
|
* on any session/client state — those flow through the EditorAdapter.
|
|
4
4
|
*/
|
|
5
|
+
/** Configure shared event components before rendering on the server and in the browser. */
|
|
6
|
+
export declare function configureAtproto(options: {
|
|
7
|
+
appview?: string;
|
|
8
|
+
cdn?: string;
|
|
9
|
+
}): void;
|
|
10
|
+
export declare function appviewUrl(): string;
|
|
5
11
|
export declare function getCDNImageBlobUrl({ did, blob, format }: {
|
|
6
12
|
did: string;
|
|
7
13
|
blob: {
|
|
@@ -2,8 +2,22 @@
|
|
|
2
2
|
* Pure browser-safe atproto helpers used by the UI components. Does not depend
|
|
3
3
|
* on any session/client state — those flow through the EditorAdapter.
|
|
4
4
|
*/
|
|
5
|
+
const services = {
|
|
6
|
+
appview: 'https://public.api.bsky.app',
|
|
7
|
+
cdn: 'https://cdn.bsky.app'
|
|
8
|
+
};
|
|
9
|
+
/** Configure shared event components before rendering on the server and in the browser. */
|
|
10
|
+
export function configureAtproto(options) {
|
|
11
|
+
if (options.appview)
|
|
12
|
+
services.appview = options.appview.replace(/\/+$/, '');
|
|
13
|
+
if (options.cdn)
|
|
14
|
+
services.cdn = options.cdn.replace(/\/+$/, '');
|
|
15
|
+
}
|
|
16
|
+
export function appviewUrl() {
|
|
17
|
+
return services.appview;
|
|
18
|
+
}
|
|
5
19
|
export function getCDNImageBlobUrl({ did, blob, format = 'webp' }) {
|
|
6
|
-
return
|
|
20
|
+
return `${services.cdn}/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@${format}`;
|
|
7
21
|
}
|
|
8
22
|
export function compressImage(file, maxSize = 900 * 1024, maxDimension = 2048) {
|
|
9
23
|
return new Promise((resolve, reject) => {
|
|
@@ -60,6 +60,7 @@ export declare function parseEventUri(uri: string): {
|
|
|
60
60
|
/**
|
|
61
61
|
* Normalize the talk records belonging to `conferenceUri` into ScheduleEvents,
|
|
62
62
|
* sorted by start time. Records whose `parentEvent.uri` doesn't match are
|
|
63
|
-
* dropped, so it's safe to pass an over-broad list.
|
|
63
|
+
* dropped, so it's safe to pass an over-broad list. `descriptions` replaces
|
|
64
|
+
* record descriptions, keyed by URI, e.g. with plain text from Markdown.
|
|
64
65
|
*/
|
|
65
|
-
export declare function toScheduleEvents(events: FlatEventRecord[], conferenceUri: string): ScheduleEvent[];
|
|
66
|
+
export declare function toScheduleEvents(events: FlatEventRecord[], conferenceUri: string, descriptions?: Record<string, string>): ScheduleEvent[];
|
|
@@ -35,9 +35,10 @@ export function parseEventUri(uri) {
|
|
|
35
35
|
/**
|
|
36
36
|
* Normalize the talk records belonging to `conferenceUri` into ScheduleEvents,
|
|
37
37
|
* sorted by start time. Records whose `parentEvent.uri` doesn't match are
|
|
38
|
-
* dropped, so it's safe to pass an over-broad list.
|
|
38
|
+
* dropped, so it's safe to pass an over-broad list. `descriptions` replaces
|
|
39
|
+
* record descriptions, keyed by URI, e.g. with plain text from Markdown.
|
|
39
40
|
*/
|
|
40
|
-
export function toScheduleEvents(events, conferenceUri) {
|
|
41
|
+
export function toScheduleEvents(events, conferenceUri, descriptions) {
|
|
41
42
|
return events
|
|
42
43
|
.filter((e) => getParentEventRef(e)?.uri === conferenceUri)
|
|
43
44
|
.map((e) => {
|
|
@@ -50,7 +51,7 @@ export function toScheduleEvents(events, conferenceUri) {
|
|
|
50
51
|
start: e.startsAt,
|
|
51
52
|
end: e.endsAt,
|
|
52
53
|
room: ad.room,
|
|
53
|
-
description: e.description,
|
|
54
|
+
description: descriptions?.[e.uri] ?? e.description,
|
|
54
55
|
did: e.did,
|
|
55
56
|
uri: e.uri,
|
|
56
57
|
cid: e.cid,
|
|
@@ -98,6 +98,10 @@
|
|
|
98
98
|
// to preserve the wall time across DST transitions.
|
|
99
99
|
const baseStart = parseDateTime(startsAt);
|
|
100
100
|
const baseEnd = endsAt ? parseDateTime(endsAt) : null;
|
|
101
|
+
if (baseEnd && baseEnd.compare(baseStart) <= 0) {
|
|
102
|
+
errorMsg = 'End time must be after the start time.';
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
101
105
|
const durationMs = baseEnd
|
|
102
106
|
? baseEnd.toDate(timezone).getTime() - baseStart.toDate(timezone).getTime()
|
|
103
107
|
: 0;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Token } from '@atcute/bluesky-richtext-parser';
|
|
2
2
|
import type { Facet } from '../../index.js';
|
|
3
3
|
import type { FlatEventRecord } from '../contrail.js';
|
|
4
4
|
import type { EventTheme } from '../theme.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { compressImage } from '../atproto-helpers.js';
|
|
2
|
-
import {
|
|
2
|
+
import { detectTokens } from '../../richtext/detect.js';
|
|
3
3
|
import { datetimeLocalToISO } from '../date-format.js';
|
|
4
4
|
import { designs, resolveAccentColor } from '../thumbnails/designs.js';
|
|
5
5
|
import { normalizeEventLocations } from './location.js';
|
|
@@ -123,7 +123,7 @@ export async function buildEventRecord(args) {
|
|
|
123
123
|
const trimmedDescription = description.trim();
|
|
124
124
|
if (trimmedDescription) {
|
|
125
125
|
record.description = trimmedDescription;
|
|
126
|
-
const tokens =
|
|
126
|
+
const tokens = detectTokens(trimmedDescription);
|
|
127
127
|
const facets = await tokensToFacets(tokens, resolveHandle);
|
|
128
128
|
if (facets.length > 0)
|
|
129
129
|
record.facets = facets;
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { AttendeeInfo, FlatEventRecord, HostProfile } from './contrail.js';
|
|
2
|
+
import type { RichTextDocument } from '../richtext/document.js';
|
|
2
3
|
/** App-loaded data for EventView; fetching and authorization stay in the host. */
|
|
3
4
|
export interface EventViewData {
|
|
4
5
|
eventData: FlatEventRecord;
|
|
6
|
+
/**
|
|
7
|
+
* `eventData.description` parsed on the server, usually with
|
|
8
|
+
* `parseRichText(description, { facets, markdown: true, detectLinks: true })`.
|
|
9
|
+
* Without it, the description renders as plain text.
|
|
10
|
+
*/
|
|
11
|
+
description?: RichTextDocument | null;
|
|
5
12
|
actorDid: string;
|
|
6
13
|
rkey: string;
|
|
7
14
|
hostProfile?: HostProfile | null;
|
|
@@ -27,6 +34,8 @@ export interface EventViewData {
|
|
|
27
34
|
playlistUrl: string;
|
|
28
35
|
} | null;
|
|
29
36
|
conferenceTalks?: FlatEventRecord[];
|
|
37
|
+
/** Plain-text talk descriptions keyed by talk URI, e.g. from `richTextToPlainText`. */
|
|
38
|
+
conferenceTalkDescriptions?: Record<string, string>;
|
|
30
39
|
conferenceTimezone?: string;
|
|
31
40
|
conferenceRsvpStatuses?: Record<string, string>;
|
|
32
41
|
conferenceRsvpRkeys?: Record<string, string>;
|
package/dist/events-data.d.ts
CHANGED
|
@@ -10,6 +10,6 @@ export * from './events/linked-records.js';
|
|
|
10
10
|
export * from './events/date-format.js';
|
|
11
11
|
export * from './events/theme.js';
|
|
12
12
|
export { getEventThumbnail, getEventExternalImage, type ExternalEventImage } from './events/event-media.js';
|
|
13
|
-
export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
|
|
13
|
+
export { getCDNImageBlobUrl, compressImage, configureAtproto } from './events/atproto-helpers.js';
|
|
14
14
|
export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
|
|
15
15
|
export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
|
package/dist/events-data.js
CHANGED
|
@@ -4,6 +4,6 @@ export * from './events/linked-records.js';
|
|
|
4
4
|
export * from './events/date-format.js';
|
|
5
5
|
export * from './events/theme.js';
|
|
6
6
|
export { getEventThumbnail, getEventExternalImage } from './events/event-media.js';
|
|
7
|
-
export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
|
|
7
|
+
export { getCDNImageBlobUrl, compressImage, configureAtproto } from './events/atproto-helpers.js';
|
|
8
8
|
export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
|
|
9
9
|
export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -78,6 +78,7 @@ export { default as NestedComments } from './components/NestedComments.svelte';
|
|
|
78
78
|
export { default as PostContent } from './components/PostContent.svelte';
|
|
79
79
|
export { default as RichText } from './components/RichText.svelte';
|
|
80
80
|
export type { RichTextProps } from './components/RichText.svelte';
|
|
81
|
+
export type { RichTextDocument, RichTextLinkOptions } from './richtext/document.js';
|
|
81
82
|
export { default as ExternalEmbed } from './components/ExternalEmbed.svelte';
|
|
82
83
|
export { defaultExternalEmbedProviders, youtubeEmbedProvider, klipyEmbedProvider, resolveExternalEmbed } from './social/external-embeds.js';
|
|
83
84
|
export type { ExternalPostEmbed, ExternalEmbedProvider, ExternalEmbedPlayer } from './social/external-embeds.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AutolinkToken, MentionToken, TextToken, TopicToken } from '@atcute/bluesky-richtext-parser';
|
|
2
|
+
export type DetectedToken = TextToken | MentionToken | TopicToken | AutolinkToken;
|
|
3
|
+
/**
|
|
4
|
+
* Plain-text URLs, mentions and hashtags, in linear time. Other text, including Markdown-like
|
|
5
|
+
* syntax, is returned as text tokens; joining every `raw` reproduces the input.
|
|
6
|
+
*/
|
|
7
|
+
export declare function detectTokens(text: string): DetectedToken[];
|