+18
-8
actions/publishToPublication.ts
+18
-8
actions/publishToPublication.ts
···
2
2
3
3
import * as Y from "yjs";
4
4
import * as base64 from "base64-js";
5
-
import {
6
-
restoreOAuthSession,
7
-
OAuthSessionError,
8
-
} from "src/atproto-oauth";
5
+
import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
9
6
import { getIdentityData } from "actions/getIdentityData";
10
7
import {
11
8
AtpBaseClient,
···
50
47
ColorToRGBA,
51
48
} from "components/ThemeManager/colorToLexicons";
52
49
import { parseColor } from "@react-stately/color";
53
-
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
50
+
import {
51
+
Notification,
52
+
pingIdentityToUpdateNotification,
53
+
} from "src/notifications";
54
54
import { v7 } from "uuid";
55
55
56
56
type PublishResult =
···
253
253
254
254
// Create notifications for mentions (only on first publish)
255
255
if (!existingDocUri) {
256
-
await createMentionNotifications(result.uri, record, credentialSession.did!);
256
+
await createMentionNotifications(
257
+
result.uri,
258
+
record,
259
+
credentialSession.did!,
260
+
);
257
261
}
258
262
259
263
return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) };
···
463
467
464
468
if (b.type == "text") {
465
469
let [stringValue, facets] = getBlockContent(b.value);
470
+
let [textSize] = scan.eav(b.value, "block/text-size");
466
471
let block: $Typed<PubLeafletBlocksText.Main> = {
467
472
$type: ids.PubLeafletBlocksText,
468
473
plaintext: stringValue,
469
474
facets,
475
+
...(textSize && { textSize: textSize.data.value }),
470
476
};
471
477
return block;
472
478
}
···
865
871
.single();
866
872
867
873
if (publication && publication.identity_did !== authorDid) {
868
-
mentionedPublications.set(publication.identity_did, feature.atURI);
874
+
mentionedPublications.set(
875
+
publication.identity_did,
876
+
feature.atURI,
877
+
);
869
878
}
870
879
} else if (uri.collection === "pub.leaflet.document") {
871
880
// Get the document owner's DID
···
876
885
.single();
877
886
878
887
if (document) {
879
-
const docRecord = document.data as PubLeafletDocument.Record;
888
+
const docRecord =
889
+
document.data as PubLeafletDocument.Record;
880
890
if (docRecord.author !== authorDid) {
881
891
mentionedDocuments.set(docRecord.author, feature.atURI);
882
892
}
+1
-1
app/(home-pages)/notifications/CommentNotication.tsx
+1
-1
app/(home-pages)/notifications/CommentNotication.tsx
+1
-1
app/(home-pages)/notifications/Notification.tsx
+1
-1
app/(home-pages)/notifications/Notification.tsx
···
1
1
"use client";
2
2
import { Avatar } from "components/Avatar";
3
-
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock";
3
+
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock";
4
4
import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api";
5
5
import { timeAgo } from "src/utils/timeAgo";
6
6
import { useReplicache, useEntity } from "src/replicache";
+1
-1
app/(home-pages)/notifications/ReplyNotification.tsx
+1
-1
app/(home-pages)/notifications/ReplyNotification.tsx
···
1
1
import { Avatar } from "components/Avatar";
2
-
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock";
2
+
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock";
3
3
import { ReplyTiny } from "components/Icons/ReplyTiny";
4
4
import {
5
5
CommentInNotification,
+1
-1
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
+1
-1
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
···
6
6
import { PubLeafletComment, PubLeafletDocument } from "lexicons/api";
7
7
import { ReplyTiny } from "components/Icons/ReplyTiny";
8
8
import { Avatar } from "components/Avatar";
9
-
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock";
9
+
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock";
10
10
import { blobRefToSrc } from "src/utils/blobRefToSrc";
11
11
import {
12
12
getProfileComments,
+24
app/[leaflet_id]/actions/HelpButton.tsx
+24
app/[leaflet_id]/actions/HelpButton.tsx
···
58
58
keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]}
59
59
/>
60
60
<KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} />
61
+
<KeyboardShortcut
62
+
name="Make Title"
63
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "1"]}
64
+
/>
65
+
<KeyboardShortcut
66
+
name="Make Heading"
67
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "2"]}
68
+
/>
69
+
<KeyboardShortcut
70
+
name="Make Subheading"
71
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "3"]}
72
+
/>
73
+
<KeyboardShortcut
74
+
name="Regular Text"
75
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "0"]}
76
+
/>
77
+
<KeyboardShortcut
78
+
name="Large Text"
79
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "+"]}
80
+
/>
81
+
<KeyboardShortcut
82
+
name="Small Text"
83
+
keys={[metaKey(), isMac() ? "Opt" : "Alt", "-"]}
84
+
/>
61
85
62
86
<Label>Block Shortcuts</Label>
63
87
{/* shift + up/down arrows (or click + drag): select multiple blocks */}
+2
-2
app/[leaflet_id]/actions/PublishButton.tsx
+2
-2
app/[leaflet_id]/actions/PublishButton.tsx
+5
-1
app/[leaflet_id]/publish/PublishPost.tsx
+5
-1
app/[leaflet_id]/publish/PublishPost.tsx
···
199
199
className="place-self-end h-[30px]"
200
200
disabled={charCount > 300}
201
201
>
202
-
{isLoading ? <DotLoader /> : "Publish this Post!"}
202
+
{isLoading ? (
203
+
<DotLoader className="h-[23px]" />
204
+
) : (
205
+
"Publish this Post!"
206
+
)}
203
207
</ButtonPrimary>
204
208
</div>
205
209
{oauthError && (
-26
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
-26
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
···
1
-
import { ProfilePopover } from "components/ProfilePopover";
2
-
import { TextBlockCore, TextBlockCoreProps, RichText } from "./TextBlockCore";
3
-
import { ReactNode } from "react";
4
-
5
-
// Re-export RichText for backwards compatibility
6
-
export { RichText };
7
-
8
-
function DidMentionWithPopover(props: { did: string; children: ReactNode }) {
9
-
return (
10
-
<ProfilePopover
11
-
didOrHandle={props.did}
12
-
trigger={props.children}
13
-
/>
14
-
);
15
-
}
16
-
17
-
export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
18
-
return (
19
-
<TextBlockCore
20
-
{...props}
21
-
renderers={{
22
-
DidMention: DidMentionWithPopover,
23
-
}}
24
-
/>
25
-
);
26
-
}
+25
app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx
+25
app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx
···
1
+
import { ProfilePopover } from "components/ProfilePopover";
2
+
import {
3
+
TextBlockCore,
4
+
TextBlockCoreProps,
5
+
RichText,
6
+
} from "../Blocks/TextBlockCore";
7
+
import { ReactNode } from "react";
8
+
9
+
// Re-export RichText for backwards compatibility
10
+
export { RichText };
11
+
12
+
function DidMentionWithPopover(props: { did: string; children: ReactNode }) {
13
+
return <ProfilePopover didOrHandle={props.did} trigger={props.children} />;
14
+
}
15
+
16
+
export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
17
+
return (
18
+
<TextBlockCore
19
+
{...props}
20
+
renderers={{
21
+
DidMention: DidMentionWithPopover,
22
+
}}
23
+
/>
24
+
);
25
+
}
+28
app/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx
+28
app/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx
···
1
+
"use client";
2
+
3
+
import { PubLeafletBlocksCode } from "lexicons/api";
4
+
import { useLayoutEffect, useState } from "react";
5
+
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
6
+
7
+
export function PubCodeBlock({
8
+
block,
9
+
prerenderedCode,
10
+
}: {
11
+
block: PubLeafletBlocksCode.Main;
12
+
prerenderedCode?: string;
13
+
}) {
14
+
const [html, setHTML] = useState<string | null>(prerenderedCode || null);
15
+
16
+
useLayoutEffect(() => {
17
+
const lang = bundledLanguagesInfo.find((l) => l.id === block.language)?.id || "plaintext";
18
+
const theme = bundledThemesInfo.find((t) => t.id === block.syntaxHighlightingTheme)?.id || "github-light";
19
+
20
+
codeToHtml(block.plaintext, { lang, theme }).then(setHTML);
21
+
}, [block]);
22
+
return (
23
+
<div
24
+
className="w-full min-h-[42px] my-2 rounded-md border-border-light outline-border-light selected-outline"
25
+
dangerouslySetInnerHTML={{ __html: html || "" }}
26
+
/>
27
+
);
28
+
}
+174
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
+174
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
···
1
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
2
+
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
+
import { Separator } from "components/Layout";
4
+
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
5
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
6
+
import { CommentTiny } from "components/Icons/CommentTiny";
7
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
+
import { ThreadLink, QuotesLink } from "../PostLinks";
9
+
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
10
+
import {
11
+
BlueskyEmbed,
12
+
PostNotAvailable,
13
+
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
14
+
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
15
+
import { openPage } from "../PostPages";
16
+
17
+
export const PubBlueskyPostBlock = (props: {
18
+
post: PostView;
19
+
className: string;
20
+
pageId?: string;
21
+
}) => {
22
+
let post = props.post;
23
+
24
+
const handleOpenThread = () => {
25
+
openPage(props.pageId ? { type: "doc", id: props.pageId } : undefined, {
26
+
type: "thread",
27
+
uri: post.uri,
28
+
});
29
+
};
30
+
31
+
switch (true) {
32
+
case AppBskyFeedDefs.isBlockedPost(post) ||
33
+
AppBskyFeedDefs.isBlockedAuthor(post) ||
34
+
AppBskyFeedDefs.isNotFoundPost(post):
35
+
return (
36
+
<div className={`w-full`}>
37
+
<PostNotAvailable />
38
+
</div>
39
+
);
40
+
41
+
case AppBskyFeedDefs.validatePostView(post).success:
42
+
let record = post.record as AppBskyFeedDefs.PostView["record"];
43
+
44
+
// silliness to get the text and timestamp from the record with proper types
45
+
let timestamp: string | undefined = undefined;
46
+
if (AppBskyFeedPost.isRecord(record)) {
47
+
timestamp = (record as AppBskyFeedPost.Record).createdAt;
48
+
}
49
+
50
+
//getting the url to the post
51
+
let postId = post.uri.split("/")[4];
52
+
let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
53
+
54
+
const parent = props.pageId
55
+
? { type: "doc" as const, id: props.pageId }
56
+
: undefined;
57
+
58
+
return (
59
+
<div
60
+
onClick={handleOpenThread}
61
+
className={`
62
+
${props.className}
63
+
block-border
64
+
mb-2
65
+
flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
66
+
cursor-pointer hover:border-accent-contrast
67
+
`}
68
+
>
69
+
{post.author && record && (
70
+
<>
71
+
<div className="bskyAuthor w-full flex items-center gap-2">
72
+
{post.author.avatar && (
73
+
<img
74
+
src={post.author?.avatar}
75
+
alt={`${post.author?.displayName}'s avatar`}
76
+
className="shink-0 w-8 h-8 rounded-full border border-border-light"
77
+
/>
78
+
)}
79
+
<div className="grow flex flex-col gap-0.5 leading-tight">
80
+
<div className=" font-bold text-secondary">
81
+
{post.author?.displayName}
82
+
</div>
83
+
<a
84
+
className="text-xs text-tertiary hover:underline"
85
+
target="_blank"
86
+
href={`https://bsky.app/profile/${post.author?.handle}`}
87
+
onClick={(e) => e.stopPropagation()}
88
+
>
89
+
@{post.author?.handle}
90
+
</a>
91
+
</div>
92
+
</div>
93
+
94
+
<div className="flex flex-col gap-2 ">
95
+
<div>
96
+
<pre className="whitespace-pre-wrap">
97
+
{BlueskyRichText({
98
+
record: record as AppBskyFeedPost.Record | null,
99
+
})}
100
+
</pre>
101
+
</div>
102
+
{post.embed && (
103
+
<div onClick={(e) => e.stopPropagation()}>
104
+
<BlueskyEmbed embed={post.embed} postUrl={url} />
105
+
</div>
106
+
)}
107
+
</div>
108
+
</>
109
+
)}
110
+
<div className="w-full flex gap-2 items-center justify-between">
111
+
<ClientDate date={timestamp} />
112
+
<div className="flex gap-2 items-center">
113
+
{post.replyCount != null && post.replyCount > 0 && (
114
+
<>
115
+
<ThreadLink
116
+
threadUri={post.uri}
117
+
parent={parent}
118
+
className="flex items-center gap-1 hover:text-accent-contrast"
119
+
onClick={(e) => e.stopPropagation()}
120
+
>
121
+
{post.replyCount}
122
+
<CommentTiny />
123
+
</ThreadLink>
124
+
<Separator classname="h-4" />
125
+
</>
126
+
)}
127
+
{post.quoteCount != null && post.quoteCount > 0 && (
128
+
<>
129
+
<QuotesLink
130
+
postUri={post.uri}
131
+
parent={parent}
132
+
className="flex items-center gap-1 hover:text-accent-contrast"
133
+
onClick={(e) => e.stopPropagation()}
134
+
>
135
+
{post.quoteCount}
136
+
<QuoteTiny />
137
+
</QuotesLink>
138
+
<Separator classname="h-4" />
139
+
</>
140
+
)}
141
+
142
+
<a
143
+
className=""
144
+
target="_blank"
145
+
href={url}
146
+
onClick={(e) => e.stopPropagation()}
147
+
>
148
+
<BlueskyTiny />
149
+
</a>
150
+
</div>
151
+
</div>
152
+
</div>
153
+
);
154
+
}
155
+
};
156
+
157
+
const ClientDate = (props: { date?: string }) => {
158
+
let pageLoaded = useHasPageLoaded();
159
+
const formattedDate = useLocalizedDate(
160
+
props.date || new Date().toISOString(),
161
+
{
162
+
month: "short",
163
+
day: "numeric",
164
+
year: "numeric",
165
+
hour: "numeric",
166
+
minute: "numeric",
167
+
hour12: true,
168
+
},
169
+
);
170
+
171
+
if (!pageLoaded) return null;
172
+
173
+
return <div className="text-xs text-tertiary">{formattedDate}</div>;
174
+
};
+344
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
+344
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
···
1
+
"use client";
2
+
3
+
import { useEntity, useReplicache } from "src/replicache";
4
+
import { useUIState } from "src/useUIState";
5
+
import { CSSProperties, useContext, useRef } from "react";
6
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
+
import { PostContent, Block } from "../PostContent";
8
+
import {
9
+
PubLeafletBlocksHeader,
10
+
PubLeafletBlocksText,
11
+
PubLeafletComment,
12
+
PubLeafletPagesLinearDocument,
13
+
PubLeafletPagesCanvas,
14
+
PubLeafletPublication,
15
+
} from "lexicons/api";
16
+
import { AppBskyFeedDefs } from "@atproto/api";
17
+
import { TextBlock } from "./TextBlock";
18
+
import { PostPageContext } from "../PostPageContext";
19
+
import { openPage, useOpenPages } from "../PostPages";
20
+
import {
21
+
openInteractionDrawer,
22
+
setInteractionState,
23
+
useInteractionState,
24
+
} from "../Interactions/Interactions";
25
+
import { CommentTiny } from "components/Icons/CommentTiny";
26
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
27
+
import { CanvasBackgroundPattern } from "components/Canvas";
28
+
29
+
export function PublishedPageLinkBlock(props: {
30
+
blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[];
31
+
parentPageId: string | undefined;
32
+
pageId: string;
33
+
did: string;
34
+
preview?: boolean;
35
+
className?: string;
36
+
prerenderedCodeBlocks?: Map<string, string>;
37
+
bskyPostData: AppBskyFeedDefs.PostView[];
38
+
isCanvas?: boolean;
39
+
pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
40
+
}) {
41
+
//switch to use actually state
42
+
let openPages = useOpenPages();
43
+
let isOpen = openPages.some((p) => p.type === "doc" && p.id === props.pageId);
44
+
return (
45
+
<div
46
+
className={`w-full cursor-pointer
47
+
pageLinkBlockWrapper relative group/pageLinkBlock
48
+
bg-bg-page shadow-sm
49
+
flex overflow-clip
50
+
block-border
51
+
${isOpen && "!border-tertiary"}
52
+
${props.className}
53
+
`}
54
+
onClick={(e) => {
55
+
if (e.isDefaultPrevented()) return;
56
+
if (e.shiftKey) return;
57
+
e.preventDefault();
58
+
e.stopPropagation();
59
+
60
+
openPage(
61
+
props.parentPageId
62
+
? { type: "doc", id: props.parentPageId }
63
+
: undefined,
64
+
{ type: "doc", id: props.pageId },
65
+
);
66
+
}}
67
+
>
68
+
{props.isCanvas ? (
69
+
<CanvasLinkBlock
70
+
blocks={props.blocks as PubLeafletPagesCanvas.Block[]}
71
+
did={props.did}
72
+
pageId={props.pageId}
73
+
bskyPostData={props.bskyPostData}
74
+
pages={props.pages || []}
75
+
/>
76
+
) : (
77
+
<DocLinkBlock
78
+
{...props}
79
+
blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]}
80
+
/>
81
+
)}
82
+
</div>
83
+
);
84
+
}
85
+
export function DocLinkBlock(props: {
86
+
blocks: PubLeafletPagesLinearDocument.Block[];
87
+
pageId: string;
88
+
parentPageId?: string;
89
+
did: string;
90
+
preview?: boolean;
91
+
className?: string;
92
+
prerenderedCodeBlocks?: Map<string, string>;
93
+
bskyPostData: AppBskyFeedDefs.PostView[];
94
+
}) {
95
+
let [title, description] = props.blocks
96
+
.map((b) => b.block)
97
+
.filter(
98
+
(b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b),
99
+
);
100
+
101
+
return (
102
+
<div
103
+
style={{ "--list-marker-width": "20px" } as CSSProperties}
104
+
className={`
105
+
w-full h-[104px]
106
+
`}
107
+
>
108
+
<>
109
+
<div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full">
110
+
<div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col ">
111
+
<div className="grow">
112
+
{title && (
113
+
<div
114
+
className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`}
115
+
>
116
+
<TextBlock
117
+
facets={title.facets}
118
+
plaintext={title.plaintext}
119
+
index={[]}
120
+
preview
121
+
/>
122
+
</div>
123
+
)}
124
+
{description && (
125
+
<div
126
+
className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`}
127
+
>
128
+
<TextBlock
129
+
facets={description.facets}
130
+
plaintext={description.plaintext}
131
+
index={[]}
132
+
preview
133
+
/>
134
+
</div>
135
+
)}
136
+
</div>
137
+
138
+
<Interactions
139
+
pageId={props.pageId}
140
+
parentPageId={props.parentPageId}
141
+
/>
142
+
</div>
143
+
{!props.preview && (
144
+
<PagePreview blocks={props.blocks} did={props.did} />
145
+
)}
146
+
</div>
147
+
</>
148
+
</div>
149
+
);
150
+
}
151
+
152
+
export function PagePreview(props: {
153
+
did: string;
154
+
blocks: PubLeafletPagesLinearDocument.Block[];
155
+
}) {
156
+
let previewRef = useRef<HTMLDivElement | null>(null);
157
+
let { rootEntity } = useReplicache();
158
+
let data = useContext(PostPageContext);
159
+
let theme = data?.theme;
160
+
let pageWidth = `var(--page-width-unitless)`;
161
+
let cardBorderHidden = !theme?.showPageBackground;
162
+
return (
163
+
<div
164
+
ref={previewRef}
165
+
className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`}
166
+
>
167
+
<div
168
+
className="absolute top-0 left-0 origin-top-left pointer-events-none "
169
+
style={{
170
+
width: `calc(1px * ${pageWidth})`,
171
+
height: `calc(100vh - 64px)`,
172
+
transform: `scale(calc((120 / ${pageWidth} )))`,
173
+
backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))",
174
+
}}
175
+
>
176
+
{!cardBorderHidden && (
177
+
<div
178
+
className={`pageLinkBlockBackground
179
+
absolute top-0 left-0 right-0 bottom-0
180
+
pointer-events-none
181
+
`}
182
+
/>
183
+
)}
184
+
<PostContent
185
+
pollData={[]}
186
+
pages={[]}
187
+
did={props.did}
188
+
blocks={props.blocks}
189
+
preview
190
+
bskyPostData={[]}
191
+
/>
192
+
</div>
193
+
</div>
194
+
);
195
+
}
196
+
197
+
const Interactions = (props: { pageId: string; parentPageId?: string }) => {
198
+
const data = useContext(PostPageContext);
199
+
const document_uri = data?.uri;
200
+
if (!document_uri)
201
+
throw new Error("document_uri not available in PostPageContext");
202
+
let comments = data.comments_on_documents.filter(
203
+
(c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId,
204
+
).length;
205
+
let quotes = data.document_mentions_in_bsky.filter((q) =>
206
+
q.link.includes(props.pageId),
207
+
).length;
208
+
209
+
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
210
+
211
+
return (
212
+
<div
213
+
className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`}
214
+
>
215
+
{quotes > 0 && (
216
+
<button
217
+
className={`flex gap-1 items-center`}
218
+
onClick={(e) => {
219
+
e.preventDefault();
220
+
e.stopPropagation();
221
+
openPage(
222
+
props.parentPageId
223
+
? { type: "doc", id: props.parentPageId }
224
+
: undefined,
225
+
{ type: "doc", id: props.pageId },
226
+
{ scrollIntoView: false },
227
+
);
228
+
if (!drawerOpen || drawer !== "quotes")
229
+
openInteractionDrawer("quotes", document_uri, props.pageId);
230
+
else setInteractionState(document_uri, { drawerOpen: false });
231
+
}}
232
+
>
233
+
<span className="sr-only">Page quotes</span>
234
+
<QuoteTiny aria-hidden /> {quotes}{" "}
235
+
</button>
236
+
)}
237
+
{comments > 0 && (
238
+
<button
239
+
className={`flex gap-1 items-center`}
240
+
onClick={(e) => {
241
+
e.preventDefault();
242
+
e.stopPropagation();
243
+
openPage(
244
+
props.parentPageId
245
+
? { type: "doc", id: props.parentPageId }
246
+
: undefined,
247
+
{ type: "doc", id: props.pageId },
248
+
{ scrollIntoView: false },
249
+
);
250
+
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
251
+
openInteractionDrawer("comments", document_uri, props.pageId);
252
+
else setInteractionState(document_uri, { drawerOpen: false });
253
+
}}
254
+
>
255
+
<span className="sr-only">Page comments</span>
256
+
<CommentTiny aria-hidden /> {comments}{" "}
257
+
</button>
258
+
)}
259
+
</div>
260
+
);
261
+
};
262
+
263
+
const CanvasLinkBlock = (props: {
264
+
blocks: PubLeafletPagesCanvas.Block[];
265
+
did: string;
266
+
pageId: string;
267
+
bskyPostData: AppBskyFeedDefs.PostView[];
268
+
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
269
+
}) => {
270
+
let pageWidth = `var(--page-width-unitless)`;
271
+
let height =
272
+
props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0;
273
+
274
+
return (
275
+
<div
276
+
style={{ contain: "size layout paint" }}
277
+
className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`}
278
+
>
279
+
<div
280
+
className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`}
281
+
style={{
282
+
width: `calc(1px * ${pageWidth})`,
283
+
height: "calc(1150px * 2)",
284
+
transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`,
285
+
}}
286
+
>
287
+
<div
288
+
style={{
289
+
minHeight: height + 512,
290
+
contain: "size layout paint",
291
+
}}
292
+
className="relative h-full w-[1272px]"
293
+
>
294
+
<div className="w-full h-full pointer-events-none">
295
+
<CanvasBackgroundPattern pattern="grid" />
296
+
</div>
297
+
{props.blocks
298
+
.sort((a, b) => {
299
+
if (a.y === b.y) {
300
+
return a.x - b.x;
301
+
}
302
+
return a.y - b.y;
303
+
})
304
+
.map((canvasBlock, index) => {
305
+
let { x, y, width, rotation } = canvasBlock;
306
+
let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`;
307
+
308
+
// Wrap the block in a LinearDocument.Block structure for compatibility
309
+
let linearBlock: PubLeafletPagesLinearDocument.Block = {
310
+
$type: "pub.leaflet.pages.linearDocument#block",
311
+
block: canvasBlock.block,
312
+
};
313
+
314
+
return (
315
+
<div
316
+
key={index}
317
+
className="absolute rounded-lg flex items-stretch origin-center p-3"
318
+
style={{
319
+
top: 0,
320
+
left: 0,
321
+
width,
322
+
transform,
323
+
}}
324
+
>
325
+
<div className="contents">
326
+
<Block
327
+
pollData={[]}
328
+
pageId={props.pageId}
329
+
pages={props.pages}
330
+
bskyPostData={props.bskyPostData}
331
+
block={linearBlock}
332
+
did={props.did}
333
+
index={[index]}
334
+
preview={true}
335
+
/>
336
+
</div>
337
+
</div>
338
+
);
339
+
})}
340
+
</div>
341
+
</div>
342
+
</div>
343
+
);
344
+
};
+346
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx
+346
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx
···
1
+
"use client";
2
+
3
+
import {
4
+
PubLeafletBlocksPoll,
5
+
PubLeafletPollDefinition,
6
+
PubLeafletPollVote,
7
+
} from "lexicons/api";
8
+
import { useState, useEffect } from "react";
9
+
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
10
+
import { useIdentityData } from "components/IdentityProvider";
11
+
import { AtpAgent } from "@atproto/api";
12
+
import { voteOnPublishedPoll } from "../voteOnPublishedPoll";
13
+
import { PollData } from "../fetchPollData";
14
+
import { Popover } from "components/Popover";
15
+
import LoginForm from "app/login/LoginForm";
16
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
17
+
import { getVoterIdentities, VoterIdentity } from "../getVoterIdentities";
18
+
import { Json } from "supabase/database.types";
19
+
import { InfoSmall } from "components/Icons/InfoSmall";
20
+
21
+
// Helper function to extract the first option from a vote record
22
+
const getVoteOption = (voteRecord: any): string | null => {
23
+
try {
24
+
const record = voteRecord as PubLeafletPollVote.Record;
25
+
return record.option && record.option.length > 0 ? record.option[0] : null;
26
+
} catch {
27
+
return null;
28
+
}
29
+
};
30
+
31
+
export const PublishedPollBlock = (props: {
32
+
block: PubLeafletBlocksPoll.Main;
33
+
pollData: PollData;
34
+
className?: string;
35
+
}) => {
36
+
const { identity } = useIdentityData();
37
+
const [selectedOption, setSelectedOption] = useState<string | null>(null);
38
+
const [isVoting, setIsVoting] = useState(false);
39
+
const [showResults, setShowResults] = useState(false);
40
+
const [optimisticVote, setOptimisticVote] = useState<{
41
+
option: string;
42
+
voter_did: string;
43
+
} | null>(null);
44
+
let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record;
45
+
let [isClient, setIsClient] = useState(false);
46
+
useEffect(() => {
47
+
setIsClient(true);
48
+
}, []);
49
+
50
+
const handleVote = async () => {
51
+
if (!selectedOption || !identity?.atp_did) return;
52
+
53
+
setIsVoting(true);
54
+
55
+
// Optimistically add the vote
56
+
setOptimisticVote({
57
+
option: selectedOption,
58
+
voter_did: identity.atp_did,
59
+
});
60
+
setShowResults(true);
61
+
62
+
try {
63
+
const result = await voteOnPublishedPoll(
64
+
props.block.pollRef.uri,
65
+
props.block.pollRef.cid,
66
+
selectedOption,
67
+
);
68
+
69
+
if (!result.success) {
70
+
console.error("Failed to vote:", result.error);
71
+
// Revert optimistic update on failure
72
+
setOptimisticVote(null);
73
+
setShowResults(false);
74
+
}
75
+
} catch (error) {
76
+
console.error("Failed to vote:", error);
77
+
// Revert optimistic update on failure
78
+
setOptimisticVote(null);
79
+
setShowResults(false);
80
+
} finally {
81
+
setIsVoting(false);
82
+
}
83
+
};
84
+
85
+
const hasVoted =
86
+
!!identity?.atp_did &&
87
+
(!!props.pollData?.atp_poll_votes.find(
88
+
(v) => v.voter_did === identity?.atp_did,
89
+
) ||
90
+
!!optimisticVote);
91
+
let isCreator =
92
+
identity?.atp_did && props.pollData.uri.includes(identity?.atp_did);
93
+
const displayResults = showResults || hasVoted;
94
+
95
+
return (
96
+
<div
97
+
className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`}
98
+
style={{
99
+
backgroundColor:
100
+
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
101
+
}}
102
+
>
103
+
{displayResults ? (
104
+
<>
105
+
<PollResults
106
+
pollData={props.pollData}
107
+
hasVoted={hasVoted}
108
+
setShowResults={setShowResults}
109
+
optimisticVote={optimisticVote}
110
+
/>
111
+
{!hasVoted && (
112
+
<div className="flex justify-start">
113
+
<button
114
+
className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
115
+
onClick={() => setShowResults(false)}
116
+
>
117
+
Back to Voting
118
+
</button>
119
+
</div>
120
+
)}
121
+
</>
122
+
) : (
123
+
<>
124
+
{pollRecord.options.map((option, index) => (
125
+
<PollOptionButton
126
+
key={index}
127
+
option={option}
128
+
optionIndex={index.toString()}
129
+
selected={selectedOption === index.toString()}
130
+
onSelect={() => setSelectedOption(index.toString())}
131
+
disabled={!identity?.atp_did}
132
+
/>
133
+
))}
134
+
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2">
135
+
<div className="text-sm text-tertiary">All votes are public</div>
136
+
<div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center">
137
+
<button
138
+
className="w-fit font-bold text-accent-contrast"
139
+
onClick={() => setShowResults(!showResults)}
140
+
>
141
+
See Results
142
+
</button>
143
+
{identity?.atp_did ? (
144
+
<ButtonPrimary
145
+
className="place-self-end"
146
+
onClick={handleVote}
147
+
disabled={!selectedOption || isVoting}
148
+
>
149
+
{isVoting ? "Voting..." : "Vote!"}
150
+
</ButtonPrimary>
151
+
) : (
152
+
<Popover
153
+
asChild
154
+
trigger={
155
+
<ButtonPrimary className="place-self-center">
156
+
<BlueskyTiny /> Login to vote
157
+
</ButtonPrimary>
158
+
}
159
+
>
160
+
{isClient && (
161
+
<LoginForm
162
+
text="Log in to vote on this poll!"
163
+
noEmail
164
+
redirectRoute={window?.location.href + "?refreshAuth"}
165
+
/>
166
+
)}
167
+
</Popover>
168
+
)}
169
+
</div>
170
+
</div>
171
+
</>
172
+
)}
173
+
</div>
174
+
);
175
+
};
176
+
177
+
const PollOptionButton = (props: {
178
+
option: PubLeafletPollDefinition.Option;
179
+
optionIndex: string;
180
+
selected: boolean;
181
+
onSelect: () => void;
182
+
disabled?: boolean;
183
+
}) => {
184
+
const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary;
185
+
186
+
return (
187
+
<div className="flex gap-2 items-center">
188
+
<ButtonComponent
189
+
className="pollOption grow max-w-full flex"
190
+
onClick={props.onSelect}
191
+
disabled={props.disabled}
192
+
>
193
+
{props.option.text}
194
+
</ButtonComponent>
195
+
</div>
196
+
);
197
+
};
198
+
199
+
const PollResults = (props: {
200
+
pollData: PollData;
201
+
hasVoted: boolean;
202
+
setShowResults: (show: boolean) => void;
203
+
optimisticVote: { option: string; voter_did: string } | null;
204
+
}) => {
205
+
// Merge optimistic vote with actual votes
206
+
const allVotes = props.optimisticVote
207
+
? [
208
+
...props.pollData.atp_poll_votes,
209
+
{
210
+
voter_did: props.optimisticVote.voter_did,
211
+
record: {
212
+
$type: "pub.leaflet.poll.vote",
213
+
option: [props.optimisticVote.option],
214
+
},
215
+
},
216
+
]
217
+
: props.pollData.atp_poll_votes;
218
+
219
+
const totalVotes = allVotes.length || 0;
220
+
let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record;
221
+
let optionsWithCount = pollRecord.options.map((o, index) => ({
222
+
...o,
223
+
votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()),
224
+
}));
225
+
226
+
const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length));
227
+
return (
228
+
<>
229
+
{pollRecord.options.map((option, index) => {
230
+
const voteRecords = allVotes.filter(
231
+
(v) => getVoteOption(v.record) === index.toString(),
232
+
);
233
+
const isWinner = totalVotes > 0 && voteRecords.length === highestVotes;
234
+
235
+
return (
236
+
<PollResult
237
+
key={index}
238
+
option={option}
239
+
votes={voteRecords.length}
240
+
voteRecords={voteRecords}
241
+
totalVotes={totalVotes}
242
+
winner={isWinner}
243
+
/>
244
+
);
245
+
})}
246
+
</>
247
+
);
248
+
};
249
+
250
+
const VoterListPopover = (props: {
251
+
votes: number;
252
+
voteRecords: { voter_did: string; record: Json }[];
253
+
}) => {
254
+
const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]);
255
+
const [isLoading, setIsLoading] = useState(false);
256
+
const [hasFetched, setHasFetched] = useState(false);
257
+
258
+
const handleOpenChange = async () => {
259
+
if (!hasFetched && props.voteRecords.length > 0) {
260
+
setIsLoading(true);
261
+
setHasFetched(true);
262
+
try {
263
+
const dids = props.voteRecords.map((v) => v.voter_did);
264
+
const identities = await getVoterIdentities(dids);
265
+
setVoterIdentities(identities);
266
+
} catch (error) {
267
+
console.error("Failed to fetch voter identities:", error);
268
+
} finally {
269
+
setIsLoading(false);
270
+
}
271
+
}
272
+
};
273
+
274
+
return (
275
+
<Popover
276
+
trigger={
277
+
<button
278
+
className="hover:underline cursor-pointer"
279
+
disabled={props.votes === 0}
280
+
>
281
+
{props.votes}
282
+
</button>
283
+
}
284
+
onOpenChange={handleOpenChange}
285
+
className="w-64 max-h-80"
286
+
>
287
+
{isLoading ? (
288
+
<div className="flex justify-center py-4">
289
+
<div className="text-sm text-secondary">Loading...</div>
290
+
</div>
291
+
) : (
292
+
<div className="flex flex-col gap-1 text-sm py-0.5">
293
+
{voterIdentities.map((voter) => (
294
+
<a
295
+
key={voter.did}
296
+
href={`https://bsky.app/profile/${voter.handle || voter.did}`}
297
+
target="_blank"
298
+
rel="noopener noreferrer"
299
+
className=""
300
+
>
301
+
@{voter.handle || voter.did}
302
+
</a>
303
+
))}
304
+
</div>
305
+
)}
306
+
</Popover>
307
+
);
308
+
};
309
+
310
+
const PollResult = (props: {
311
+
option: PubLeafletPollDefinition.Option;
312
+
votes: number;
313
+
voteRecords: { voter_did: string; record: Json }[];
314
+
totalVotes: number;
315
+
winner: boolean;
316
+
}) => {
317
+
return (
318
+
<div
319
+
className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
320
+
>
321
+
<div
322
+
style={{
323
+
WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`,
324
+
paintOrder: "stroke fill",
325
+
}}
326
+
className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10"
327
+
>
328
+
<div className="grow max-w-full truncate">{props.option.text}</div>
329
+
<VoterListPopover votes={props.votes} voteRecords={props.voteRecords} />
330
+
</div>
331
+
<div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0">
332
+
<div
333
+
className="bg-accent-contrast rounded-[2px] m-0.5"
334
+
style={{
335
+
maskImage: "var(--hatchSVG)",
336
+
maskRepeat: "repeat repeat",
337
+
...(props.votes === 0
338
+
? { width: "4px" }
339
+
: { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
340
+
}}
341
+
/>
342
+
<div />
343
+
</div>
344
+
</div>
345
+
);
346
+
};
+20
app/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx
+20
app/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx
···
1
+
import { PubLeafletBlocksMath } from "lexicons/api";
2
+
import Katex from "katex";
3
+
import "katex/dist/katex.min.css";
4
+
5
+
export const StaticMathBlock = ({
6
+
block,
7
+
}: {
8
+
block: PubLeafletBlocksMath.Main;
9
+
}) => {
10
+
const html = Katex.renderToString(block.tex, {
11
+
displayMode: true,
12
+
output: "html",
13
+
throwOnError: false,
14
+
});
15
+
return (
16
+
<div className="math-block my-2">
17
+
<div dangerouslySetInnerHTML={{ __html: html }} />
18
+
</div>
19
+
);
20
+
};
+95
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx
+95
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx
···
1
+
"use client";
2
+
import { UnicodeString } from "@atproto/api";
3
+
import { PubLeafletRichtextFacet } from "lexicons/api";
4
+
import { useMemo } from "react";
5
+
import { useHighlight } from "../useHighlight";
6
+
import { BaseTextBlock } from "./BaseTextBlock";
7
+
8
+
type Facet = PubLeafletRichtextFacet.Main;
9
+
export function TextBlock(props: {
10
+
plaintext: string;
11
+
facets?: Facet[];
12
+
index: number[];
13
+
preview?: boolean;
14
+
pageId?: string;
15
+
}) {
16
+
let children = [];
17
+
let highlights = useHighlight(props.index, props.pageId);
18
+
let facets = useMemo(() => {
19
+
if (props.preview) return props.facets;
20
+
let facets = [...(props.facets || [])];
21
+
for (let highlight of highlights) {
22
+
const fragmentId = props.pageId
23
+
? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}`
24
+
: `${props.index.join(".")}_${highlight.startOffset || 0}`;
25
+
facets = addFacet(
26
+
facets,
27
+
{
28
+
index: {
29
+
byteStart: highlight.startOffset
30
+
? new UnicodeString(
31
+
props.plaintext.slice(0, highlight.startOffset),
32
+
).length
33
+
: 0,
34
+
byteEnd: new UnicodeString(
35
+
props.plaintext.slice(0, highlight.endOffset ?? undefined),
36
+
).length,
37
+
},
38
+
features: [
39
+
{ $type: "pub.leaflet.richtext.facet#highlight" },
40
+
{
41
+
$type: "pub.leaflet.richtext.facet#id",
42
+
id: fragmentId,
43
+
},
44
+
],
45
+
},
46
+
new UnicodeString(props.plaintext).length,
47
+
);
48
+
}
49
+
return facets;
50
+
}, [props.plaintext, props.facets, highlights, props.preview, props.pageId]);
51
+
return <BaseTextBlock {...props} facets={facets} />;
52
+
}
53
+
54
+
function addFacet(facets: Facet[], newFacet: Facet, length: number) {
55
+
if (facets.length === 0) {
56
+
return [newFacet];
57
+
}
58
+
59
+
const allFacets = [...facets, newFacet];
60
+
61
+
// Collect all boundary positions
62
+
const boundaries = new Set<number>();
63
+
boundaries.add(0);
64
+
boundaries.add(length);
65
+
66
+
for (const facet of allFacets) {
67
+
boundaries.add(facet.index.byteStart);
68
+
boundaries.add(facet.index.byteEnd);
69
+
}
70
+
71
+
const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
72
+
const result: Facet[] = [];
73
+
74
+
// Process segments between consecutive boundaries
75
+
for (let i = 0; i < sortedBoundaries.length - 1; i++) {
76
+
const start = sortedBoundaries[i];
77
+
const end = sortedBoundaries[i + 1];
78
+
79
+
// Find facets that are active at the start position
80
+
const activeFacets = allFacets.filter(
81
+
(facet) => facet.index.byteStart <= start && facet.index.byteEnd > start,
82
+
);
83
+
84
+
// Only create facet if there are active facets (features present)
85
+
if (activeFacets.length > 0) {
86
+
const features = activeFacets.flatMap((f) => f.features);
87
+
result.push({
88
+
index: { byteStart: start, byteEnd: end },
89
+
features,
90
+
});
91
+
}
92
+
}
93
+
94
+
return result;
95
+
}
+181
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx
+181
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx
···
1
+
import { UnicodeString } from "@atproto/api";
2
+
import { PubLeafletRichtextFacet } from "lexicons/api";
3
+
import { AtMentionLink } from "components/AtMentionLink";
4
+
import { ReactNode } from "react";
5
+
6
+
type Facet = PubLeafletRichtextFacet.Main;
7
+
8
+
export type FacetRenderers = {
9
+
DidMention?: (props: { did: string; children: ReactNode }) => ReactNode;
10
+
};
11
+
12
+
export type TextBlockCoreProps = {
13
+
plaintext: string;
14
+
facets?: Facet[];
15
+
index: number[];
16
+
preview?: boolean;
17
+
renderers?: FacetRenderers;
18
+
};
19
+
20
+
export function TextBlockCore(props: TextBlockCoreProps) {
21
+
let children = [];
22
+
let richText = new RichText({
23
+
text: props.plaintext,
24
+
facets: props.facets || [],
25
+
});
26
+
let counter = 0;
27
+
for (const segment of richText.segments()) {
28
+
let id = segment.facet?.find(PubLeafletRichtextFacet.isId);
29
+
let link = segment.facet?.find(PubLeafletRichtextFacet.isLink);
30
+
let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold);
31
+
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
32
+
let isStrikethrough = segment.facet?.find(
33
+
PubLeafletRichtextFacet.isStrikethrough,
34
+
);
35
+
let isDidMention = segment.facet?.find(
36
+
PubLeafletRichtextFacet.isDidMention,
37
+
);
38
+
let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention);
39
+
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
40
+
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
41
+
let isHighlighted = segment.facet?.find(
42
+
PubLeafletRichtextFacet.isHighlight,
43
+
);
44
+
let className = `
45
+
${isCode ? "inline-code" : ""}
46
+
${id ? "scroll-mt-12 scroll-mb-10" : ""}
47
+
${isBold ? "font-bold" : ""}
48
+
${isItalic ? "italic" : ""}
49
+
${isUnderline ? "underline" : ""}
50
+
${isStrikethrough ? "line-through decoration-tertiary" : ""}
51
+
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
52
+
53
+
// Split text by newlines and insert <br> tags
54
+
const textParts = segment.text.split("\n");
55
+
const renderedText = textParts.flatMap((part, i) =>
56
+
i < textParts.length - 1
57
+
? [part, <br key={`br-${counter}-${i}`} />]
58
+
: [part],
59
+
);
60
+
61
+
if (isCode) {
62
+
children.push(
63
+
<code key={counter} className={className} id={id?.id}>
64
+
{renderedText}
65
+
</code>,
66
+
);
67
+
} else if (isDidMention) {
68
+
const DidMentionRenderer = props.renderers?.DidMention;
69
+
if (DidMentionRenderer) {
70
+
children.push(
71
+
<DidMentionRenderer key={counter} did={isDidMention.did}>
72
+
<span className="mention">{renderedText}</span>
73
+
</DidMentionRenderer>,
74
+
);
75
+
} else {
76
+
// Default: render as a simple link
77
+
children.push(
78
+
<a
79
+
key={counter}
80
+
href={`https://leaflet.pub/p/${isDidMention.did}`}
81
+
target="_blank"
82
+
className="no-underline"
83
+
>
84
+
<span className="mention">{renderedText}</span>
85
+
</a>,
86
+
);
87
+
}
88
+
} else if (isAtMention) {
89
+
children.push(
90
+
<AtMentionLink
91
+
key={counter}
92
+
atURI={isAtMention.atURI}
93
+
className={className}
94
+
>
95
+
{renderedText}
96
+
</AtMentionLink>,
97
+
);
98
+
} else if (link) {
99
+
children.push(
100
+
<a
101
+
key={counter}
102
+
href={link.uri.trim()}
103
+
className={`text-accent-contrast hover:underline ${className}`}
104
+
target="_blank"
105
+
>
106
+
{renderedText}
107
+
</a>,
108
+
);
109
+
} else {
110
+
children.push(
111
+
<span key={counter} className={className} id={id?.id}>
112
+
{renderedText}
113
+
</span>,
114
+
);
115
+
}
116
+
117
+
counter++;
118
+
}
119
+
return <>{children}</>;
120
+
}
121
+
122
+
type RichTextSegment = {
123
+
text: string;
124
+
facet?: Exclude<Facet["features"], { $type: string }>;
125
+
};
126
+
127
+
export class RichText {
128
+
unicodeText: UnicodeString;
129
+
facets?: Facet[];
130
+
131
+
constructor(props: { text: string; facets: Facet[] }) {
132
+
this.unicodeText = new UnicodeString(props.text);
133
+
this.facets = props.facets;
134
+
if (this.facets) {
135
+
this.facets = this.facets
136
+
.filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
137
+
.sort((a, b) => a.index.byteStart - b.index.byteStart);
138
+
}
139
+
}
140
+
141
+
*segments(): Generator<RichTextSegment, void, void> {
142
+
const facets = this.facets || [];
143
+
if (!facets.length) {
144
+
yield { text: this.unicodeText.utf16 };
145
+
return;
146
+
}
147
+
148
+
let textCursor = 0;
149
+
let facetCursor = 0;
150
+
do {
151
+
const currFacet = facets[facetCursor];
152
+
if (textCursor < currFacet.index.byteStart) {
153
+
yield {
154
+
text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
155
+
};
156
+
} else if (textCursor > currFacet.index.byteStart) {
157
+
facetCursor++;
158
+
continue;
159
+
}
160
+
if (currFacet.index.byteStart < currFacet.index.byteEnd) {
161
+
const subtext = this.unicodeText.slice(
162
+
currFacet.index.byteStart,
163
+
currFacet.index.byteEnd,
164
+
);
165
+
if (!subtext.trim()) {
166
+
// dont empty string entities
167
+
yield { text: subtext };
168
+
} else {
169
+
yield { text: subtext, facet: currFacet.features };
170
+
}
171
+
}
172
+
textCursor = currFacet.index.byteEnd;
173
+
facetCursor++;
174
+
} while (facetCursor < facets.length);
175
+
if (textCursor < this.unicodeText.length) {
176
+
yield {
177
+
text: this.unicodeText.slice(textCursor, this.unicodeText.length),
178
+
};
179
+
}
180
+
}
181
+
}
+1
-6
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+1
-6
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
202
202
isSubpage: boolean | undefined;
203
203
data: PostPageData;
204
204
profile: ProfileViewDetailed;
205
-
preferences: {
206
-
showComments?: boolean;
207
-
showMentions?: boolean;
208
-
showPrevNext?: boolean;
209
-
};
205
+
preferences: { showComments?: boolean };
210
206
quotesCount: number | undefined;
211
207
commentsCount: number | undefined;
212
208
}) => {
···
217
213
quotesCount={props.quotesCount || 0}
218
214
commentsCount={props.commentsCount || 0}
219
215
showComments={props.preferences.showComments}
220
-
showMentions={props.preferences.showMentions}
221
216
pageId={props.pageId}
222
217
/>
223
218
{!props.isSubpage && (
+2
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
+2
-5
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
···
5
5
import { CommentBox } from "./CommentBox";
6
6
import { Json } from "supabase/database.types";
7
7
import { PubLeafletComment } from "lexicons/api";
8
-
import { BaseTextBlock } from "../../BaseTextBlock";
8
+
import { BaseTextBlock } from "../../Blocks/BaseTextBlock";
9
9
import { useMemo, useState } from "react";
10
10
import { CommentTiny } from "components/Icons/CommentTiny";
11
11
import { Separator } from "components/Layout";
···
51
51
}, []);
52
52
53
53
return (
54
-
<div
55
-
id={"commentsDrawer"}
56
-
className="flex flex-col gap-2 relative text-sm text-secondary"
57
-
>
54
+
<div id={"commentsDrawer"} className="flex flex-col gap-2 relative">
58
55
<div className="w-full flex justify-between text-secondary font-bold">
59
56
Comments
60
57
<button
+1
-2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
+1
-2
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
···
9
9
import { decodeQuotePosition } from "../quotePosition";
10
10
11
11
export const InteractionDrawer = (props: {
12
-
showPageBackground: boolean | undefined;
13
12
document_uri: string;
14
13
quotesAndMentions: { uri: string; link?: string }[];
15
14
comments: Comment[];
···
39
38
<div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]">
40
39
<div
41
40
id="interaction-drawer"
42
-
className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] ${props.showPageBackground ? "rounded-l-none! rounded-r-lg!" : "rounded-lg! sm:mx-2"}`}
41
+
className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] "
43
42
>
44
43
{drawer.drawer === "quotes" ? (
45
44
<Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
+44
-68
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
+44
-68
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
···
108
108
commentsCount: number;
109
109
className?: string;
110
110
showComments?: boolean;
111
-
showMentions?: boolean;
112
111
pageId?: string;
113
112
}) => {
114
113
const data = useContext(PostPageContext);
···
132
131
<div className={`flex gap-2 text-tertiary text-sm ${props.className}`}>
133
132
{tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />}
134
133
135
-
{props.quotesCount === 0 || props.showMentions === false ? null : (
134
+
{props.quotesCount > 0 && (
136
135
<button
137
136
className="flex w-fit gap-2 items-center"
138
137
onClick={() => {
···
169
168
commentsCount: number;
170
169
className?: string;
171
170
showComments?: boolean;
172
-
showMentions?: boolean;
173
171
pageId?: string;
174
172
}) => {
175
173
const data = useContext(PostPageContext);
···
191
189
const tags = (data?.data as any)?.tags as string[] | undefined;
192
190
const tagCount = tags?.length || 0;
193
191
194
-
let noInteractions = !props.showComments && !props.showMentions;
195
-
196
192
let subscribed =
197
193
identity?.atp_did &&
198
194
publication?.publication_subscriptions &&
···
233
229
<TagList tags={tags} className="mb-3" />
234
230
</>
235
231
)}
236
-
237
232
<hr className="border-border-light mb-3 " />
238
-
239
233
<div className="flex gap-2 justify-between">
240
-
{noInteractions ? (
241
-
<div />
242
-
) : (
243
-
<>
244
-
<div className="flex gap-2">
245
-
{props.quotesCount === 0 ||
246
-
props.showMentions === false ? null : (
247
-
<button
248
-
className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
249
-
onClick={() => {
250
-
if (!drawerOpen || drawer !== "quotes")
251
-
openInteractionDrawer(
252
-
"quotes",
253
-
document_uri,
254
-
props.pageId,
255
-
);
256
-
else
257
-
setInteractionState(document_uri, { drawerOpen: false });
258
-
}}
259
-
onMouseEnter={handleQuotePrefetch}
260
-
onTouchStart={handleQuotePrefetch}
261
-
aria-label="Post quotes"
262
-
>
263
-
<QuoteTiny aria-hidden /> {props.quotesCount}{" "}
264
-
<span
265
-
aria-hidden
266
-
>{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span>
267
-
</button>
268
-
)}
269
-
{props.showComments === false ? null : (
270
-
<button
271
-
className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
272
-
onClick={() => {
273
-
if (
274
-
!drawerOpen ||
275
-
drawer !== "comments" ||
276
-
pageId !== props.pageId
277
-
)
278
-
openInteractionDrawer(
279
-
"comments",
280
-
document_uri,
281
-
props.pageId,
282
-
);
283
-
else
284
-
setInteractionState(document_uri, { drawerOpen: false });
285
-
}}
286
-
aria-label="Post comments"
287
-
>
288
-
<CommentTiny aria-hidden />{" "}
289
-
{props.commentsCount > 0 ? (
290
-
<span aria-hidden>
291
-
{`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`}
292
-
</span>
293
-
) : (
294
-
"Comment"
295
-
)}
296
-
</button>
234
+
<div className="flex gap-2">
235
+
{props.quotesCount > 0 && (
236
+
<button
237
+
className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
238
+
onClick={() => {
239
+
if (!drawerOpen || drawer !== "quotes")
240
+
openInteractionDrawer("quotes", document_uri, props.pageId);
241
+
else setInteractionState(document_uri, { drawerOpen: false });
242
+
}}
243
+
onMouseEnter={handleQuotePrefetch}
244
+
onTouchStart={handleQuotePrefetch}
245
+
aria-label="Post quotes"
246
+
>
247
+
<QuoteTiny aria-hidden /> {props.quotesCount}{" "}
248
+
<span
249
+
aria-hidden
250
+
>{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span>
251
+
</button>
252
+
)}
253
+
{props.showComments === false ? null : (
254
+
<button
255
+
className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
256
+
onClick={() => {
257
+
if (
258
+
!drawerOpen ||
259
+
drawer !== "comments" ||
260
+
pageId !== props.pageId
261
+
)
262
+
openInteractionDrawer("comments", document_uri, props.pageId);
263
+
else setInteractionState(document_uri, { drawerOpen: false });
264
+
}}
265
+
aria-label="Post comments"
266
+
>
267
+
<CommentTiny aria-hidden />{" "}
268
+
{props.commentsCount > 0 ? (
269
+
<span aria-hidden>
270
+
{`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`}
271
+
</span>
272
+
) : (
273
+
"Comment"
297
274
)}
298
-
</div>
299
-
</>
300
-
)}
301
-
275
+
</button>
276
+
)}
277
+
</div>
302
278
<EditButton document={data} />
303
279
{subscribed && publication && (
304
280
<ManageSubscription
+2
-7
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+2
-7
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
14
14
ExpandedInteractions,
15
15
getCommentCount,
16
16
getQuoteCount,
17
+
Interactions,
17
18
} from "./Interactions/Interactions";
18
19
import { PostContent } from "./PostContent";
19
20
import { PostHeader } from "./PostHeader/PostHeader";
···
24
25
import { decodeQuotePosition } from "./quotePosition";
25
26
import { PollData } from "./fetchPollData";
26
27
import { SharedPageProps } from "./PostPages";
27
-
import { PostPrevNextButtons } from "./PostPrevNextButtons";
28
28
29
29
export function LinearDocumentPage({
30
30
blocks,
···
56
56
57
57
const isSubpage = !!pageId;
58
58
59
-
console.log("prev/next?: " + preferences.showPrevNext);
60
-
61
59
return (
62
60
<>
63
61
<PageWrapper
···
85
83
did={did}
86
84
prerenderedCodeBlocks={prerenderedCodeBlocks}
87
85
/>
88
-
<PostPrevNextButtons
89
-
showPrevNext={preferences.showPrevNext && !isSubpage}
90
-
/>
86
+
91
87
<ExpandedInteractions
92
88
pageId={pageId}
93
89
showComments={preferences.showComments}
94
-
showMentions={preferences.showMentions}
95
90
commentsCount={getCommentCount(document, pageId) || 0}
96
91
quotesCount={getQuoteCount(document, pageId) || 0}
97
92
/>
+18
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+18
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
···
20
20
} from "lexicons/api";
21
21
22
22
import { blobRefToSrc } from "src/utils/blobRefToSrc";
23
-
import { TextBlock } from "./TextBlock";
23
+
import { TextBlock } from "./Blocks/TextBlock";
24
24
import { Popover } from "components/Popover";
25
25
import { theme } from "tailwind.config";
26
26
import { ImageAltSmall } from "components/Icons/ImageAlt";
27
-
import { StaticMathBlock } from "./StaticMathBlock";
28
-
import { PubCodeBlock } from "./PubCodeBlock";
27
+
import { StaticMathBlock } from "./Blocks/StaticMathBlock";
28
+
import { PubCodeBlock } from "./Blocks/PubCodeBlock";
29
29
import { AppBskyFeedDefs } from "@atproto/api";
30
-
import { PubBlueskyPostBlock } from "./PublishBskyPostBlock";
30
+
import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock";
31
31
import { openPage } from "./PostPages";
32
32
import { PageLinkBlock } from "components/Blocks/PageLinkBlock";
33
-
import { PublishedPageLinkBlock } from "./PublishedPageBlock";
34
-
import { PublishedPollBlock } from "./PublishedPollBlock";
33
+
import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock";
34
+
import { PublishedPollBlock } from "./Blocks/PublishedPollBlock";
35
35
import { PollData } from "./fetchPollData";
36
36
import { ButtonPrimary } from "components/Buttons";
37
37
···
173
173
let uri = b.block.postRef.uri;
174
174
let post = bskyPostData.find((p) => p.uri === uri);
175
175
if (!post) return <div>no prefetched post rip</div>;
176
-
return <PubBlueskyPostBlock post={post} className={className} pageId={pageId} />;
176
+
return (
177
+
<PubBlueskyPostBlock
178
+
post={post}
179
+
className={className}
180
+
pageId={pageId}
181
+
/>
182
+
);
177
183
}
178
184
case PubLeafletBlocksIframe.isMain(b.block): {
179
185
return (
···
339
345
}
340
346
case PubLeafletBlocksText.isMain(b.block):
341
347
return (
342
-
<p className={`textBlock ${className}`} {...blockProps}>
348
+
<p
349
+
className={`textBlock ${className} ${b.block.textSize === "small" ? "text-sm text-secondary" : b.block.textSize === "large" ? "text-lg" : ""}`}
350
+
{...blockProps}
351
+
>
343
352
<TextBlock
344
353
facets={b.block.facets}
345
354
plaintext={b.block.plaintext}
···
349
358
/>
350
359
</p>
351
360
);
361
+
352
362
case PubLeafletBlocksHeader.isMain(b.block): {
353
363
if (b.block.level === 1)
354
364
return (
+1
-2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+1
-2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
···
23
23
export function PostHeader(props: {
24
24
data: PostPageData;
25
25
profile: ProfileViewDetailed;
26
-
preferences: { showComments?: boolean; showMentions?: boolean };
26
+
preferences: { showComments?: boolean };
27
27
}) {
28
28
let { identity } = useIdentityData();
29
29
let document = props.data;
···
91
91
</div>
92
92
<Interactions
93
93
showComments={props.preferences.showComments}
94
-
showMentions={props.preferences.showMentions}
95
94
quotesCount={getQuoteCount(document) || 0}
96
95
commentsCount={getCommentCount(document) || 0}
97
96
/>
+4
-22
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+4
-22
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
147
147
document: PostPageData;
148
148
did: string;
149
149
profile: ProfileViewDetailed;
150
-
preferences: {
151
-
showComments?: boolean;
152
-
showMentions?: boolean;
153
-
showPrevNext?: boolean;
154
-
};
150
+
preferences: { showComments?: boolean };
155
151
pubRecord?: PubLeafletPublication.Record;
156
152
theme?: PubLeafletPublication.Theme | null;
157
153
prerenderedCodeBlocks?: Map<string, string>;
···
210
206
did: string;
211
207
prerenderedCodeBlocks?: Map<string, string>;
212
208
bskyPostData: AppBskyFeedDefs.PostView[];
213
-
preferences: {
214
-
showComments?: boolean;
215
-
showMentions?: boolean;
216
-
showPrevNext?: boolean;
217
-
};
209
+
preferences: { showComments?: boolean };
218
210
pollData: PollData[];
219
211
}) {
220
212
let drawer = useDrawerOpen(document_uri);
···
269
261
270
262
{drawer && !drawer.pageId && (
271
263
<InteractionDrawer
272
-
showPageBackground={pubRecord?.theme?.showPageBackground}
273
264
document_uri={document.uri}
274
265
comments={
275
266
pubRecord?.preferences?.showComments === false
276
267
? []
277
268
: document.comments_on_documents
278
269
}
279
-
quotesAndMentions={
280
-
pubRecord?.preferences?.showMentions === false
281
-
? []
282
-
: quotesAndMentions
283
-
}
270
+
quotesAndMentions={quotesAndMentions}
284
271
did={did}
285
272
/>
286
273
)}
···
360
347
/>
361
348
{drawer && drawer.pageId === page.id && (
362
349
<InteractionDrawer
363
-
showPageBackground={pubRecord?.theme?.showPageBackground}
364
350
pageId={page.id}
365
351
document_uri={document.uri}
366
352
comments={
···
368
354
? []
369
355
: document.comments_on_documents
370
356
}
371
-
quotesAndMentions={
372
-
pubRecord?.preferences?.showMentions === false
373
-
? []
374
-
: quotesAndMentions
375
-
}
357
+
quotesAndMentions={quotesAndMentions}
376
358
did={did}
377
359
/>
378
360
)}
-58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
-58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
···
1
-
"use client";
2
-
import { PubLeafletDocument } from "lexicons/api";
3
-
import { usePublicationData } from "../dashboard/PublicationSWRProvider";
4
-
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
5
-
import { AtUri } from "@atproto/api";
6
-
import { useParams } from "next/navigation";
7
-
import { getPostPageData } from "./getPostPageData";
8
-
import { PostPageContext } from "./PostPageContext";
9
-
import { useContext } from "react";
10
-
import { SpeedyLink } from "components/SpeedyLink";
11
-
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
12
-
13
-
export const PostPrevNextButtons = (props: {
14
-
showPrevNext: boolean | undefined;
15
-
}) => {
16
-
let postData = useContext(PostPageContext);
17
-
let pub = postData?.documents_in_publications[0]?.publications;
18
-
19
-
if (!props.showPrevNext || !pub || !postData) return;
20
-
21
-
function getPostLink(uri: string) {
22
-
return pub && uri
23
-
? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}`
24
-
: "leaflet.pub/not-found";
25
-
}
26
-
let prevPost = postData?.prevNext?.prev;
27
-
let nextPost = postData?.prevNext?.next;
28
-
29
-
return (
30
-
<div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2">
31
-
{/*<hr className="border-border-light" />*/}
32
-
<div className="flex justify-between w-full gap-8 ">
33
-
{nextPost ? (
34
-
<SpeedyLink
35
-
href={getPostLink(nextPost.uri)}
36
-
className="flex gap-1 items-center truncate min-w-0 basis-1/2"
37
-
>
38
-
<ArrowRightTiny className="rotate-180 shrink-0" />
39
-
<div className="min-w-0 truncate">{nextPost.title}</div>
40
-
</SpeedyLink>
41
-
) : (
42
-
<div />
43
-
)}
44
-
{prevPost ? (
45
-
<SpeedyLink
46
-
href={getPostLink(prevPost.uri)}
47
-
className="flex gap-1 items-center truncate min-w-0 basis-1/2 justify-end"
48
-
>
49
-
<div className="min-w-0 truncate">{prevPost.title}</div>
50
-
<ArrowRightTiny className="shrink-0" />
51
-
</SpeedyLink>
52
-
) : (
53
-
<div />
54
-
)}
55
-
</div>
56
-
</div>
57
-
);
58
-
};
-28
app/lish/[did]/[publication]/[rkey]/PubCodeBlock.tsx
-28
app/lish/[did]/[publication]/[rkey]/PubCodeBlock.tsx
···
1
-
"use client";
2
-
3
-
import { PubLeafletBlocksCode } from "lexicons/api";
4
-
import { useLayoutEffect, useState } from "react";
5
-
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
6
-
7
-
export function PubCodeBlock({
8
-
block,
9
-
prerenderedCode,
10
-
}: {
11
-
block: PubLeafletBlocksCode.Main;
12
-
prerenderedCode?: string;
13
-
}) {
14
-
const [html, setHTML] = useState<string | null>(prerenderedCode || null);
15
-
16
-
useLayoutEffect(() => {
17
-
const lang = bundledLanguagesInfo.find((l) => l.id === block.language)?.id || "plaintext";
18
-
const theme = bundledThemesInfo.find((t) => t.id === block.syntaxHighlightingTheme)?.id || "github-light";
19
-
20
-
codeToHtml(block.plaintext, { lang, theme }).then(setHTML);
21
-
}, [block]);
22
-
return (
23
-
<div
24
-
className="w-full min-h-[42px] my-2 rounded-md border-border-light outline-border-light selected-outline"
25
-
dangerouslySetInnerHTML={{ __html: html || "" }}
26
-
/>
27
-
);
28
-
}
-172
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
-172
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
···
1
-
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
2
-
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
-
import { Separator } from "components/Layout";
4
-
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
5
-
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
6
-
import { CommentTiny } from "components/Icons/CommentTiny";
7
-
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
-
import { ThreadLink, QuotesLink } from "./PostLinks";
9
-
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
10
-
import {
11
-
BlueskyEmbed,
12
-
PostNotAvailable,
13
-
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
14
-
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
15
-
import { openPage } from "./PostPages";
16
-
17
-
export const PubBlueskyPostBlock = (props: {
18
-
post: PostView;
19
-
className: string;
20
-
pageId?: string;
21
-
}) => {
22
-
let post = props.post;
23
-
24
-
const handleOpenThread = () => {
25
-
openPage(
26
-
props.pageId ? { type: "doc", id: props.pageId } : undefined,
27
-
{ type: "thread", uri: post.uri },
28
-
);
29
-
};
30
-
31
-
switch (true) {
32
-
case AppBskyFeedDefs.isBlockedPost(post) ||
33
-
AppBskyFeedDefs.isBlockedAuthor(post) ||
34
-
AppBskyFeedDefs.isNotFoundPost(post):
35
-
return (
36
-
<div className={`w-full`}>
37
-
<PostNotAvailable />
38
-
</div>
39
-
);
40
-
41
-
case AppBskyFeedDefs.validatePostView(post).success:
42
-
let record = post.record as AppBskyFeedDefs.PostView["record"];
43
-
44
-
// silliness to get the text and timestamp from the record with proper types
45
-
let timestamp: string | undefined = undefined;
46
-
if (AppBskyFeedPost.isRecord(record)) {
47
-
timestamp = (record as AppBskyFeedPost.Record).createdAt;
48
-
}
49
-
50
-
//getting the url to the post
51
-
let postId = post.uri.split("/")[4];
52
-
let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
53
-
54
-
const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined;
55
-
56
-
return (
57
-
<div
58
-
onClick={handleOpenThread}
59
-
className={`
60
-
${props.className}
61
-
block-border
62
-
mb-2
63
-
flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page
64
-
cursor-pointer hover:border-accent-contrast
65
-
`}
66
-
>
67
-
{post.author && record && (
68
-
<>
69
-
<div className="bskyAuthor w-full flex items-center gap-2">
70
-
{post.author.avatar && (
71
-
<img
72
-
src={post.author?.avatar}
73
-
alt={`${post.author?.displayName}'s avatar`}
74
-
className="shink-0 w-8 h-8 rounded-full border border-border-light"
75
-
/>
76
-
)}
77
-
<div className="grow flex flex-col gap-0.5 leading-tight">
78
-
<div className=" font-bold text-secondary">
79
-
{post.author?.displayName}
80
-
</div>
81
-
<a
82
-
className="text-xs text-tertiary hover:underline"
83
-
target="_blank"
84
-
href={`https://bsky.app/profile/${post.author?.handle}`}
85
-
onClick={(e) => e.stopPropagation()}
86
-
>
87
-
@{post.author?.handle}
88
-
</a>
89
-
</div>
90
-
</div>
91
-
92
-
<div className="flex flex-col gap-2 ">
93
-
<div>
94
-
<pre className="whitespace-pre-wrap">
95
-
{BlueskyRichText({
96
-
record: record as AppBskyFeedPost.Record | null,
97
-
})}
98
-
</pre>
99
-
</div>
100
-
{post.embed && (
101
-
<div onClick={(e) => e.stopPropagation()}>
102
-
<BlueskyEmbed embed={post.embed} postUrl={url} />
103
-
</div>
104
-
)}
105
-
</div>
106
-
</>
107
-
)}
108
-
<div className="w-full flex gap-2 items-center justify-between">
109
-
<ClientDate date={timestamp} />
110
-
<div className="flex gap-2 items-center">
111
-
{post.replyCount != null && post.replyCount > 0 && (
112
-
<>
113
-
<ThreadLink
114
-
threadUri={post.uri}
115
-
parent={parent}
116
-
className="flex items-center gap-1 hover:text-accent-contrast"
117
-
onClick={(e) => e.stopPropagation()}
118
-
>
119
-
{post.replyCount}
120
-
<CommentTiny />
121
-
</ThreadLink>
122
-
<Separator classname="h-4" />
123
-
</>
124
-
)}
125
-
{post.quoteCount != null && post.quoteCount > 0 && (
126
-
<>
127
-
<QuotesLink
128
-
postUri={post.uri}
129
-
parent={parent}
130
-
className="flex items-center gap-1 hover:text-accent-contrast"
131
-
onClick={(e) => e.stopPropagation()}
132
-
>
133
-
{post.quoteCount}
134
-
<QuoteTiny />
135
-
</QuotesLink>
136
-
<Separator classname="h-4" />
137
-
</>
138
-
)}
139
-
140
-
<a
141
-
className=""
142
-
target="_blank"
143
-
href={url}
144
-
onClick={(e) => e.stopPropagation()}
145
-
>
146
-
<BlueskyTiny />
147
-
</a>
148
-
</div>
149
-
</div>
150
-
</div>
151
-
);
152
-
}
153
-
};
154
-
155
-
const ClientDate = (props: { date?: string }) => {
156
-
let pageLoaded = useHasPageLoaded();
157
-
const formattedDate = useLocalizedDate(
158
-
props.date || new Date().toISOString(),
159
-
{
160
-
month: "short",
161
-
day: "numeric",
162
-
year: "numeric",
163
-
hour: "numeric",
164
-
minute: "numeric",
165
-
hour12: true,
166
-
},
167
-
);
168
-
169
-
if (!pageLoaded) return null;
170
-
171
-
return <div className="text-xs text-tertiary">{formattedDate}</div>;
172
-
};
-340
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
-340
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
···
1
-
"use client";
2
-
3
-
import { useEntity, useReplicache } from "src/replicache";
4
-
import { useUIState } from "src/useUIState";
5
-
import { CSSProperties, useContext, useRef } from "react";
6
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
-
import { PostContent, Block } from "./PostContent";
8
-
import {
9
-
PubLeafletBlocksHeader,
10
-
PubLeafletBlocksText,
11
-
PubLeafletComment,
12
-
PubLeafletPagesLinearDocument,
13
-
PubLeafletPagesCanvas,
14
-
PubLeafletPublication,
15
-
} from "lexicons/api";
16
-
import { AppBskyFeedDefs } from "@atproto/api";
17
-
import { TextBlock } from "./TextBlock";
18
-
import { PostPageContext } from "./PostPageContext";
19
-
import { openPage, useOpenPages } from "./PostPages";
20
-
import {
21
-
openInteractionDrawer,
22
-
setInteractionState,
23
-
useInteractionState,
24
-
} from "./Interactions/Interactions";
25
-
import { CommentTiny } from "components/Icons/CommentTiny";
26
-
import { QuoteTiny } from "components/Icons/QuoteTiny";
27
-
import { CanvasBackgroundPattern } from "components/Canvas";
28
-
29
-
export function PublishedPageLinkBlock(props: {
30
-
blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[];
31
-
parentPageId: string | undefined;
32
-
pageId: string;
33
-
did: string;
34
-
preview?: boolean;
35
-
className?: string;
36
-
prerenderedCodeBlocks?: Map<string, string>;
37
-
bskyPostData: AppBskyFeedDefs.PostView[];
38
-
isCanvas?: boolean;
39
-
pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
40
-
}) {
41
-
//switch to use actually state
42
-
let openPages = useOpenPages();
43
-
let isOpen = openPages.some(
44
-
(p) => p.type === "doc" && p.id === props.pageId,
45
-
);
46
-
return (
47
-
<div
48
-
className={`w-full cursor-pointer
49
-
pageLinkBlockWrapper relative group/pageLinkBlock
50
-
bg-bg-page shadow-sm
51
-
flex overflow-clip
52
-
block-border
53
-
${isOpen && "!border-tertiary"}
54
-
${props.className}
55
-
`}
56
-
onClick={(e) => {
57
-
if (e.isDefaultPrevented()) return;
58
-
if (e.shiftKey) return;
59
-
e.preventDefault();
60
-
e.stopPropagation();
61
-
62
-
openPage(
63
-
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
64
-
{ type: "doc", id: props.pageId },
65
-
);
66
-
}}
67
-
>
68
-
{props.isCanvas ? (
69
-
<CanvasLinkBlock
70
-
blocks={props.blocks as PubLeafletPagesCanvas.Block[]}
71
-
did={props.did}
72
-
pageId={props.pageId}
73
-
bskyPostData={props.bskyPostData}
74
-
pages={props.pages || []}
75
-
/>
76
-
) : (
77
-
<DocLinkBlock
78
-
{...props}
79
-
blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]}
80
-
/>
81
-
)}
82
-
</div>
83
-
);
84
-
}
85
-
export function DocLinkBlock(props: {
86
-
blocks: PubLeafletPagesLinearDocument.Block[];
87
-
pageId: string;
88
-
parentPageId?: string;
89
-
did: string;
90
-
preview?: boolean;
91
-
className?: string;
92
-
prerenderedCodeBlocks?: Map<string, string>;
93
-
bskyPostData: AppBskyFeedDefs.PostView[];
94
-
}) {
95
-
let [title, description] = props.blocks
96
-
.map((b) => b.block)
97
-
.filter(
98
-
(b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b),
99
-
);
100
-
101
-
return (
102
-
<div
103
-
style={{ "--list-marker-width": "20px" } as CSSProperties}
104
-
className={`
105
-
w-full h-[104px]
106
-
`}
107
-
>
108
-
<>
109
-
<div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full">
110
-
<div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col ">
111
-
<div className="grow">
112
-
{title && (
113
-
<div
114
-
className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`}
115
-
>
116
-
<TextBlock
117
-
facets={title.facets}
118
-
plaintext={title.plaintext}
119
-
index={[]}
120
-
preview
121
-
/>
122
-
</div>
123
-
)}
124
-
{description && (
125
-
<div
126
-
className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`}
127
-
>
128
-
<TextBlock
129
-
facets={description.facets}
130
-
plaintext={description.plaintext}
131
-
index={[]}
132
-
preview
133
-
/>
134
-
</div>
135
-
)}
136
-
</div>
137
-
138
-
<Interactions
139
-
pageId={props.pageId}
140
-
parentPageId={props.parentPageId}
141
-
/>
142
-
</div>
143
-
{!props.preview && (
144
-
<PagePreview blocks={props.blocks} did={props.did} />
145
-
)}
146
-
</div>
147
-
</>
148
-
</div>
149
-
);
150
-
}
151
-
152
-
export function PagePreview(props: {
153
-
did: string;
154
-
blocks: PubLeafletPagesLinearDocument.Block[];
155
-
}) {
156
-
let previewRef = useRef<HTMLDivElement | null>(null);
157
-
let { rootEntity } = useReplicache();
158
-
let data = useContext(PostPageContext);
159
-
let theme = data?.theme;
160
-
let pageWidth = `var(--page-width-unitless)`;
161
-
let cardBorderHidden = !theme?.showPageBackground;
162
-
return (
163
-
<div
164
-
ref={previewRef}
165
-
className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`}
166
-
>
167
-
<div
168
-
className="absolute top-0 left-0 origin-top-left pointer-events-none "
169
-
style={{
170
-
width: `calc(1px * ${pageWidth})`,
171
-
height: `calc(100vh - 64px)`,
172
-
transform: `scale(calc((120 / ${pageWidth} )))`,
173
-
backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))",
174
-
}}
175
-
>
176
-
{!cardBorderHidden && (
177
-
<div
178
-
className={`pageLinkBlockBackground
179
-
absolute top-0 left-0 right-0 bottom-0
180
-
pointer-events-none
181
-
`}
182
-
/>
183
-
)}
184
-
<PostContent
185
-
pollData={[]}
186
-
pages={[]}
187
-
did={props.did}
188
-
blocks={props.blocks}
189
-
preview
190
-
bskyPostData={[]}
191
-
/>
192
-
</div>
193
-
</div>
194
-
);
195
-
}
196
-
197
-
const Interactions = (props: { pageId: string; parentPageId?: string }) => {
198
-
const data = useContext(PostPageContext);
199
-
const document_uri = data?.uri;
200
-
if (!document_uri)
201
-
throw new Error("document_uri not available in PostPageContext");
202
-
let comments = data.comments_on_documents.filter(
203
-
(c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId,
204
-
).length;
205
-
let quotes = data.document_mentions_in_bsky.filter((q) =>
206
-
q.link.includes(props.pageId),
207
-
).length;
208
-
209
-
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
210
-
211
-
return (
212
-
<div
213
-
className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`}
214
-
>
215
-
{quotes > 0 && (
216
-
<button
217
-
className={`flex gap-1 items-center`}
218
-
onClick={(e) => {
219
-
e.preventDefault();
220
-
e.stopPropagation();
221
-
openPage(
222
-
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
223
-
{ type: "doc", id: props.pageId },
224
-
{ scrollIntoView: false },
225
-
);
226
-
if (!drawerOpen || drawer !== "quotes")
227
-
openInteractionDrawer("quotes", document_uri, props.pageId);
228
-
else setInteractionState(document_uri, { drawerOpen: false });
229
-
}}
230
-
>
231
-
<span className="sr-only">Page quotes</span>
232
-
<QuoteTiny aria-hidden /> {quotes}{" "}
233
-
</button>
234
-
)}
235
-
{comments > 0 && (
236
-
<button
237
-
className={`flex gap-1 items-center`}
238
-
onClick={(e) => {
239
-
e.preventDefault();
240
-
e.stopPropagation();
241
-
openPage(
242
-
props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined,
243
-
{ type: "doc", id: props.pageId },
244
-
{ scrollIntoView: false },
245
-
);
246
-
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
247
-
openInteractionDrawer("comments", document_uri, props.pageId);
248
-
else setInteractionState(document_uri, { drawerOpen: false });
249
-
}}
250
-
>
251
-
<span className="sr-only">Page comments</span>
252
-
<CommentTiny aria-hidden /> {comments}{" "}
253
-
</button>
254
-
)}
255
-
</div>
256
-
);
257
-
};
258
-
259
-
const CanvasLinkBlock = (props: {
260
-
blocks: PubLeafletPagesCanvas.Block[];
261
-
did: string;
262
-
pageId: string;
263
-
bskyPostData: AppBskyFeedDefs.PostView[];
264
-
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
265
-
}) => {
266
-
let pageWidth = `var(--page-width-unitless)`;
267
-
let height =
268
-
props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0;
269
-
270
-
return (
271
-
<div
272
-
style={{ contain: "size layout paint" }}
273
-
className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`}
274
-
>
275
-
<div
276
-
className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`}
277
-
style={{
278
-
width: `calc(1px * ${pageWidth})`,
279
-
height: "calc(1150px * 2)",
280
-
transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`,
281
-
}}
282
-
>
283
-
<div
284
-
style={{
285
-
minHeight: height + 512,
286
-
contain: "size layout paint",
287
-
}}
288
-
className="relative h-full w-[1272px]"
289
-
>
290
-
<div className="w-full h-full pointer-events-none">
291
-
<CanvasBackgroundPattern pattern="grid" />
292
-
</div>
293
-
{props.blocks
294
-
.sort((a, b) => {
295
-
if (a.y === b.y) {
296
-
return a.x - b.x;
297
-
}
298
-
return a.y - b.y;
299
-
})
300
-
.map((canvasBlock, index) => {
301
-
let { x, y, width, rotation } = canvasBlock;
302
-
let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`;
303
-
304
-
// Wrap the block in a LinearDocument.Block structure for compatibility
305
-
let linearBlock: PubLeafletPagesLinearDocument.Block = {
306
-
$type: "pub.leaflet.pages.linearDocument#block",
307
-
block: canvasBlock.block,
308
-
};
309
-
310
-
return (
311
-
<div
312
-
key={index}
313
-
className="absolute rounded-lg flex items-stretch origin-center p-3"
314
-
style={{
315
-
top: 0,
316
-
left: 0,
317
-
width,
318
-
transform,
319
-
}}
320
-
>
321
-
<div className="contents">
322
-
<Block
323
-
pollData={[]}
324
-
pageId={props.pageId}
325
-
pages={props.pages}
326
-
bskyPostData={props.bskyPostData}
327
-
block={linearBlock}
328
-
did={props.did}
329
-
index={[index]}
330
-
preview={true}
331
-
/>
332
-
</div>
333
-
</div>
334
-
);
335
-
})}
336
-
</div>
337
-
</div>
338
-
</div>
339
-
);
340
-
};
-346
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
-346
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
···
1
-
"use client";
2
-
3
-
import {
4
-
PubLeafletBlocksPoll,
5
-
PubLeafletPollDefinition,
6
-
PubLeafletPollVote,
7
-
} from "lexicons/api";
8
-
import { useState, useEffect } from "react";
9
-
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
10
-
import { useIdentityData } from "components/IdentityProvider";
11
-
import { AtpAgent } from "@atproto/api";
12
-
import { voteOnPublishedPoll } from "./voteOnPublishedPoll";
13
-
import { PollData } from "./fetchPollData";
14
-
import { Popover } from "components/Popover";
15
-
import LoginForm from "app/login/LoginForm";
16
-
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
17
-
import { getVoterIdentities, VoterIdentity } from "./getVoterIdentities";
18
-
import { Json } from "supabase/database.types";
19
-
import { InfoSmall } from "components/Icons/InfoSmall";
20
-
21
-
// Helper function to extract the first option from a vote record
22
-
const getVoteOption = (voteRecord: any): string | null => {
23
-
try {
24
-
const record = voteRecord as PubLeafletPollVote.Record;
25
-
return record.option && record.option.length > 0 ? record.option[0] : null;
26
-
} catch {
27
-
return null;
28
-
}
29
-
};
30
-
31
-
export const PublishedPollBlock = (props: {
32
-
block: PubLeafletBlocksPoll.Main;
33
-
pollData: PollData;
34
-
className?: string;
35
-
}) => {
36
-
const { identity } = useIdentityData();
37
-
const [selectedOption, setSelectedOption] = useState<string | null>(null);
38
-
const [isVoting, setIsVoting] = useState(false);
39
-
const [showResults, setShowResults] = useState(false);
40
-
const [optimisticVote, setOptimisticVote] = useState<{
41
-
option: string;
42
-
voter_did: string;
43
-
} | null>(null);
44
-
let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record;
45
-
let [isClient, setIsClient] = useState(false);
46
-
useEffect(() => {
47
-
setIsClient(true);
48
-
}, []);
49
-
50
-
const handleVote = async () => {
51
-
if (!selectedOption || !identity?.atp_did) return;
52
-
53
-
setIsVoting(true);
54
-
55
-
// Optimistically add the vote
56
-
setOptimisticVote({
57
-
option: selectedOption,
58
-
voter_did: identity.atp_did,
59
-
});
60
-
setShowResults(true);
61
-
62
-
try {
63
-
const result = await voteOnPublishedPoll(
64
-
props.block.pollRef.uri,
65
-
props.block.pollRef.cid,
66
-
selectedOption,
67
-
);
68
-
69
-
if (!result.success) {
70
-
console.error("Failed to vote:", result.error);
71
-
// Revert optimistic update on failure
72
-
setOptimisticVote(null);
73
-
setShowResults(false);
74
-
}
75
-
} catch (error) {
76
-
console.error("Failed to vote:", error);
77
-
// Revert optimistic update on failure
78
-
setOptimisticVote(null);
79
-
setShowResults(false);
80
-
} finally {
81
-
setIsVoting(false);
82
-
}
83
-
};
84
-
85
-
const hasVoted =
86
-
!!identity?.atp_did &&
87
-
(!!props.pollData?.atp_poll_votes.find(
88
-
(v) => v.voter_did === identity?.atp_did,
89
-
) ||
90
-
!!optimisticVote);
91
-
let isCreator =
92
-
identity?.atp_did && props.pollData.uri.includes(identity?.atp_did);
93
-
const displayResults = showResults || hasVoted;
94
-
95
-
return (
96
-
<div
97
-
className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`}
98
-
style={{
99
-
backgroundColor:
100
-
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
101
-
}}
102
-
>
103
-
{displayResults ? (
104
-
<>
105
-
<PollResults
106
-
pollData={props.pollData}
107
-
hasVoted={hasVoted}
108
-
setShowResults={setShowResults}
109
-
optimisticVote={optimisticVote}
110
-
/>
111
-
{!hasVoted && (
112
-
<div className="flex justify-start">
113
-
<button
114
-
className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
115
-
onClick={() => setShowResults(false)}
116
-
>
117
-
Back to Voting
118
-
</button>
119
-
</div>
120
-
)}
121
-
</>
122
-
) : (
123
-
<>
124
-
{pollRecord.options.map((option, index) => (
125
-
<PollOptionButton
126
-
key={index}
127
-
option={option}
128
-
optionIndex={index.toString()}
129
-
selected={selectedOption === index.toString()}
130
-
onSelect={() => setSelectedOption(index.toString())}
131
-
disabled={!identity?.atp_did}
132
-
/>
133
-
))}
134
-
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2">
135
-
<div className="text-sm text-tertiary">All votes are public</div>
136
-
<div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center">
137
-
<button
138
-
className="w-fit font-bold text-accent-contrast"
139
-
onClick={() => setShowResults(!showResults)}
140
-
>
141
-
See Results
142
-
</button>
143
-
{identity?.atp_did ? (
144
-
<ButtonPrimary
145
-
className="place-self-end"
146
-
onClick={handleVote}
147
-
disabled={!selectedOption || isVoting}
148
-
>
149
-
{isVoting ? "Voting..." : "Vote!"}
150
-
</ButtonPrimary>
151
-
) : (
152
-
<Popover
153
-
asChild
154
-
trigger={
155
-
<ButtonPrimary className="place-self-center">
156
-
<BlueskyTiny /> Login to vote
157
-
</ButtonPrimary>
158
-
}
159
-
>
160
-
{isClient && (
161
-
<LoginForm
162
-
text="Log in to vote on this poll!"
163
-
noEmail
164
-
redirectRoute={window?.location.href + "?refreshAuth"}
165
-
/>
166
-
)}
167
-
</Popover>
168
-
)}
169
-
</div>
170
-
</div>
171
-
</>
172
-
)}
173
-
</div>
174
-
);
175
-
};
176
-
177
-
const PollOptionButton = (props: {
178
-
option: PubLeafletPollDefinition.Option;
179
-
optionIndex: string;
180
-
selected: boolean;
181
-
onSelect: () => void;
182
-
disabled?: boolean;
183
-
}) => {
184
-
const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary;
185
-
186
-
return (
187
-
<div className="flex gap-2 items-center">
188
-
<ButtonComponent
189
-
className="pollOption grow max-w-full flex"
190
-
onClick={props.onSelect}
191
-
disabled={props.disabled}
192
-
>
193
-
{props.option.text}
194
-
</ButtonComponent>
195
-
</div>
196
-
);
197
-
};
198
-
199
-
const PollResults = (props: {
200
-
pollData: PollData;
201
-
hasVoted: boolean;
202
-
setShowResults: (show: boolean) => void;
203
-
optimisticVote: { option: string; voter_did: string } | null;
204
-
}) => {
205
-
// Merge optimistic vote with actual votes
206
-
const allVotes = props.optimisticVote
207
-
? [
208
-
...props.pollData.atp_poll_votes,
209
-
{
210
-
voter_did: props.optimisticVote.voter_did,
211
-
record: {
212
-
$type: "pub.leaflet.poll.vote",
213
-
option: [props.optimisticVote.option],
214
-
},
215
-
},
216
-
]
217
-
: props.pollData.atp_poll_votes;
218
-
219
-
const totalVotes = allVotes.length || 0;
220
-
let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record;
221
-
let optionsWithCount = pollRecord.options.map((o, index) => ({
222
-
...o,
223
-
votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()),
224
-
}));
225
-
226
-
const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length));
227
-
return (
228
-
<>
229
-
{pollRecord.options.map((option, index) => {
230
-
const voteRecords = allVotes.filter(
231
-
(v) => getVoteOption(v.record) === index.toString(),
232
-
);
233
-
const isWinner = totalVotes > 0 && voteRecords.length === highestVotes;
234
-
235
-
return (
236
-
<PollResult
237
-
key={index}
238
-
option={option}
239
-
votes={voteRecords.length}
240
-
voteRecords={voteRecords}
241
-
totalVotes={totalVotes}
242
-
winner={isWinner}
243
-
/>
244
-
);
245
-
})}
246
-
</>
247
-
);
248
-
};
249
-
250
-
const VoterListPopover = (props: {
251
-
votes: number;
252
-
voteRecords: { voter_did: string; record: Json }[];
253
-
}) => {
254
-
const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]);
255
-
const [isLoading, setIsLoading] = useState(false);
256
-
const [hasFetched, setHasFetched] = useState(false);
257
-
258
-
const handleOpenChange = async () => {
259
-
if (!hasFetched && props.voteRecords.length > 0) {
260
-
setIsLoading(true);
261
-
setHasFetched(true);
262
-
try {
263
-
const dids = props.voteRecords.map((v) => v.voter_did);
264
-
const identities = await getVoterIdentities(dids);
265
-
setVoterIdentities(identities);
266
-
} catch (error) {
267
-
console.error("Failed to fetch voter identities:", error);
268
-
} finally {
269
-
setIsLoading(false);
270
-
}
271
-
}
272
-
};
273
-
274
-
return (
275
-
<Popover
276
-
trigger={
277
-
<button
278
-
className="hover:underline cursor-pointer"
279
-
disabled={props.votes === 0}
280
-
>
281
-
{props.votes}
282
-
</button>
283
-
}
284
-
onOpenChange={handleOpenChange}
285
-
className="w-64 max-h-80"
286
-
>
287
-
{isLoading ? (
288
-
<div className="flex justify-center py-4">
289
-
<div className="text-sm text-secondary">Loading...</div>
290
-
</div>
291
-
) : (
292
-
<div className="flex flex-col gap-1 text-sm py-0.5">
293
-
{voterIdentities.map((voter) => (
294
-
<a
295
-
key={voter.did}
296
-
href={`https://bsky.app/profile/${voter.handle || voter.did}`}
297
-
target="_blank"
298
-
rel="noopener noreferrer"
299
-
className=""
300
-
>
301
-
@{voter.handle || voter.did}
302
-
</a>
303
-
))}
304
-
</div>
305
-
)}
306
-
</Popover>
307
-
);
308
-
};
309
-
310
-
const PollResult = (props: {
311
-
option: PubLeafletPollDefinition.Option;
312
-
votes: number;
313
-
voteRecords: { voter_did: string; record: Json }[];
314
-
totalVotes: number;
315
-
winner: boolean;
316
-
}) => {
317
-
return (
318
-
<div
319
-
className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
320
-
>
321
-
<div
322
-
style={{
323
-
WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`,
324
-
paintOrder: "stroke fill",
325
-
}}
326
-
className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10"
327
-
>
328
-
<div className="grow max-w-full truncate">{props.option.text}</div>
329
-
<VoterListPopover votes={props.votes} voteRecords={props.voteRecords} />
330
-
</div>
331
-
<div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0">
332
-
<div
333
-
className="bg-accent-contrast rounded-[2px] m-0.5"
334
-
style={{
335
-
maskImage: "var(--hatchSVG)",
336
-
maskRepeat: "repeat repeat",
337
-
...(props.votes === 0
338
-
? { width: "4px" }
339
-
: { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
340
-
}}
341
-
/>
342
-
<div />
343
-
</div>
344
-
</div>
345
-
);
346
-
};
+2
-3
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
+2
-3
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
···
186
186
<BlueskyLinkTiny className="shrink-0" />
187
187
Bluesky
188
188
</a>
189
-
<Separator classname="h-4!" />
189
+
<Separator classname="h-4" />
190
190
<button
191
191
id="copy-quote-link"
192
192
className="flex gap-1 items-center hover:font-bold px-1"
···
211
211
</button>
212
212
{pubRecord?.preferences?.showComments !== false && identity?.atp_did && (
213
213
<>
214
-
<Separator classname="h-4! " />
215
-
214
+
<Separator classname="h-4" />
216
215
<button
217
216
className="flex gap-1 items-center hover:font-bold px-1"
218
217
onClick={() => {
-20
app/lish/[did]/[publication]/[rkey]/StaticMathBlock.tsx
-20
app/lish/[did]/[publication]/[rkey]/StaticMathBlock.tsx
···
1
-
import { PubLeafletBlocksMath } from "lexicons/api";
2
-
import Katex from "katex";
3
-
import "katex/dist/katex.min.css";
4
-
5
-
export const StaticMathBlock = ({
6
-
block,
7
-
}: {
8
-
block: PubLeafletBlocksMath.Main;
9
-
}) => {
10
-
const html = Katex.renderToString(block.tex, {
11
-
displayMode: true,
12
-
output: "html",
13
-
throwOnError: false,
14
-
});
15
-
return (
16
-
<div className="math-block my-2">
17
-
<div dangerouslySetInnerHTML={{ __html: html }} />
18
-
</div>
19
-
);
20
-
};
+2
-2
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
+2
-2
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
···
12
12
PubLeafletPagesLinearDocument,
13
13
} from "lexicons/api";
14
14
import { blobRefToSrc } from "src/utils/blobRefToSrc";
15
-
import { TextBlockCore, TextBlockCoreProps } from "./TextBlockCore";
16
-
import { StaticMathBlock } from "./StaticMathBlock";
15
+
import { TextBlockCore, TextBlockCoreProps } from "./Blocks/TextBlockCore";
16
+
import { StaticMathBlock } from "./Blocks/StaticMathBlock";
17
17
import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki";
18
18
19
19
function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
-95
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
-95
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
···
1
-
"use client";
2
-
import { UnicodeString } from "@atproto/api";
3
-
import { PubLeafletRichtextFacet } from "lexicons/api";
4
-
import { useMemo } from "react";
5
-
import { useHighlight } from "./useHighlight";
6
-
import { BaseTextBlock } from "./BaseTextBlock";
7
-
8
-
type Facet = PubLeafletRichtextFacet.Main;
9
-
export function TextBlock(props: {
10
-
plaintext: string;
11
-
facets?: Facet[];
12
-
index: number[];
13
-
preview?: boolean;
14
-
pageId?: string;
15
-
}) {
16
-
let children = [];
17
-
let highlights = useHighlight(props.index, props.pageId);
18
-
let facets = useMemo(() => {
19
-
if (props.preview) return props.facets;
20
-
let facets = [...(props.facets || [])];
21
-
for (let highlight of highlights) {
22
-
const fragmentId = props.pageId
23
-
? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}`
24
-
: `${props.index.join(".")}_${highlight.startOffset || 0}`;
25
-
facets = addFacet(
26
-
facets,
27
-
{
28
-
index: {
29
-
byteStart: highlight.startOffset
30
-
? new UnicodeString(
31
-
props.plaintext.slice(0, highlight.startOffset),
32
-
).length
33
-
: 0,
34
-
byteEnd: new UnicodeString(
35
-
props.plaintext.slice(0, highlight.endOffset ?? undefined),
36
-
).length,
37
-
},
38
-
features: [
39
-
{ $type: "pub.leaflet.richtext.facet#highlight" },
40
-
{
41
-
$type: "pub.leaflet.richtext.facet#id",
42
-
id: fragmentId,
43
-
},
44
-
],
45
-
},
46
-
new UnicodeString(props.plaintext).length,
47
-
);
48
-
}
49
-
return facets;
50
-
}, [props.plaintext, props.facets, highlights, props.preview, props.pageId]);
51
-
return <BaseTextBlock {...props} facets={facets} />;
52
-
}
53
-
54
-
function addFacet(facets: Facet[], newFacet: Facet, length: number) {
55
-
if (facets.length === 0) {
56
-
return [newFacet];
57
-
}
58
-
59
-
const allFacets = [...facets, newFacet];
60
-
61
-
// Collect all boundary positions
62
-
const boundaries = new Set<number>();
63
-
boundaries.add(0);
64
-
boundaries.add(length);
65
-
66
-
for (const facet of allFacets) {
67
-
boundaries.add(facet.index.byteStart);
68
-
boundaries.add(facet.index.byteEnd);
69
-
}
70
-
71
-
const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b);
72
-
const result: Facet[] = [];
73
-
74
-
// Process segments between consecutive boundaries
75
-
for (let i = 0; i < sortedBoundaries.length - 1; i++) {
76
-
const start = sortedBoundaries[i];
77
-
const end = sortedBoundaries[i + 1];
78
-
79
-
// Find facets that are active at the start position
80
-
const activeFacets = allFacets.filter(
81
-
(facet) => facet.index.byteStart <= start && facet.index.byteEnd > start,
82
-
);
83
-
84
-
// Only create facet if there are active facets (features present)
85
-
if (activeFacets.length > 0) {
86
-
const features = activeFacets.flatMap((f) => f.features);
87
-
result.push({
88
-
index: { byteStart: start, byteEnd: end },
89
-
features,
90
-
});
91
-
}
92
-
}
93
-
94
-
return result;
95
-
}
-181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
-181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
···
1
-
import { UnicodeString } from "@atproto/api";
2
-
import { PubLeafletRichtextFacet } from "lexicons/api";
3
-
import { AtMentionLink } from "components/AtMentionLink";
4
-
import { ReactNode } from "react";
5
-
6
-
type Facet = PubLeafletRichtextFacet.Main;
7
-
8
-
export type FacetRenderers = {
9
-
DidMention?: (props: { did: string; children: ReactNode }) => ReactNode;
10
-
};
11
-
12
-
export type TextBlockCoreProps = {
13
-
plaintext: string;
14
-
facets?: Facet[];
15
-
index: number[];
16
-
preview?: boolean;
17
-
renderers?: FacetRenderers;
18
-
};
19
-
20
-
export function TextBlockCore(props: TextBlockCoreProps) {
21
-
let children = [];
22
-
let richText = new RichText({
23
-
text: props.plaintext,
24
-
facets: props.facets || [],
25
-
});
26
-
let counter = 0;
27
-
for (const segment of richText.segments()) {
28
-
let id = segment.facet?.find(PubLeafletRichtextFacet.isId);
29
-
let link = segment.facet?.find(PubLeafletRichtextFacet.isLink);
30
-
let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold);
31
-
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
32
-
let isStrikethrough = segment.facet?.find(
33
-
PubLeafletRichtextFacet.isStrikethrough,
34
-
);
35
-
let isDidMention = segment.facet?.find(
36
-
PubLeafletRichtextFacet.isDidMention,
37
-
);
38
-
let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention);
39
-
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
40
-
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
41
-
let isHighlighted = segment.facet?.find(
42
-
PubLeafletRichtextFacet.isHighlight,
43
-
);
44
-
let className = `
45
-
${isCode ? "inline-code" : ""}
46
-
${id ? "scroll-mt-12 scroll-mb-10" : ""}
47
-
${isBold ? "font-bold" : ""}
48
-
${isItalic ? "italic" : ""}
49
-
${isUnderline ? "underline" : ""}
50
-
${isStrikethrough ? "line-through decoration-tertiary" : ""}
51
-
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
52
-
53
-
// Split text by newlines and insert <br> tags
54
-
const textParts = segment.text.split("\n");
55
-
const renderedText = textParts.flatMap((part, i) =>
56
-
i < textParts.length - 1
57
-
? [part, <br key={`br-${counter}-${i}`} />]
58
-
: [part],
59
-
);
60
-
61
-
if (isCode) {
62
-
children.push(
63
-
<code key={counter} className={className} id={id?.id}>
64
-
{renderedText}
65
-
</code>,
66
-
);
67
-
} else if (isDidMention) {
68
-
const DidMentionRenderer = props.renderers?.DidMention;
69
-
if (DidMentionRenderer) {
70
-
children.push(
71
-
<DidMentionRenderer key={counter} did={isDidMention.did}>
72
-
<span className="mention">{renderedText}</span>
73
-
</DidMentionRenderer>,
74
-
);
75
-
} else {
76
-
// Default: render as a simple link
77
-
children.push(
78
-
<a
79
-
key={counter}
80
-
href={`https://leaflet.pub/p/${isDidMention.did}`}
81
-
target="_blank"
82
-
className="no-underline"
83
-
>
84
-
<span className="mention">{renderedText}</span>
85
-
</a>,
86
-
);
87
-
}
88
-
} else if (isAtMention) {
89
-
children.push(
90
-
<AtMentionLink
91
-
key={counter}
92
-
atURI={isAtMention.atURI}
93
-
className={className}
94
-
>
95
-
{renderedText}
96
-
</AtMentionLink>,
97
-
);
98
-
} else if (link) {
99
-
children.push(
100
-
<a
101
-
key={counter}
102
-
href={link.uri.trim()}
103
-
className={`text-accent-contrast hover:underline ${className}`}
104
-
target="_blank"
105
-
>
106
-
{renderedText}
107
-
</a>,
108
-
);
109
-
} else {
110
-
children.push(
111
-
<span key={counter} className={className} id={id?.id}>
112
-
{renderedText}
113
-
</span>,
114
-
);
115
-
}
116
-
117
-
counter++;
118
-
}
119
-
return <>{children}</>;
120
-
}
121
-
122
-
type RichTextSegment = {
123
-
text: string;
124
-
facet?: Exclude<Facet["features"], { $type: string }>;
125
-
};
126
-
127
-
export class RichText {
128
-
unicodeText: UnicodeString;
129
-
facets?: Facet[];
130
-
131
-
constructor(props: { text: string; facets: Facet[] }) {
132
-
this.unicodeText = new UnicodeString(props.text);
133
-
this.facets = props.facets;
134
-
if (this.facets) {
135
-
this.facets = this.facets
136
-
.filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
137
-
.sort((a, b) => a.index.byteStart - b.index.byteStart);
138
-
}
139
-
}
140
-
141
-
*segments(): Generator<RichTextSegment, void, void> {
142
-
const facets = this.facets || [];
143
-
if (!facets.length) {
144
-
yield { text: this.unicodeText.utf16 };
145
-
return;
146
-
}
147
-
148
-
let textCursor = 0;
149
-
let facetCursor = 0;
150
-
do {
151
-
const currFacet = facets[facetCursor];
152
-
if (textCursor < currFacet.index.byteStart) {
153
-
yield {
154
-
text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
155
-
};
156
-
} else if (textCursor > currFacet.index.byteStart) {
157
-
facetCursor++;
158
-
continue;
159
-
}
160
-
if (currFacet.index.byteStart < currFacet.index.byteEnd) {
161
-
const subtext = this.unicodeText.slice(
162
-
currFacet.index.byteStart,
163
-
currFacet.index.byteEnd,
164
-
);
165
-
if (!subtext.trim()) {
166
-
// dont empty string entities
167
-
yield { text: subtext };
168
-
} else {
169
-
yield { text: subtext, facet: currFacet.features };
170
-
}
171
-
}
172
-
textCursor = currFacet.index.byteEnd;
173
-
facetCursor++;
174
-
} while (facetCursor < facets.length);
175
-
if (textCursor < this.unicodeText.length) {
176
-
yield {
177
-
text: this.unicodeText.slice(textCursor, this.unicodeText.length),
178
-
};
179
-
}
180
-
}
181
-
}
+1
-58
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
+1
-58
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
···
10
10
data,
11
11
uri,
12
12
comments_on_documents(*, bsky_profiles(*)),
13
-
documents_in_publications(publications(*,
14
-
documents_in_publications(documents(uri, data)),
15
-
publication_subscriptions(*))
16
-
),
13
+
documents_in_publications(publications(*, publication_subscriptions(*))),
17
14
document_mentions_in_bsky(*),
18
15
leaflets_in_publications(*)
19
16
`,
···
54
51
?.record as PubLeafletPublication.Record
55
52
)?.theme || (document?.data as PubLeafletDocument.Record)?.theme;
56
53
57
-
// Calculate prev/next documents from the fetched publication documents
58
-
let prevNext:
59
-
| {
60
-
prev?: { uri: string; title: string };
61
-
next?: { uri: string; title: string };
62
-
}
63
-
| undefined;
64
-
65
-
const currentPublishedAt = (document.data as PubLeafletDocument.Record)
66
-
?.publishedAt;
67
-
const allDocs =
68
-
document.documents_in_publications[0]?.publications
69
-
?.documents_in_publications;
70
-
71
-
if (currentPublishedAt && allDocs) {
72
-
// Filter and sort documents by publishedAt
73
-
const sortedDocs = allDocs
74
-
.map((dip) => ({
75
-
uri: dip?.documents?.uri,
76
-
title: (dip?.documents?.data as PubLeafletDocument.Record).title,
77
-
publishedAt: (dip?.documents?.data as PubLeafletDocument.Record)
78
-
.publishedAt,
79
-
}))
80
-
.filter((doc) => doc.publishedAt) // Only include docs with publishedAt
81
-
.sort(
82
-
(a, b) =>
83
-
new Date(a.publishedAt!).getTime() -
84
-
new Date(b.publishedAt!).getTime(),
85
-
);
86
-
87
-
// Find current document index
88
-
const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri);
89
-
90
-
if (currentIndex !== -1) {
91
-
prevNext = {
92
-
prev:
93
-
currentIndex > 0
94
-
? {
95
-
uri: sortedDocs[currentIndex - 1].uri || "",
96
-
title: sortedDocs[currentIndex - 1].title,
97
-
}
98
-
: undefined,
99
-
next:
100
-
currentIndex < sortedDocs.length - 1
101
-
? {
102
-
uri: sortedDocs[currentIndex + 1].uri || "",
103
-
title: sortedDocs[currentIndex + 1].title,
104
-
}
105
-
: undefined,
106
-
};
107
-
}
108
-
}
109
-
110
54
return {
111
55
...document,
112
56
quotesAndMentions,
113
57
theme,
114
-
prevNext,
115
58
};
116
59
}
117
60
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+25
-31
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
+25
-31
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
···
22
22
? true
23
23
: record.preferences.showComments,
24
24
);
25
-
let [showMentions, setShowMentions] = useState(
26
-
record?.preferences?.showMentions === undefined
27
-
? true
28
-
: record.preferences.showMentions,
29
-
);
30
-
let [showPrevNext, setShowPrevNext] = useState(
31
-
record?.preferences?.showPrevNext === undefined
32
-
? true
33
-
: record.preferences.showPrevNext,
34
-
);
25
+
let [showMentions, setShowMentions] = useState(true);
26
+
let [showPrevNext, setShowPrevNext] = useState(true);
35
27
36
28
let toast = useToaster();
37
29
return (
38
30
<form
39
31
onSubmit={async (e) => {
40
-
if (!pubData) return;
41
-
e.preventDefault();
42
-
props.setLoading(true);
43
-
let data = await updatePublication({
44
-
name: record.name,
45
-
uri: pubData.uri,
46
-
preferences: {
47
-
showInDiscover:
48
-
record?.preferences?.showInDiscover === undefined
49
-
? true
50
-
: record.preferences.showInDiscover,
51
-
showComments: showComments,
52
-
showMentions: showMentions,
53
-
showPrevNext: showPrevNext,
54
-
},
55
-
});
56
-
toast({ type: "success", content: <strong>Posts Updated!</strong> });
57
-
console.log(record.preferences?.showPrevNext);
58
-
props.setLoading(false);
59
-
mutate("publication-data");
32
+
// if (!pubData) return;
33
+
// e.preventDefault();
34
+
// props.setLoading(true);
35
+
// let data = await updatePublication({
36
+
// uri: pubData.uri,
37
+
// name: nameValue,
38
+
// description: descriptionValue,
39
+
// iconFile: iconFile,
40
+
// preferences: {
41
+
// showInDiscover: showInDiscover,
42
+
// showComments: showComments,
43
+
// },
44
+
// });
45
+
// toast({ type: "success", content: "Posts Updated!" });
46
+
// props.setLoading(false);
47
+
// mutate("publication-data");
60
48
}}
61
49
className="text-primary flex flex-col"
62
50
>
···
69
57
Post Options
70
58
</PubSettingsHeader>
71
59
<h4 className="mb-1">Layout</h4>
60
+
{/*<div>Max Post Width</div>*/}
72
61
<Toggle
73
62
toggle={showPrevNext}
74
63
onToggle={() => {
75
64
setShowPrevNext(!showPrevNext);
76
65
}}
77
66
>
78
-
<div className="font-bold">Show Prev/Next Buttons</div>
67
+
<div className="flex flex-col justify-start">
68
+
<div className="font-bold">Show Prev/Next Buttons</div>
69
+
<div className="text-tertiary text-sm leading-tight">
70
+
Show buttons that navigate to the previous and next posts
71
+
</div>
72
+
</div>
79
73
</Toggle>
80
74
<hr className="my-2 border-border-light" />
81
75
<h4 className="mb-1">Interactions</h4>
+2
-2
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
+2
-2
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
···
103
103
Theme and Layout
104
104
<ArrowRightTiny />
105
105
</button>
106
-
<button
106
+
{/*<button
107
107
className={menuItemClassName}
108
108
type="button"
109
109
onClick={() => props.setState("post-options")}
110
110
>
111
111
Post Options
112
112
<ArrowRightTiny />
113
-
</button>
113
+
</button>*/}
114
114
</div>
115
115
);
116
116
};
+7
-3
app/lish/[did]/[publication]/page.tsx
+7
-3
app/lish/[did]/[publication]/page.tsx
···
18
18
import { LocalizedDate } from "./LocalizedDate";
19
19
import { PublicationHomeLayout } from "./PublicationHomeLayout";
20
20
import { PublicationAuthor } from "./PublicationAuthor";
21
+
import { Separator } from "components/Layout";
21
22
22
23
export default async function Publication(props: {
23
24
params: Promise<{ publication: string; did: string }>;
···
147
148
</p>
148
149
</SpeedyLink>
149
150
150
-
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2">
151
+
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center">
151
152
<p className="text-sm text-tertiary ">
152
153
{doc_record.publishedAt && (
153
154
<LocalizedDate
···
160
161
/>
161
162
)}{" "}
162
163
</p>
163
-
{comments > 0 || quotes > 0 ? "| " : ""}
164
+
{comments > 0 || quotes > 0 || tags.length > 0 ? (
165
+
<Separator classname="h-4! mx-1" />
166
+
) : (
167
+
""
168
+
)}
164
169
<InteractionPreview
165
170
quotesCount={quotes}
166
171
commentsCount={comments}
167
172
tags={tags}
168
173
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
169
174
showComments={record?.preferences?.showComments}
170
-
showMentions={record?.preferences?.showMentions}
171
175
/>
172
176
</div>
173
177
</div>
+2
-9
app/lish/createPub/CreatePubForm.tsx
+2
-9
app/lish/createPub/CreatePubForm.tsx
···
53
53
description: descriptionValue,
54
54
iconFile: logoFile,
55
55
subdomain: domainValue,
56
-
preferences: {
57
-
showInDiscover,
58
-
showComments: true,
59
-
showMentions: true,
60
-
showPrevNext: false,
61
-
},
56
+
preferences: { showInDiscover, showComments: true },
62
57
});
63
58
64
59
if (!result.success) {
···
73
68
setTimeout(() => {
74
69
setFormState("normal");
75
70
if (result.publication)
76
-
router.push(
77
-
`${getBasePublicationURL(result.publication)}/dashboard`,
78
-
);
71
+
router.push(`${getBasePublicationURL(result.publication)}/dashboard`);
79
72
}, 500);
80
73
}}
81
74
>
+14
-19
app/lish/createPub/UpdatePubForm.tsx
+14
-19
app/lish/createPub/UpdatePubForm.tsx
···
21
21
import { Checkbox } from "components/Checkbox";
22
22
import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop";
23
23
import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings";
24
-
import { Toggle } from "components/Toggle";
25
24
26
25
export const EditPubForm = (props: {
27
26
backToMenuAction: () => void;
···
44
43
? true
45
44
: record.preferences.showComments,
46
45
);
47
-
let showMentions =
48
-
record?.preferences?.showMentions === undefined
49
-
? true
50
-
: record.preferences.showMentions;
51
-
let showPrevNext =
52
-
record?.preferences?.showPrevNext === undefined
53
-
? true
54
-
: record.preferences.showPrevNext;
55
-
56
46
let [descriptionValue, setDescriptionValue] = useState(
57
47
record?.description || "",
58
48
);
···
84
74
preferences: {
85
75
showInDiscover: showInDiscover,
86
76
showComments: showComments,
87
-
showMentions: showMentions,
88
-
showPrevNext: showPrevNext,
89
77
},
90
78
});
91
79
toast({ type: "success", content: "Updated!" });
···
102
90
General Settings
103
91
</PubSettingsHeader>
104
92
<div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2">
105
-
<div className="flex items-center justify-between gap-2 mt-2 ">
93
+
<div className="flex items-center justify-between gap-2 ">
106
94
<p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
107
95
Logo <span className="font-normal">(optional)</span>
108
96
</p>
···
172
160
<CustomDomainForm />
173
161
<hr className="border-border-light" />
174
162
175
-
<Toggle
176
-
toggle={showInDiscover}
177
-
onToggle={() => setShowInDiscover(!showInDiscover)}
163
+
<Checkbox
164
+
checked={showInDiscover}
165
+
onChange={(e) => setShowInDiscover(e.target.checked)}
178
166
>
179
-
<div className=" pt-0.5 flex flex-col text-sm text-tertiary ">
167
+
<div className=" pt-0.5 flex flex-col text-sm italic text-tertiary ">
180
168
<p className="font-bold">
181
169
Show In{" "}
182
170
<a href="/discover" target="_blank">
···
191
179
page. You can change this at any time!
192
180
</p>
193
181
</div>
194
-
</Toggle>
182
+
</Checkbox>
195
183
196
-
184
+
<Checkbox
185
+
checked={showComments}
186
+
onChange={(e) => setShowComments(e.target.checked)}
187
+
>
188
+
<div className=" pt-0.5 flex flex-col text-sm italic text-tertiary ">
189
+
<p className="font-bold">Show comments on posts</p>
190
+
</div>
191
+
</Checkbox>
197
192
</div>
198
193
</form>
199
194
);
+2
-2
app/lish/createPub/updatePublication.ts
+2
-2
app/lish/createPub/updatePublication.ts
···
25
25
}: {
26
26
uri: string;
27
27
name: string;
28
-
description?: string;
29
-
iconFile?: File | null;
28
+
description: string;
29
+
iconFile: File | null;
30
30
preferences?: Omit<PubLeafletPublication.Preferences, "$type">;
31
31
}): Promise<UpdatePublicationResult> {
32
32
let identity = await getIdentityData();
+2
-2
components/ActionBar/ActionButton.tsx
+2
-2
components/ActionBar/ActionButton.tsx
···
70
70
>
71
71
<div className="shrink-0">{icon}</div>
72
72
<div
73
-
className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`}
73
+
className={`flex flex-col pr-1 ${subtext && "leading-snug"} max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`}
74
74
>
75
-
<div className="truncate text-left pt-[1px]">{label}</div>
75
+
<div className="truncate text-left">{label}</div>
76
76
{subtext && (
77
77
<div className="text-xs text-tertiary font-normal text-left">
78
78
{subtext}
+1
-1
components/Blocks/Block.tsx
+1
-1
components/Blocks/Block.tsx
···
10
10
import { useHandleDrop } from "./useHandleDrop";
11
11
import { useEntitySetContext } from "components/EntitySetProvider";
12
12
13
-
import { TextBlock } from "components/Blocks/TextBlock";
13
+
import { TextBlock } from "./TextBlock/index";
14
14
import { ImageBlock } from "./ImageBlock";
15
15
import { PageLinkBlock } from "./PageLinkBlock";
16
16
import { ExternalLinkBlock } from "./ExternalLinkBlock";
+1
-1
components/Blocks/TextBlock/RenderYJSFragment.tsx
+1
-1
components/Blocks/TextBlock/RenderYJSFragment.tsx
···
8
8
import { Delta } from "src/utils/yjsFragmentToString";
9
9
import { ProfilePopover } from "components/ProfilePopover";
10
10
11
-
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
11
+
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p" | "small";
12
12
export function RenderYJSFragment({
13
13
value,
14
14
wrapper,
+18
-4
components/Blocks/TextBlock/index.tsx
+18
-4
components/Blocks/TextBlock/index.tsx
···
120
120
}) {
121
121
let initialFact = useEntity(props.entityID, "block/text");
122
122
let headingLevel = useEntity(props.entityID, "block/heading-level");
123
+
let textSize = useEntity(props.entityID, "block/text-size");
123
124
let alignment =
124
125
useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
125
126
let alignmentClass = {
···
128
129
center: "text-center",
129
130
justify: "text-justify",
130
131
}[alignment];
132
+
let textStyle =
133
+
textSize?.data.value === "small"
134
+
? "text-sm"
135
+
: textSize?.data.value === "large"
136
+
? "text-lg"
137
+
: "";
131
138
let { permissions } = useEntitySetContext();
132
139
133
140
let content = <br />;
···
159
166
className={`
160
167
${alignmentClass}
161
168
${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""}
162
-
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
169
+
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle}
163
170
w-full whitespace-pre-wrap outline-hidden ${props.className} `}
164
171
>
165
172
{content}
···
169
176
170
177
export function BaseTextBlock(props: BlockProps & { className?: string }) {
171
178
let headingLevel = useEntity(props.entityID, "block/heading-level");
179
+
let textSize = useEntity(props.entityID, "block/text-size");
172
180
let alignment =
173
181
useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
174
182
···
184
192
center: "text-center",
185
193
justify: "text-justify",
186
194
}[alignment];
195
+
let textStyle =
196
+
textSize?.data.value === "small"
197
+
? "text-sm text-secondary"
198
+
: textSize?.data.value === "large"
199
+
? "text-lg text-primary"
200
+
: "text-base text-primary";
187
201
188
202
let editorState = useEditorStates(
189
203
(s) => s.editorStates[props.entityID],
···
258
272
grow resize-none align-top whitespace-pre-wrap bg-transparent
259
273
outline-hidden
260
274
261
-
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
275
+
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle}
262
276
${props.className}`}
263
277
ref={mountRef}
264
278
/>
···
277
291
// if this is the only block on the page and is empty or is a canvas, show placeholder
278
292
<div
279
293
className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col
280
-
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
294
+
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle}
281
295
`}
282
296
>
283
297
{props.type === "text"
···
496
510
497
511
// Find the relative positioned parent container
498
512
const editorEl = view.dom;
499
-
const container = editorEl.closest('.relative') as HTMLElement | null;
513
+
const container = editorEl.closest(".relative") as HTMLElement | null;
500
514
501
515
if (container) {
502
516
const containerRect = container.getBoundingClientRect();
+14
components/Blocks/TextBlock/keymap.ts
+14
components/Blocks/TextBlock/keymap.ts
···
555
555
},
556
556
});
557
557
}
558
+
let [textSize] =
559
+
(await repRef.current?.query((tx) =>
560
+
scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"),
561
+
)) || [];
562
+
if (textSize) {
563
+
await repRef.current?.mutate.assertFact({
564
+
entity: newEntityID,
565
+
attribute: "block/text-size",
566
+
data: {
567
+
type: "text-size-union",
568
+
value: textSize.data.value,
569
+
},
570
+
});
571
+
}
558
572
};
559
573
asyncRun().then(() => {
560
574
useUIState.getState().setSelectedBlock({
+11
components/Blocks/TextBlock/useHandlePaste.ts
+11
components/Blocks/TextBlock/useHandlePaste.ts
···
299
299
},
300
300
});
301
301
}
302
+
let textSize = child.getAttribute("data-text-size");
303
+
if (textSize && ["default", "small", "large"].includes(textSize)) {
304
+
rep.mutate.assertFact({
305
+
entity: entityID,
306
+
attribute: "block/text-size",
307
+
data: {
308
+
type: "text-size-union",
309
+
value: textSize as "default" | "small" | "large",
310
+
},
311
+
});
312
+
}
302
313
if (child.tagName === "A") {
303
314
let href = child.getAttribute("href");
304
315
let dataType = child.getAttribute("data-type");
+3
-6
components/Canvas.tsx
+3
-6
components/Canvas.tsx
···
170
170
171
171
let pubRecord = pub.publications.record as PubLeafletPublication.Record;
172
172
let showComments = pubRecord.preferences?.showComments;
173
-
let showMentions = pubRecord.preferences?.showMentions;
174
173
175
174
return (
176
175
<div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
···
179
178
<CommentTiny className="text-border" /> โ
180
179
</div>
181
180
)}
182
-
{showComments && (
183
-
<div className="flex gap-1 text-tertiary items-center">
184
-
<QuoteTiny className="text-border" /> โ
185
-
</div>
186
-
)}
181
+
<div className="flex gap-1 text-tertiary items-center">
182
+
<QuoteTiny className="text-border" /> โ
183
+
</div>
187
184
188
185
{!props.isSubpage && (
189
186
<>
+2
-4
components/InteractionsPreview.tsx
+2
-4
components/InteractionsPreview.tsx
···
14
14
tags?: string[];
15
15
postUrl: string;
16
16
showComments: boolean | undefined;
17
-
showMentions: boolean | undefined;
18
-
19
17
share?: boolean;
20
18
}) => {
21
19
let smoker = useSmoker();
22
20
let interactionsAvailable =
23
-
(props.quotesCount > 0 && props.showMentions !== false) ||
21
+
props.quotesCount > 0 ||
24
22
(props.showComments !== false && props.commentsCount > 0);
25
23
26
24
const tagsCount = props.tags?.length || 0;
···
38
36
</>
39
37
)}
40
38
41
-
{props.showMentions === false || props.quotesCount === 0 ? null : (
39
+
{props.quotesCount === 0 ? null : (
42
40
<SpeedyLink
43
41
aria-label="Post quotes"
44
42
href={`${props.postUrl}?interactionDrawer=quotes`}
+3
-5
components/Pages/PublicationMetadata.tsx
+3
-5
components/Pages/PublicationMetadata.tsx
···
121
121
<Separator classname="h-4!" />
122
122
</>
123
123
)}
124
-
{pubRecord?.preferences?.showMentions && (
125
-
<div className="flex gap-1 items-center">
126
-
<QuoteTiny />โ
127
-
</div>
128
-
)}
124
+
<div className="flex gap-1 items-center">
125
+
<QuoteTiny />โ
126
+
</div>
129
127
{pubRecord?.preferences?.showComments && (
130
128
<div className="flex gap-1 items-center">
131
129
<CommentTiny />โ
-1
components/PostListing.tsx
-1
components/PostListing.tsx
+148
-1
components/SelectionManager/index.tsx
+148
-1
components/SelectionManager/index.tsx
···
89
89
},
90
90
{
91
91
metaKey: true,
92
+
altKey: true,
93
+
key: ["1", "ยก"],
94
+
handler: async () => {
95
+
let [sortedBlocks] = await getSortedSelectionBound();
96
+
for (let block of sortedBlocks) {
97
+
await rep?.mutate.assertFact({
98
+
entity: block.value,
99
+
attribute: "block/heading-level",
100
+
data: { type: "number", value: 1 },
101
+
});
102
+
await rep?.mutate.assertFact({
103
+
entity: block.value,
104
+
attribute: "block/type",
105
+
data: { type: "block-type-union", value: "heading" },
106
+
});
107
+
}
108
+
},
109
+
},
110
+
{
111
+
metaKey: true,
112
+
altKey: true,
113
+
key: ["2", "โข"],
114
+
handler: async () => {
115
+
let [sortedBlocks] = await getSortedSelectionBound();
116
+
for (let block of sortedBlocks) {
117
+
await rep?.mutate.assertFact({
118
+
entity: block.value,
119
+
attribute: "block/heading-level",
120
+
data: { type: "number", value: 2 },
121
+
});
122
+
await rep?.mutate.assertFact({
123
+
entity: block.value,
124
+
attribute: "block/type",
125
+
data: { type: "block-type-union", value: "heading" },
126
+
});
127
+
}
128
+
},
129
+
},
130
+
{
131
+
metaKey: true,
132
+
altKey: true,
133
+
key: ["3", "ยฃ"],
134
+
handler: async () => {
135
+
let [sortedBlocks] = await getSortedSelectionBound();
136
+
for (let block of sortedBlocks) {
137
+
await rep?.mutate.assertFact({
138
+
entity: block.value,
139
+
attribute: "block/heading-level",
140
+
data: { type: "number", value: 3 },
141
+
});
142
+
await rep?.mutate.assertFact({
143
+
entity: block.value,
144
+
attribute: "block/type",
145
+
data: { type: "block-type-union", value: "heading" },
146
+
});
147
+
}
148
+
},
149
+
},
150
+
{
151
+
metaKey: true,
152
+
altKey: true,
153
+
key: ["0", "ยบ"],
154
+
handler: async () => {
155
+
let [sortedBlocks] = await getSortedSelectionBound();
156
+
for (let block of sortedBlocks) {
157
+
// Convert to text block
158
+
await rep?.mutate.assertFact({
159
+
entity: block.value,
160
+
attribute: "block/type",
161
+
data: { type: "block-type-union", value: "text" },
162
+
});
163
+
// Remove heading level if exists
164
+
let headingLevel = await rep?.query((tx) =>
165
+
scanIndex(tx).eav(block.value, "block/heading-level"),
166
+
);
167
+
if (headingLevel?.[0]) {
168
+
await rep?.mutate.retractFact({ factID: headingLevel[0].id });
169
+
}
170
+
// Remove text-size to make it default
171
+
let textSizeFact = await rep?.query((tx) =>
172
+
scanIndex(tx).eav(block.value, "block/text-size"),
173
+
);
174
+
if (textSizeFact?.[0]) {
175
+
await rep?.mutate.retractFact({ factID: textSizeFact[0].id });
176
+
}
177
+
}
178
+
},
179
+
},
180
+
{
181
+
metaKey: true,
182
+
altKey: true,
183
+
key: ["+", "โ "],
184
+
handler: async () => {
185
+
let [sortedBlocks] = await getSortedSelectionBound();
186
+
for (let block of sortedBlocks) {
187
+
// Convert to text block
188
+
await rep?.mutate.assertFact({
189
+
entity: block.value,
190
+
attribute: "block/type",
191
+
data: { type: "block-type-union", value: "text" },
192
+
});
193
+
// Remove heading level if exists
194
+
let headingLevel = await rep?.query((tx) =>
195
+
scanIndex(tx).eav(block.value, "block/heading-level"),
196
+
);
197
+
if (headingLevel?.[0]) {
198
+
await rep?.mutate.retractFact({ factID: headingLevel[0].id });
199
+
}
200
+
// Set text size to large
201
+
await rep?.mutate.assertFact({
202
+
entity: block.value,
203
+
attribute: "block/text-size",
204
+
data: { type: "text-size-union", value: "large" },
205
+
});
206
+
}
207
+
},
208
+
},
209
+
{
210
+
metaKey: true,
211
+
altKey: true,
212
+
key: ["-", "โ"],
213
+
handler: async () => {
214
+
let [sortedBlocks] = await getSortedSelectionBound();
215
+
for (let block of sortedBlocks) {
216
+
// Convert to text block
217
+
await rep?.mutate.assertFact({
218
+
entity: block.value,
219
+
attribute: "block/type",
220
+
data: { type: "block-type-union", value: "text" },
221
+
});
222
+
// Remove heading level if exists
223
+
let headingLevel = await rep?.query((tx) =>
224
+
scanIndex(tx).eav(block.value, "block/heading-level"),
225
+
);
226
+
if (headingLevel?.[0]) {
227
+
await rep?.mutate.retractFact({ factID: headingLevel[0].id });
228
+
}
229
+
// Set text size to small
230
+
await rep?.mutate.assertFact({
231
+
entity: block.value,
232
+
attribute: "block/text-size",
233
+
data: { type: "text-size-union", value: "small" },
234
+
});
235
+
}
236
+
},
237
+
},
238
+
{
239
+
metaKey: true,
92
240
shift: true,
93
241
key: ["ArrowDown", "J"],
94
242
handler: async () => {
···
684
832
}
685
833
return null;
686
834
}
687
-
688
835
689
836
function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) {
690
837
let everyBlockHasMark = blocks.reduce((acc, block) => {
+3
-2
components/ThemeManager/ThemeSetter.tsx
+3
-2
components/ThemeManager/ThemeSetter.tsx
···
1
1
"use client";
2
2
import { Popover } from "components/Popover";
3
+
import { theme } from "../../tailwind.config";
3
4
4
5
import { Color } from "react-aria-components";
5
6
···
165
166
setOpenPicker={(pickers) => setOpenPicker(pickers)}
166
167
/>
167
168
<SectionArrow
168
-
fill="rgb(var(--accent-2))"
169
-
stroke="rgb(var(--accent-1))"
169
+
fill={theme.colors["accent-2"]}
170
+
stroke={theme.colors["accent-1"]}
170
171
className="ml-2"
171
172
/>
172
173
</div>
+9
-5
components/Toolbar/BlockToolbar.tsx
+9
-5
components/Toolbar/BlockToolbar.tsx
···
5
5
import { useUIState } from "src/useUIState";
6
6
import { LockBlockButton } from "./LockBlockButton";
7
7
import { TextAlignmentButton } from "./TextAlignmentToolbar";
8
-
import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } from "./ImageToolbar";
8
+
import {
9
+
ImageFullBleedButton,
10
+
ImageAltTextButton,
11
+
ImageCoverButton,
12
+
} from "./ImageToolbar";
9
13
import { DeleteSmall } from "components/Icons/DeleteSmall";
10
14
import { getSortedSelection } from "components/SelectionManager/selectionState";
11
15
···
37
41
>
38
42
<DeleteSmall />
39
43
</ToolbarButton>
40
-
<Separator classname="h-6" />
44
+
<Separator classname="h-6!" />
41
45
<MoveBlockButtons />
42
46
{blockType === "image" && (
43
47
<>
···
46
50
<ImageAltTextButton setToolbarState={props.setToolbarState} />
47
51
<ImageCoverButton />
48
52
{focusedEntityType?.data.value !== "canvas" && (
49
-
<Separator classname="h-6" />
53
+
<Separator classname="h-6!" />
50
54
)}
51
55
</>
52
56
)}
···
54
58
<>
55
59
<TextAlignmentButton setToolbarState={props.setToolbarState} />
56
60
{focusedEntityType?.data.value !== "canvas" && (
57
-
<Separator classname="h-6" />
61
+
<Separator classname="h-6!" />
58
62
)}
59
63
</>
60
64
)}
···
175
179
>
176
180
<MoveBlockDown />
177
181
</ToolbarButton>
178
-
<Separator classname="h-6" />
182
+
<Separator classname="h-6!" />
179
183
</>
180
184
);
181
185
};
+1
-1
components/Toolbar/HighlightToolbar.tsx
+1
-1
components/Toolbar/HighlightToolbar.tsx
+1
-1
components/Toolbar/InlineLinkToolbar.tsx
+1
-1
components/Toolbar/InlineLinkToolbar.tsx
+2
-2
components/Toolbar/ListToolbar.tsx
+2
-2
components/Toolbar/ListToolbar.tsx
···
131
131
>
132
132
<ListIndentIncreaseSmall />
133
133
</ToolbarButton>
134
-
<Separator classname="h-6" />
134
+
<Separator classname="h-6!" />
135
135
<ToolbarButton
136
136
disabled={!isList?.data.value}
137
137
tooltipContent=<div className="flex flex-col gap-1 justify-center">
138
138
<div className="text-center">Add a Checkbox</div>
139
139
<div className="flex gap-1 font-normal">
140
-
start line with <ShortcutKey>[</ShortcutKey>
140
+
<ShortcutKey>[</ShortcutKey>
141
141
<ShortcutKey>]</ShortcutKey>
142
142
</div>
143
143
</div>
+154
-95
components/Toolbar/TextBlockTypeToolbar.tsx
+154
-95
components/Toolbar/TextBlockTypeToolbar.tsx
···
4
4
Header3Small,
5
5
} from "components/Icons/BlockTextSmall";
6
6
import { Props } from "components/Icons/Props";
7
-
import { ShortcutKey } from "components/Layout";
7
+
import { ShortcutKey, Separator } from "components/Layout";
8
8
import { ToolbarButton } from "components/Toolbar";
9
9
import { TextSelection } from "prosemirror-state";
10
10
import { useCallback } from "react";
···
22
22
focusedBlock?.entityID || null,
23
23
"block/heading-level",
24
24
);
25
+
26
+
let textSize = useEntity(focusedBlock?.entityID || null, "block/text-size");
25
27
let { rep } = useReplicache();
26
28
27
29
let setLevel = useCallback(
···
51
53
);
52
54
return (
53
55
// This Toolbar should close once the user starts typing again
54
-
<div className="flex w-full justify-between items-center gap-4">
55
-
<div className="flex items-center gap-[6px]">
56
-
<ToolbarButton
57
-
className={props.className}
58
-
onClick={() => {
59
-
setLevel(1);
60
-
}}
61
-
active={
62
-
blockType?.data.value === "heading" &&
63
-
headingLevel?.data.value === 1
64
-
}
65
-
tooltipContent={
66
-
<div className="flex flex-col justify-center">
67
-
<div className="font-bold text-center">Title</div>
68
-
<div className="flex gap-1 font-normal">
69
-
start line with
70
-
<ShortcutKey>#</ShortcutKey>
71
-
</div>
56
+
<>
57
+
<ToolbarButton
58
+
className={props.className}
59
+
onClick={() => {
60
+
setLevel(1);
61
+
}}
62
+
active={
63
+
blockType?.data.value === "heading" && headingLevel?.data.value === 1
64
+
}
65
+
tooltipContent={
66
+
<div className="flex flex-col justify-center">
67
+
<div className="font-bold text-center">Title</div>
68
+
<div className="flex gap-1 font-normal">
69
+
start line with
70
+
<ShortcutKey>#</ShortcutKey>
71
+
</div>
72
+
</div>
73
+
}
74
+
>
75
+
<Header1Small />
76
+
</ToolbarButton>
77
+
<ToolbarButton
78
+
className={props.className}
79
+
onClick={() => {
80
+
setLevel(2);
81
+
}}
82
+
active={
83
+
blockType?.data.value === "heading" && headingLevel?.data.value === 2
84
+
}
85
+
tooltipContent={
86
+
<div className="flex flex-col justify-center">
87
+
<div className="font-bold text-center">Heading</div>
88
+
<div className="flex gap-1 font-normal">
89
+
start line with
90
+
<ShortcutKey>##</ShortcutKey>
72
91
</div>
73
-
}
74
-
>
75
-
<Header1Small />
76
-
</ToolbarButton>
77
-
<ToolbarButton
78
-
className={props.className}
79
-
onClick={() => {
80
-
setLevel(2);
81
-
}}
82
-
active={
83
-
blockType?.data.value === "heading" &&
84
-
headingLevel?.data.value === 2
85
-
}
86
-
tooltipContent={
87
-
<div className="flex flex-col justify-center">
88
-
<div className="font-bold text-center">Heading</div>
89
-
<div className="flex gap-1 font-normal">
90
-
start line with
91
-
<ShortcutKey>##</ShortcutKey>
92
-
</div>
92
+
</div>
93
+
}
94
+
>
95
+
<Header2Small />
96
+
</ToolbarButton>
97
+
<ToolbarButton
98
+
className={props.className}
99
+
onClick={() => {
100
+
setLevel(3);
101
+
}}
102
+
active={
103
+
blockType?.data.value === "heading" && headingLevel?.data.value === 3
104
+
}
105
+
tooltipContent={
106
+
<div className="flex flex-col justify-center">
107
+
<div className="font-bold text-center">Subheading</div>
108
+
<div className="flex gap-1 font-normal">
109
+
start line with
110
+
<ShortcutKey>###</ShortcutKey>
93
111
</div>
112
+
</div>
113
+
}
114
+
>
115
+
<Header3Small />
116
+
</ToolbarButton>
117
+
<Separator classname="h-6!" />
118
+
<ToolbarButton
119
+
className={`px-[6px] ${props.className}`}
120
+
onClick={async () => {
121
+
if (headingLevel)
122
+
await rep?.mutate.retractFact({ factID: headingLevel.id });
123
+
if (textSize) await rep?.mutate.retractFact({ factID: textSize.id });
124
+
if (!focusedBlock || !blockType) return;
125
+
if (blockType.data.value !== "text") {
126
+
let existingEditor =
127
+
useEditorStates.getState().editorStates[focusedBlock.entityID];
128
+
let selection = existingEditor?.editor.selection;
129
+
await rep?.mutate.assertFact({
130
+
entity: focusedBlock?.entityID,
131
+
attribute: "block/type",
132
+
data: { type: "block-type-union", value: "text" },
133
+
});
134
+
135
+
let newEditor =
136
+
useEditorStates.getState().editorStates[focusedBlock.entityID];
137
+
if (!newEditor || !selection) return;
138
+
newEditor.view?.dispatch(
139
+
newEditor.editor.tr.setSelection(
140
+
TextSelection.create(newEditor.editor.doc, selection.anchor),
141
+
),
142
+
);
143
+
144
+
newEditor.view?.focus();
94
145
}
95
-
>
96
-
<Header2Small />
97
-
</ToolbarButton>
98
-
<ToolbarButton
99
-
className={props.className}
100
-
onClick={() => {
101
-
setLevel(3);
102
-
}}
103
-
active={
104
-
blockType?.data.value === "heading" &&
105
-
headingLevel?.data.value === 3
106
-
}
107
-
tooltipContent={
108
-
<div className="flex flex-col justify-center">
109
-
<div className="font-bold text-center">Subheading</div>
110
-
<div className="flex gap-1 font-normal">
111
-
start line with
112
-
<ShortcutKey>###</ShortcutKey>
113
-
</div>
114
-
</div>
146
+
}}
147
+
active={
148
+
blockType?.data.value === "text" &&
149
+
textSize?.data.value !== "small" &&
150
+
textSize?.data.value !== "large"
151
+
}
152
+
tooltipContent={<div>Normal Text</div>}
153
+
>
154
+
Text
155
+
</ToolbarButton>
156
+
<ToolbarButton
157
+
className={`px-[6px] text-lg ${props.className}`}
158
+
onClick={async () => {
159
+
if (!focusedBlock || !blockType) return;
160
+
if (blockType.data.value !== "text") {
161
+
// Convert to text block first if it's a heading
162
+
if (headingLevel)
163
+
await rep?.mutate.retractFact({ factID: headingLevel.id });
164
+
await rep?.mutate.assertFact({
165
+
entity: focusedBlock.entityID,
166
+
attribute: "block/type",
167
+
data: { type: "block-type-union", value: "text" },
168
+
});
115
169
}
116
-
>
117
-
<Header3Small />
118
-
</ToolbarButton>
119
-
<ToolbarButton
120
-
className={`px-[6px] ${props.className}`}
121
-
onClick={async () => {
170
+
// Set text size to large
171
+
await rep?.mutate.assertFact({
172
+
entity: focusedBlock.entityID,
173
+
attribute: "block/text-size",
174
+
data: { type: "text-size-union", value: "large" },
175
+
});
176
+
}}
177
+
active={
178
+
blockType?.data.value === "text" && textSize?.data.value === "large"
179
+
}
180
+
tooltipContent={<div>Large Text</div>}
181
+
>
182
+
<div className="leading-[1.625rem]">Large</div>
183
+
</ToolbarButton>
184
+
<ToolbarButton
185
+
className={`px-[6px] text-sm text-secondary ${props.className}`}
186
+
onClick={async () => {
187
+
if (!focusedBlock || !blockType) return;
188
+
if (blockType.data.value !== "text") {
189
+
// Convert to text block first if it's a heading
122
190
if (headingLevel)
123
191
await rep?.mutate.retractFact({ factID: headingLevel.id });
124
-
if (!focusedBlock || !blockType) return;
125
-
if (blockType.data.value !== "text") {
126
-
let existingEditor =
127
-
useEditorStates.getState().editorStates[focusedBlock.entityID];
128
-
let selection = existingEditor?.editor.selection;
129
-
await rep?.mutate.assertFact({
130
-
entity: focusedBlock?.entityID,
131
-
attribute: "block/type",
132
-
data: { type: "block-type-union", value: "text" },
133
-
});
134
-
135
-
let newEditor =
136
-
useEditorStates.getState().editorStates[focusedBlock.entityID];
137
-
if (!newEditor || !selection) return;
138
-
newEditor.view?.dispatch(
139
-
newEditor.editor.tr.setSelection(
140
-
TextSelection.create(newEditor.editor.doc, selection.anchor),
141
-
),
142
-
);
143
-
144
-
newEditor.view?.focus();
145
-
}
146
-
}}
147
-
active={blockType?.data.value === "text"}
148
-
tooltipContent={<div>Paragraph</div>}
149
-
>
150
-
Paragraph
151
-
</ToolbarButton>
152
-
</div>
153
-
</div>
192
+
await rep?.mutate.assertFact({
193
+
entity: focusedBlock.entityID,
194
+
attribute: "block/type",
195
+
data: { type: "block-type-union", value: "text" },
196
+
});
197
+
}
198
+
// Set text size to small
199
+
await rep?.mutate.assertFact({
200
+
entity: focusedBlock.entityID,
201
+
attribute: "block/text-size",
202
+
data: { type: "text-size-union", value: "small" },
203
+
});
204
+
}}
205
+
active={
206
+
blockType?.data.value === "text" && textSize?.data.value === "small"
207
+
}
208
+
tooltipContent={<div>Small Text</div>}
209
+
>
210
+
<div className="leading-[1.625rem]">Small</div>
211
+
</ToolbarButton>
212
+
</>
154
213
);
155
214
};
156
215
+3
-3
components/Toolbar/TextToolbar.tsx
+3
-3
components/Toolbar/TextToolbar.tsx
···
74
74
lastUsedHighlight={props.lastUsedHighlight}
75
75
setToolbarState={props.setToolbarState}
76
76
/>
77
-
<Separator classname="h-6" />
77
+
<Separator classname="h-6!" />
78
78
<LinkButton setToolbarState={props.setToolbarState} />
79
-
<Separator classname="h-6" />
79
+
<Separator classname="h-6!" />
80
80
<TextBlockTypeButton setToolbarState={props.setToolbarState} />
81
81
<TextAlignmentButton setToolbarState={props.setToolbarState} />
82
82
<ListButton setToolbarState={props.setToolbarState} />
83
-
<Separator classname="h-6" />
83
+
<Separator classname="h-6!" />
84
84
85
85
<LockBlockButton />
86
86
</>
+2
-2
components/utils/DotLoader.tsx
+2
-2
components/utils/DotLoader.tsx
···
1
1
import { useEffect, useState } from "react";
2
2
3
-
export function DotLoader() {
3
+
export function DotLoader(props: { className?: string }) {
4
4
let [dots, setDots] = useState(1);
5
5
useEffect(() => {
6
6
let id = setInterval(() => {
···
11
11
};
12
12
}, []);
13
13
return (
14
-
<div className="w-[26px] h-[24px] text-center text-sm">
14
+
<div className={`w-[26px] h-[24px] text-center text-sm ${props.className}`}>
15
15
{".".repeat(dots) + "\u00a0".repeat(3 - dots)}
16
16
</div>
17
17
);
+4
-8
lexicons/api/lexicons.ts
+4
-8
lexicons/api/lexicons.ts
···
1246
1246
plaintext: {
1247
1247
type: 'string',
1248
1248
},
1249
+
textSize: {
1250
+
type: 'string',
1251
+
enum: ['default', 'small', 'large'],
1252
+
},
1249
1253
facets: {
1250
1254
type: 'array',
1251
1255
items: {
···
1803
1807
default: true,
1804
1808
},
1805
1809
showComments: {
1806
-
type: 'boolean',
1807
-
default: true,
1808
-
},
1809
-
showMentions: {
1810
-
type: 'boolean',
1811
-
default: true,
1812
-
},
1813
-
showPrevNext: {
1814
1810
type: 'boolean',
1815
1811
default: true,
1816
1812
},
+1
lexicons/api/types/pub/leaflet/blocks/text.ts
+1
lexicons/api/types/pub/leaflet/blocks/text.ts
-2
lexicons/api/types/pub/leaflet/publication.ts
-2
lexicons/api/types/pub/leaflet/publication.ts
+8
lexicons/pub/leaflet/blocks/text.json
+8
lexicons/pub/leaflet/blocks/text.json
-8
lexicons/pub/leaflet/publication.json
-8
lexicons/pub/leaflet/publication.json
+1
lexicons/src/blocks.ts
+1
lexicons/src/blocks.ts
-2
lexicons/src/publication.ts
-2
lexicons/src/publication.ts
+8
src/replicache/attributes.ts
+8
src/replicache/attributes.ts
···
71
71
type: "number",
72
72
cardinality: "one",
73
73
},
74
+
"block/text-size": {
75
+
type: "text-size-union",
76
+
cardinality: "one",
77
+
},
74
78
"block/image": {
75
79
type: "image",
76
80
cardinality: "one",
···
321
325
"text-alignment-type-union": {
322
326
type: "text-alignment-type-union";
323
327
value: "right" | "left" | "center" | "justify";
328
+
};
329
+
"text-size-union": {
330
+
type: "text-size-union";
331
+
value: "default" | "small" | "large";
324
332
};
325
333
"page-type-union": { type: "page-type-union"; value: "doc" | "canvas" };
326
334
"block-type-union": {
+3
src/utils/getBlocksAsHTML.tsx
+3
src/utils/getBlocksAsHTML.tsx
···
171
171
},
172
172
text: async (b, tx, a) => {
173
173
let [value] = await scanIndex(tx).eav(b.value, "block/text");
174
+
let [textSize] = await scanIndex(tx).eav(b.value, "block/text-size");
175
+
174
176
return (
175
177
<RenderYJSFragment
176
178
value={value?.data.value}
177
179
attrs={{
178
180
"data-alignment": a,
181
+
"data-text-size": textSize?.data.value,
179
182
}}
180
183
wrapper="p"
181
184
/>