+14
-12
actions/deleteLeaflet.ts
+14
-12
actions/deleteLeaflet.ts
···
53
53
}
54
54
55
55
// Check if there's a standalone published document
56
-
const leafletDoc = tokenData.leaflets_to_documents;
57
-
if (leafletDoc && leafletDoc.document) {
58
-
if (!identity || !identity.atp_did) {
56
+
const leafletDocs = tokenData.leaflets_to_documents || [];
57
+
if (leafletDocs.length > 0) {
58
+
if (!identity) {
59
59
throw new Error(
60
60
"Unauthorized: You must be logged in to delete a published leaflet",
61
61
);
62
62
}
63
-
const docUri = leafletDoc.documents?.uri;
64
-
// Extract the DID from the document URI (format: at://did:plc:xxx/...)
65
-
if (docUri && !docUri.includes(identity.atp_did)) {
66
-
throw new Error(
67
-
"Unauthorized: You must own the published document to delete this leaflet",
68
-
);
63
+
for (let leafletDoc of leafletDocs) {
64
+
const docUri = leafletDoc.documents?.uri;
65
+
// Extract the DID from the document URI (format: at://did:plc:xxx/...)
66
+
if (docUri && identity.atp_did && !docUri.includes(identity.atp_did)) {
67
+
throw new Error(
68
+
"Unauthorized: You must own the published document to delete this leaflet",
69
+
);
70
+
}
69
71
}
70
72
}
71
73
}
···
81
83
.where(eq(permission_tokens.id, permission_token.id));
82
84
83
85
if (!token?.permission_token_rights?.write) return;
84
-
const entitySet = token.permission_token_rights.entity_set;
85
-
if (!entitySet) return;
86
-
await tx.delete(entities).where(eq(entities.set, entitySet));
86
+
await tx
87
+
.delete(entities)
88
+
.where(eq(entities.set, token.permission_token_rights.entity_set));
87
89
await tx
88
90
.delete(permission_tokens)
89
91
.where(eq(permission_tokens.id, permission_token.id));
-3
actions/publications/moveLeafletToPublication.ts
-3
actions/publications/moveLeafletToPublication.ts
···
11
11
) {
12
12
let identity = await getIdentityData();
13
13
if (!identity || !identity.atp_did) return null;
14
-
15
-
// Verify publication ownership
16
14
let { data: publication } = await supabaseServerClient
17
15
.from("publications")
18
16
.select("*")
···
20
18
.single();
21
19
if (publication?.identity_did !== identity.atp_did) return;
22
20
23
-
// Save as a publication draft
24
21
await supabaseServerClient.from("leaflets_in_publications").insert({
25
22
publication: publication_uri,
26
23
leaflet: leaflet_id,
-26
actions/publications/saveLeafletDraft.ts
-26
actions/publications/saveLeafletDraft.ts
···
1
-
"use server";
2
-
3
-
import { getIdentityData } from "actions/getIdentityData";
4
-
import { supabaseServerClient } from "supabase/serverClient";
5
-
6
-
export async function saveLeafletDraft(
7
-
leaflet_id: string,
8
-
metadata: { title: string; description: string },
9
-
entitiesToDelete: string[],
10
-
) {
11
-
let identity = await getIdentityData();
12
-
if (!identity || !identity.atp_did) return null;
13
-
14
-
// Save as a looseleaf draft in leaflets_to_documents with null document
15
-
await supabaseServerClient.from("leaflets_to_documents").upsert({
16
-
leaflet: leaflet_id,
17
-
document: null,
18
-
title: metadata.title,
19
-
description: metadata.description,
20
-
});
21
-
22
-
await supabaseServerClient
23
-
.from("entities")
24
-
.delete()
25
-
.in("id", entitiesToDelete);
26
-
}
+193
-19
actions/publishToPublication.ts
+193
-19
actions/publishToPublication.ts
···
32
32
import { scanIndexLocal } from "src/replicache/utils";
33
33
import type { Fact } from "src/replicache";
34
34
import type { Attribute } from "src/replicache/attributes";
35
-
import {
36
-
Delta,
37
-
YJSFragmentToString,
38
-
} from "components/Blocks/TextBlock/RenderYJSFragment";
35
+
import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString";
39
36
import { ids } from "lexicons/api/lexicons";
40
37
import { BlobRef } from "@atproto/lexicon";
41
38
import { AtUri } from "@atproto/syntax";
···
50
47
ColorToRGBA,
51
48
} from "components/ThemeManager/colorToLexicons";
52
49
import { parseColor } from "@react-stately/color";
50
+
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
51
+
import { v7 } from "uuid";
53
52
54
53
export async function publishToPublication({
55
54
root_entity,
···
57
56
leaflet_id,
58
57
title,
59
58
description,
59
+
tags,
60
60
entitiesToDelete,
61
61
}: {
62
62
root_entity: string;
···
64
64
leaflet_id: string;
65
65
title?: string;
66
66
description?: string;
67
+
tags?: string[];
67
68
entitiesToDelete?: string[];
68
69
}) {
69
70
const oauthClient = await createOauthClient();
···
143
144
...(theme && { theme }),
144
145
title: title || "Untitled",
145
146
description: description || "",
147
+
...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags)
146
148
pages: pages.map((p) => {
147
149
if (p.type === "canvas") {
148
150
return {
···
210
212
}
211
213
}
212
214
215
+
// Create notifications for mentions (only on first publish)
216
+
if (!existingDocUri) {
217
+
await createMentionNotifications(result.uri, record, credentialSession.did!);
218
+
}
219
+
213
220
return { rkey, record: JSON.parse(JSON.stringify(record)) };
214
221
}
215
222
···
298
305
if (!b) return [];
299
306
let block: PubLeafletPagesLinearDocument.Block = {
300
307
$type: "pub.leaflet.pages.linearDocument#block",
301
-
alignment,
302
308
block: b,
303
309
};
310
+
if (alignment) block.alignment = alignment;
304
311
return [block];
305
312
} else {
306
313
let block: PubLeafletPagesLinearDocument.Block = {
···
342
349
Y.applyUpdate(doc, update);
343
350
let nodes = doc.getXmlElement("prosemirror").toArray();
344
351
let stringValue = YJSFragmentToString(nodes[0]);
345
-
let facets = YJSFragmentToFacets(nodes[0]);
352
+
let { facets } = YJSFragmentToFacets(nodes[0]);
346
353
return [stringValue, facets] as const;
347
354
};
348
355
if (b.type === "card") {
···
398
405
let [stringValue, facets] = getBlockContent(b.value);
399
406
let block: $Typed<PubLeafletBlocksHeader.Main> = {
400
407
$type: "pub.leaflet.blocks.header",
401
-
level: headingLevel?.data.value || 1,
408
+
level: Math.floor(headingLevel?.data.value || 1),
402
409
plaintext: stringValue,
403
410
facets,
404
411
};
···
431
438
let block: $Typed<PubLeafletBlocksIframe.Main> = {
432
439
$type: "pub.leaflet.blocks.iframe",
433
440
url: url.data.value,
434
-
height: height?.data.value || 600,
441
+
height: Math.floor(height?.data.value || 600),
435
442
};
436
443
return block;
437
444
}
···
445
452
$type: "pub.leaflet.blocks.image",
446
453
image: blobref,
447
454
aspectRatio: {
448
-
height: image.data.height,
449
-
width: image.data.width,
455
+
height: Math.floor(image.data.height),
456
+
width: Math.floor(image.data.width),
450
457
},
451
458
alt: altText ? altText.data.value : undefined,
452
459
};
···
603
610
604
611
function YJSFragmentToFacets(
605
612
node: Y.XmlElement | Y.XmlText | Y.XmlHook,
606
-
): PubLeafletRichtextFacet.Main[] {
613
+
byteOffset: number = 0,
614
+
): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } {
607
615
if (node.constructor === Y.XmlElement) {
608
-
return node
609
-
.toArray()
610
-
.map((f) => YJSFragmentToFacets(f))
611
-
.flat();
616
+
// Handle inline mention nodes
617
+
if (node.nodeName === "didMention") {
618
+
const text = node.getAttribute("text") || "";
619
+
const unicodestring = new UnicodeString(text);
620
+
const facet: PubLeafletRichtextFacet.Main = {
621
+
index: {
622
+
byteStart: byteOffset,
623
+
byteEnd: byteOffset + unicodestring.length,
624
+
},
625
+
features: [
626
+
{
627
+
$type: "pub.leaflet.richtext.facet#didMention",
628
+
did: node.getAttribute("did"),
629
+
},
630
+
],
631
+
};
632
+
return { facets: [facet], byteLength: unicodestring.length };
633
+
}
634
+
635
+
if (node.nodeName === "atMention") {
636
+
const text = node.getAttribute("text") || "";
637
+
const unicodestring = new UnicodeString(text);
638
+
const facet: PubLeafletRichtextFacet.Main = {
639
+
index: {
640
+
byteStart: byteOffset,
641
+
byteEnd: byteOffset + unicodestring.length,
642
+
},
643
+
features: [
644
+
{
645
+
$type: "pub.leaflet.richtext.facet#atMention",
646
+
atURI: node.getAttribute("atURI"),
647
+
},
648
+
],
649
+
};
650
+
return { facets: [facet], byteLength: unicodestring.length };
651
+
}
652
+
653
+
if (node.nodeName === "hard_break") {
654
+
const unicodestring = new UnicodeString("\n");
655
+
return { facets: [], byteLength: unicodestring.length };
656
+
}
657
+
658
+
// For other elements (like paragraph), process children
659
+
let allFacets: PubLeafletRichtextFacet.Main[] = [];
660
+
let currentOffset = byteOffset;
661
+
for (const child of node.toArray()) {
662
+
const result = YJSFragmentToFacets(child, currentOffset);
663
+
allFacets.push(...result.facets);
664
+
currentOffset += result.byteLength;
665
+
}
666
+
return { facets: allFacets, byteLength: currentOffset - byteOffset };
612
667
}
668
+
613
669
if (node.constructor === Y.XmlText) {
614
670
let facets: PubLeafletRichtextFacet.Main[] = [];
615
671
let delta = node.toDelta() as Delta[];
616
-
let byteStart = 0;
672
+
let byteStart = byteOffset;
673
+
let totalLength = 0;
617
674
for (let d of delta) {
618
675
let unicodestring = new UnicodeString(d.insert);
619
676
let facet: PubLeafletRichtextFacet.Main = {
···
646
703
});
647
704
if (facet.features.length > 0) facets.push(facet);
648
705
byteStart += unicodestring.length;
706
+
totalLength += unicodestring.length;
649
707
}
650
-
return facets;
708
+
return { facets, byteLength: totalLength };
651
709
}
652
-
return [];
710
+
return { facets: [], byteLength: 0 };
653
711
}
654
712
655
713
type ExcludeString<T> = T extends string
···
712
770
image: blob.data.blob,
713
771
repeat: backgroundImageRepeat?.data.value ? true : false,
714
772
...(backgroundImageRepeat?.data.value && {
715
-
width: backgroundImageRepeat.data.value,
773
+
width: Math.floor(backgroundImageRepeat.data.value),
716
774
}),
717
775
};
718
776
}
···
725
783
726
784
return undefined;
727
785
}
786
+
787
+
/**
788
+
* Extract mentions from a published document and create notifications
789
+
*/
790
+
async function createMentionNotifications(
791
+
documentUri: string,
792
+
record: PubLeafletDocument.Record,
793
+
authorDid: string,
794
+
) {
795
+
const mentionedDids = new Set<string>();
796
+
const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
797
+
const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
798
+
799
+
// Extract mentions from all text blocks in all pages
800
+
for (const page of record.pages) {
801
+
if (page.$type === "pub.leaflet.pages.linearDocument") {
802
+
const linearPage = page as PubLeafletPagesLinearDocument.Main;
803
+
for (const blockWrapper of linearPage.blocks) {
804
+
const block = blockWrapper.block;
805
+
if (block.$type === "pub.leaflet.blocks.text") {
806
+
const textBlock = block as PubLeafletBlocksText.Main;
807
+
if (textBlock.facets) {
808
+
for (const facet of textBlock.facets) {
809
+
for (const feature of facet.features) {
810
+
// Check for DID mentions
811
+
if (PubLeafletRichtextFacet.isDidMention(feature)) {
812
+
if (feature.did !== authorDid) {
813
+
mentionedDids.add(feature.did);
814
+
}
815
+
}
816
+
// Check for AT URI mentions (publications and documents)
817
+
if (PubLeafletRichtextFacet.isAtMention(feature)) {
818
+
const uri = new AtUri(feature.atURI);
819
+
820
+
if (uri.collection === "pub.leaflet.publication") {
821
+
// Get the publication owner's DID
822
+
const { data: publication } = await supabaseServerClient
823
+
.from("publications")
824
+
.select("identity_did")
825
+
.eq("uri", feature.atURI)
826
+
.single();
827
+
828
+
if (publication && publication.identity_did !== authorDid) {
829
+
mentionedPublications.set(publication.identity_did, feature.atURI);
830
+
}
831
+
} else if (uri.collection === "pub.leaflet.document") {
832
+
// Get the document owner's DID
833
+
const { data: document } = await supabaseServerClient
834
+
.from("documents")
835
+
.select("uri, data")
836
+
.eq("uri", feature.atURI)
837
+
.single();
838
+
839
+
if (document) {
840
+
const docRecord = document.data as PubLeafletDocument.Record;
841
+
if (docRecord.author !== authorDid) {
842
+
mentionedDocuments.set(docRecord.author, feature.atURI);
843
+
}
844
+
}
845
+
}
846
+
}
847
+
}
848
+
}
849
+
}
850
+
}
851
+
}
852
+
}
853
+
}
854
+
855
+
// Create notifications for DID mentions
856
+
for (const did of mentionedDids) {
857
+
const notification: Notification = {
858
+
id: v7(),
859
+
recipient: did,
860
+
data: {
861
+
type: "mention",
862
+
document_uri: documentUri,
863
+
mention_type: "did",
864
+
},
865
+
};
866
+
await supabaseServerClient.from("notifications").insert(notification);
867
+
await pingIdentityToUpdateNotification(did);
868
+
}
869
+
870
+
// Create notifications for publication mentions
871
+
for (const [recipientDid, publicationUri] of mentionedPublications) {
872
+
const notification: Notification = {
873
+
id: v7(),
874
+
recipient: recipientDid,
875
+
data: {
876
+
type: "mention",
877
+
document_uri: documentUri,
878
+
mention_type: "publication",
879
+
mentioned_uri: publicationUri,
880
+
},
881
+
};
882
+
await supabaseServerClient.from("notifications").insert(notification);
883
+
await pingIdentityToUpdateNotification(recipientDid);
884
+
}
885
+
886
+
// Create notifications for document mentions
887
+
for (const [recipientDid, mentionedDocUri] of mentionedDocuments) {
888
+
const notification: Notification = {
889
+
id: v7(),
890
+
recipient: recipientDid,
891
+
data: {
892
+
type: "mention",
893
+
document_uri: documentUri,
894
+
mention_type: "document",
895
+
mentioned_uri: mentionedDocUri,
896
+
},
897
+
};
898
+
await supabaseServerClient.from("notifications").insert(notification);
899
+
await pingIdentityToUpdateNotification(recipientDid);
900
+
}
901
+
}
+25
actions/searchTags.ts
+25
actions/searchTags.ts
···
1
+
"use server";
2
+
import { supabaseServerClient } from "supabase/serverClient";
3
+
4
+
export type TagSearchResult = {
5
+
name: string;
6
+
document_count: number;
7
+
};
8
+
9
+
export async function searchTags(
10
+
query: string,
11
+
): Promise<TagSearchResult[] | null> {
12
+
const searchQuery = query.trim().toLowerCase();
13
+
14
+
// Use raw SQL query to extract and aggregate tags
15
+
const { data, error } = await supabaseServerClient.rpc("search_tags", {
16
+
search_query: searchQuery,
17
+
});
18
+
19
+
if (error) {
20
+
console.error("Error searching tags:", error);
21
+
return null;
22
+
}
23
+
24
+
return data;
25
+
}
+1
-1
actions/subscriptions/subscribeToMailboxWithEmail.ts
+1
-1
actions/subscriptions/subscribeToMailboxWithEmail.ts
···
11
11
import type { Attribute } from "src/replicache/attributes";
12
12
import { Database } from "supabase/database.types";
13
13
import * as Y from "yjs";
14
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
14
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
15
15
import { pool } from "supabase/pool";
16
16
17
17
let supabase = createServerClient<Database>(
+1
app/(home-pages)/discover/PubListing.tsx
+1
app/(home-pages)/discover/PubListing.tsx
···
1
1
"use client";
2
2
import { AtUri } from "@atproto/syntax";
3
3
import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions";
4
+
import { SubscribeWithBluesky } from "app/lish/Subscribe";
4
5
import { PubIcon } from "components/ActionBar/Publications";
5
6
import { Separator } from "components/Layout";
6
7
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
+7
-92
app/(home-pages)/home/Actions/CreateNewButton.tsx
+7
-92
app/(home-pages)/home/Actions/CreateNewButton.tsx
···
1
1
"use client";
2
2
3
-
import { Action } from "@vercel/sdk/esm/models/userevent";
4
3
import { createNewLeaflet } from "actions/createNewLeaflet";
5
4
import { ActionButton } from "components/ActionBar/ActionButton";
6
5
import { AddTiny } from "components/Icons/AddTiny";
7
-
import { ArrowDownTiny } from "components/Icons/ArrowDownTiny";
8
6
import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";
9
7
import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
10
-
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
11
-
import { Menu, MenuItem, Separator } from "components/Layout";
8
+
import { Menu, MenuItem } from "components/Layout";
12
9
import { useIsMobile } from "src/hooks/isMobile";
13
-
import { useIdentityData } from "components/IdentityProvider";
14
-
import { PubIcon } from "components/ActionBar/Publications";
15
-
import { PubLeafletPublication } from "lexicons/api";
16
-
import { createPublicationDraft } from "actions/createPublicationDraft";
17
-
import { useRouter } from "next/navigation";
18
-
import Link from "next/link";
19
10
20
11
export const CreateNewLeafletButton = (props: {}) => {
21
12
let isMobile = useIsMobile();
···
27
18
}
28
19
};
29
20
return (
30
-
<div className="flex gap-0 flex-row w-full">
31
-
<ActionButton
32
-
id="new-leaflet-button"
33
-
primary
34
-
icon=<AddTiny className="m-1 shrink-0" />
35
-
label="New"
36
-
className="grow rounded-r-none sm:ml-0! sm:mr-0! ml-1! mr-0!"
37
-
onClick={async () => {
38
-
let id = await createNewLeaflet({
39
-
pageType: "doc",
40
-
redirectUser: false,
41
-
});
42
-
openNewLeaflet(id);
43
-
}}
44
-
/>
45
-
<Separator />
46
-
<CreateNewMoreOptionsButton />
47
-
</div>
48
-
);
49
-
};
50
-
51
-
export const CreateNewMoreOptionsButton = (props: {}) => {
52
-
let { identity } = useIdentityData();
53
-
54
-
let isMobile = useIsMobile();
55
-
let openNewLeaflet = (id: string) => {
56
-
if (isMobile) {
57
-
window.location.href = `/${id}?focusFirstBlock`;
58
-
} else {
59
-
window.open(`/${id}?focusFirstBlock`, "_blank");
60
-
}
61
-
};
62
-
63
-
return (
64
21
<Menu
65
22
asChild
66
23
side={isMobile ? "top" : "right"}
67
24
align={isMobile ? "center" : "start"}
68
-
className="py-2"
69
25
trigger={
70
26
<ActionButton
71
-
id="new-leaflet-more-options"
27
+
id="new-leaflet-button"
72
28
primary
73
-
icon=<ArrowDownTiny className="m-1 shrink-0 sm:-rotate-90 rotate-180" />
74
-
className="shrink-0 rounded-l-none w-[34px]! sm:mr-0! sm:ml-0! mr-1! ml-0!"
29
+
icon=<AddTiny className="m-1 shrink-0" />
30
+
label="New"
75
31
/>
76
32
}
77
33
>
78
34
<MenuItem
79
-
className="leading-snug"
80
35
onSelect={async () => {
81
36
let id = await createNewLeaflet({
82
37
pageType: "doc",
···
85
40
openNewLeaflet(id);
86
41
}}
87
42
>
88
-
<BlockDocPageSmall />
43
+
<BlockDocPageSmall />{" "}
89
44
<div className="flex flex-col">
90
-
<div>Doc</div>
45
+
<div>New Doc</div>
91
46
<div className="text-tertiary text-sm font-normal">
92
47
A good ol' text document
93
48
</div>
94
49
</div>
95
50
</MenuItem>
96
51
<MenuItem
97
-
className="leading-snug"
98
52
onSelect={async () => {
99
53
let id = await createNewLeaflet({
100
54
pageType: "canvas",
···
105
59
>
106
60
<BlockCanvasPageSmall />
107
61
<div className="flex flex-col">
108
-
Canvas
62
+
New Canvas
109
63
<div className="text-tertiary text-sm font-normal">
110
64
A digital whiteboard
111
65
</div>
112
66
</div>
113
67
</MenuItem>
114
-
{identity && identity.atp_did && (
115
-
<>
116
-
<hr className="border-border-light mt-2 mb-1 -mx-1" />
117
-
<div className="mx-2 text-sm text-tertiary font-bold">
118
-
AT Proto Draft
119
-
</div>
120
-
<MenuItem className="leading-snug" onSelect={async () => {}}>
121
-
<LooseLeafSmall />
122
-
<div className="flex flex-col">
123
-
Looseleaf
124
-
<div className="text-tertiary text-sm font-normal">
125
-
A one off post on AT Proto
126
-
</div>
127
-
</div>
128
-
</MenuItem>
129
-
{identity?.publications && identity.publications.length > 0 && (
130
-
<>
131
-
<hr className="border-border-light border-dashed mx-2 my-0.5" />
132
-
{identity?.publications.map((pub) => {
133
-
let router = useRouter();
134
-
return (
135
-
<MenuItem
136
-
onSelect={async () => {
137
-
let newLeaflet = await createPublicationDraft(pub.uri);
138
-
router.push(`/${newLeaflet}`);
139
-
}}
140
-
>
141
-
<PubIcon
142
-
record={pub.record as PubLeafletPublication.Record}
143
-
uri={pub.uri}
144
-
/>
145
-
{pub.name}
146
-
</MenuItem>
147
-
);
148
-
})}
149
-
</>
150
-
)}
151
-
</>
152
-
)}
153
68
</Menu>
154
69
);
155
70
};
+10
-14
app/(home-pages)/home/HomeLayout.tsx
+10
-14
app/(home-pages)/home/HomeLayout.tsx
···
29
29
HomeEmptyState,
30
30
PublicationBanner,
31
31
} from "./HomeEmpty/HomeEmpty";
32
-
import { EmptyState } from "components/EmptyState";
33
32
34
33
export type Leaflet = {
35
34
added_at: string;
···
136
135
(acc, tok) => {
137
136
let title =
138
137
tok.permission_tokens.leaflets_in_publications[0]?.title ||
139
-
tok.permission_tokens.leaflets_to_documents?.title;
138
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
140
139
if (title) acc[tok.permission_tokens.root_entity] = title;
141
140
return acc;
142
141
},
···
212
211
className={`
213
212
leafletList
214
213
w-full
215
-
${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2 "} `}
214
+
${display === "grid" ? "grid auto-rows-max md:grid-cols-4 sm:grid-cols-3 grid-cols-2 gap-y-4 gap-x-4 sm:gap-x-6 sm:gap-y-5 grow" : "flex flex-col gap-2 pt-2"} `}
216
215
>
217
-
{searchedLeaflets.length === 0 && (
218
-
<EmptyState>
219
-
<div className="italic">Oh no! No results!</div>
220
-
</EmptyState>
221
-
)}
222
216
{props.leaflets.map(({ token: leaflet, added_at, archived }, index) => (
223
217
<ReplicacheProvider
224
218
disablePull
···
233
227
value={{
234
228
...leaflet,
235
229
leaflets_in_publications: leaflet.leaflets_in_publications || [],
236
-
leaflets_to_documents: leaflet.leaflets_to_documents || null,
230
+
leaflets_to_documents: leaflet.leaflets_to_documents || [],
237
231
blocked_by_admin: null,
238
232
custom_domain_routes: [],
239
233
}}
···
292
286
({ token: leaflet, archived: archived }) => {
293
287
let published =
294
288
!!leaflet.leaflets_in_publications?.find((l) => l.doc) ||
295
-
!!leaflet.leaflets_to_documents?.document;
289
+
!!leaflet.leaflets_to_documents?.find((l) => l.document);
296
290
let drafts = !!leaflet.leaflets_in_publications?.length && !published;
297
291
let docs = !leaflet.leaflets_in_publications?.length && !archived;
298
-
// If no filters are active, show all
292
+
293
+
// If no filters are active, show everything that is not archived
299
294
if (
300
295
!filter.drafts &&
301
296
!filter.published &&
···
304
299
)
305
300
return archived === false || archived === null || archived == undefined;
306
301
302
+
//if a filter is on, return itemsd of that filter that are also NOT archived
307
303
return (
308
-
(filter.drafts && drafts) ||
309
-
(filter.published && published) ||
310
-
(filter.docs && docs) ||
304
+
(filter.drafts && drafts && !archived) ||
305
+
(filter.published && published && !archived) ||
306
+
(filter.docs && docs && !archived) ||
311
307
(filter.archived && archived)
312
308
);
313
309
},
+1
-1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
+1
-1
app/(home-pages)/home/LeafletList/LeafletOptions.tsx
+1
-1
app/(home-pages)/home/page.tsx
+1
-1
app/(home-pages)/home/page.tsx
···
30
30
(acc, tok) => {
31
31
let title =
32
32
tok.permission_tokens.leaflets_in_publications[0]?.title ||
33
-
tok.permission_tokens.leaflets_to_documents?.title;
33
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
34
34
if (title) acc[tok.permission_tokens.root_entity] = title;
35
35
return acc;
36
36
},
+5
-50
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
+5
-50
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
···
1
1
"use client";
2
-
import {
3
-
DashboardLayout,
4
-
PublicationDashboardControls,
5
-
} from "components/PageLayouts/DashboardLayout";
2
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
6
3
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
4
import { useState } from "react";
8
5
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
···
14
11
import useSWR from "swr";
15
12
import { getHomeDocs } from "../home/storage";
16
13
import { Leaflet, LeafletList } from "../home/HomeLayout";
17
-
import { EmptyState } from "components/EmptyState";
18
-
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
19
14
20
15
export const LooseleafsLayout = (props: {
21
16
entityID: string | null;
···
41
36
id="looseleafs"
42
37
cardBorderHidden={cardBorderHidden}
43
38
currentPage="looseleafs"
44
-
defaultTab="Drafts"
39
+
defaultTab="home"
45
40
actions={<Actions />}
46
41
tabs={{
47
-
Drafts: {
48
-
controls: (
49
-
<PublicationDashboardControls
50
-
defaultDisplay={"list"}
51
-
hasBackgroundImage={cardBorderHidden}
52
-
searchValue={searchValue}
53
-
setSearchValueAction={setSearchValue}
54
-
/>
55
-
),
56
-
content: <LooseleafDraftList empty={true} />,
57
-
},
58
-
Published: {
42
+
home: {
59
43
controls: null,
60
44
content: (
61
45
<LooseleafList
···
71
55
);
72
56
};
73
57
74
-
const LooseleafDraftList = (props: { empty: boolean }) => {
75
-
if (props.empty)
76
-
return (
77
-
<EmptyState className="pt-2">
78
-
<div className="italic">You don't have any looseleaf drafts yetโฆ</div>
79
-
<ButtonPrimary className="mx-auto">New Draft</ButtonPrimary>
80
-
</EmptyState>
81
-
);
82
-
return (
83
-
<div className="flex flex-col">
84
-
<ButtonSecondary fullWidth>New Looseleaf Draft</ButtonSecondary>
85
-
This is where the draft would go if we had them lol
86
-
</div>
87
-
);
88
-
};
89
-
90
58
export const LooseleafList = (props: {
91
59
titles: { [root_entity: string]: string };
92
60
initialFacts: {
···
111
79
(acc, tok) => {
112
80
let title =
113
81
tok.permission_tokens.leaflets_in_publications[0]?.title ||
114
-
tok.permission_tokens.leaflets_to_documents?.title;
82
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
115
83
if (title) acc[tok.permission_tokens.root_entity] = title;
116
84
return acc;
117
85
},
···
127
95
let leaflets: Leaflet[] = identity
128
96
? identity.permission_token_on_homepage
129
97
.filter(
130
-
(ptoh) =>
131
-
ptoh.permission_tokens.leaflets_to_documents &&
132
-
ptoh.permission_tokens.leaflets_to_documents.document,
98
+
(ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0,
133
99
)
134
100
.map((ptoh) => ({
135
101
added_at: ptoh.created_at,
136
102
token: ptoh.permission_tokens as PermissionToken,
137
103
}))
138
104
: [];
139
-
140
-
if (!leaflets || leaflets.length === 0)
141
-
return (
142
-
<EmptyState>
143
-
<div className="italic">You haven't published any looseleafs yet.</div>
144
-
<ButtonPrimary className="mx-auto">
145
-
Start a Looseleaf Draft
146
-
</ButtonPrimary>
147
-
</EmptyState>
148
-
);
149
-
150
105
return (
151
106
<LeafletList
152
107
defaultDisplay="list"
+1
-1
app/(home-pages)/looseleafs/page.tsx
+1
-1
app/(home-pages)/looseleafs/page.tsx
···
34
34
(acc, tok) => {
35
35
let title =
36
36
tok.permission_tokens.leaflets_in_publications[0]?.title ||
37
-
tok.permission_tokens.leaflets_to_documents?.title;
37
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
38
38
if (title) acc[tok.permission_tokens.root_entity] = title;
39
39
return acc;
40
40
},
+98
app/(home-pages)/notifications/CommentMentionNotification.tsx
+98
app/(home-pages)/notifications/CommentMentionNotification.tsx
···
1
+
import {
2
+
AppBskyActorProfile,
3
+
PubLeafletComment,
4
+
PubLeafletDocument,
5
+
PubLeafletPublication,
6
+
} from "lexicons/api";
7
+
import { HydratedCommentMentionNotification } from "src/notifications";
8
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
9
+
import { MentionTiny } from "components/Icons/MentionTiny";
10
+
import {
11
+
CommentInNotification,
12
+
ContentLayout,
13
+
Notification,
14
+
} from "./Notification";
15
+
import { AtUri } from "@atproto/api";
16
+
17
+
export const CommentMentionNotification = (
18
+
props: HydratedCommentMentionNotification,
19
+
) => {
20
+
const docRecord = props.commentData.documents
21
+
?.data as PubLeafletDocument.Record;
22
+
const commentRecord = props.commentData.record as PubLeafletComment.Record;
23
+
const profileRecord = props.commentData.bsky_profiles
24
+
?.record as AppBskyActorProfile.Record;
25
+
const pubRecord = props.commentData.documents?.documents_in_publications[0]
26
+
?.publications?.record as PubLeafletPublication.Record | undefined;
27
+
const docUri = new AtUri(props.commentData.documents?.uri!);
28
+
const rkey = docUri.rkey;
29
+
const did = docUri.host;
30
+
31
+
const href = pubRecord
32
+
? `https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`
33
+
: `/p/${did}/${rkey}?interactionDrawer=comments`;
34
+
35
+
const commenter = props.commenterHandle
36
+
? `@${props.commenterHandle}`
37
+
: "Someone";
38
+
39
+
let actionText: React.ReactNode;
40
+
let mentionedDocRecord = props.mentionedDocument
41
+
?.data as PubLeafletDocument.Record;
42
+
43
+
if (props.mention_type === "did") {
44
+
actionText = <>{commenter} mentioned you in a comment</>;
45
+
} else if (
46
+
props.mention_type === "publication" &&
47
+
props.mentionedPublication
48
+
) {
49
+
const mentionedPubRecord = props.mentionedPublication
50
+
.record as PubLeafletPublication.Record;
51
+
actionText = (
52
+
<>
53
+
{commenter} mentioned your publication{" "}
54
+
<span className="italic">{mentionedPubRecord.name}</span> in a comment
55
+
</>
56
+
);
57
+
} else if (props.mention_type === "document" && props.mentionedDocument) {
58
+
actionText = (
59
+
<>
60
+
{commenter} mentioned your post{" "}
61
+
<span className="italic">{mentionedDocRecord.title}</span> in a comment
62
+
</>
63
+
);
64
+
} else {
65
+
actionText = <>{commenter} mentioned you in a comment</>;
66
+
}
67
+
68
+
return (
69
+
<Notification
70
+
timestamp={props.created_at}
71
+
href={href}
72
+
icon={<MentionTiny />}
73
+
actionText={actionText}
74
+
content={
75
+
<ContentLayout postTitle={docRecord?.title} pubRecord={pubRecord}>
76
+
<CommentInNotification
77
+
className=""
78
+
avatar={
79
+
profileRecord?.avatar?.ref &&
80
+
blobRefToSrc(
81
+
profileRecord?.avatar?.ref,
82
+
props.commentData.bsky_profiles?.did || "",
83
+
)
84
+
}
85
+
displayName={
86
+
profileRecord?.displayName ||
87
+
props.commentData.bsky_profiles?.handle ||
88
+
"Someone"
89
+
}
90
+
index={[]}
91
+
plaintext={commentRecord.plaintext}
92
+
facets={commentRecord.facets}
93
+
/>
94
+
</ContentLayout>
95
+
}
96
+
/>
97
+
);
98
+
};
+44
-24
app/(home-pages)/notifications/MentionNotification.tsx
+44
-24
app/(home-pages)/notifications/MentionNotification.tsx
···
1
-
import { QuoteTiny } from "components/Icons/QuoteTiny";
1
+
import { MentionTiny } from "components/Icons/MentionTiny";
2
2
import { ContentLayout, Notification } from "./Notification";
3
-
import { HydratedQuoteNotification } from "src/notifications";
3
+
import { HydratedMentionNotification } from "src/notifications";
4
4
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
5
-
import { AtUri } from "@atproto/api";
6
-
import { Avatar } from "components/Avatar";
5
+
import { Agent, AtUri } from "@atproto/api";
7
6
8
-
export const QuoteNotification = (props: HydratedQuoteNotification) => {
9
-
const postView = props.bskyPost.post_view as any;
10
-
const author = postView.author;
11
-
const displayName = author.displayName || author.handle || "Someone";
7
+
export const MentionNotification = (props: HydratedMentionNotification) => {
12
8
const docRecord = props.document.data as PubLeafletDocument.Record;
13
-
const pubRecord = props.document.documents_in_publications[0]?.publications
9
+
const pubRecord = props.document.documents_in_publications?.[0]?.publications
14
10
?.record as PubLeafletPublication.Record | undefined;
15
11
const docUri = new AtUri(props.document.uri);
16
12
const rkey = docUri.rkey;
17
13
const did = docUri.host;
18
-
const postText = postView.record?.text || "";
19
14
20
15
const href = pubRecord
21
16
? `https://${pubRecord.base_path}/${rkey}`
22
17
: `/p/${did}/${rkey}`;
23
18
19
+
let actionText: React.ReactNode;
20
+
let mentionedItemName: string | undefined;
21
+
let mentionedDocRecord = props.mentionedDocument
22
+
?.data as PubLeafletDocument.Record;
23
+
24
+
const mentioner = props.documentCreatorHandle
25
+
? `@${props.documentCreatorHandle}`
26
+
: "Someone";
27
+
28
+
if (props.mention_type === "did") {
29
+
actionText = <>{mentioner} mentioned you</>;
30
+
} else if (
31
+
props.mention_type === "publication" &&
32
+
props.mentionedPublication
33
+
) {
34
+
const mentionedPubRecord = props.mentionedPublication
35
+
.record as PubLeafletPublication.Record;
36
+
mentionedItemName = mentionedPubRecord.name;
37
+
actionText = (
38
+
<>
39
+
{mentioner} mentioned your publication{" "}
40
+
<span className="italic">{mentionedItemName}</span>
41
+
</>
42
+
);
43
+
} else if (props.mention_type === "document" && props.mentionedDocument) {
44
+
mentionedItemName = mentionedDocRecord.title;
45
+
actionText = (
46
+
<>
47
+
{mentioner} mentioned your post{" "}
48
+
<span className="italic">{mentionedItemName}</span>
49
+
</>
50
+
);
51
+
} else {
52
+
actionText = <>{mentioner} mentioned you</>;
53
+
}
54
+
24
55
return (
25
56
<Notification
26
57
timestamp={props.created_at}
27
58
href={href}
28
-
icon={<QuoteTiny />}
29
-
actionText={<>{displayName} quoted your post</>}
59
+
icon={<MentionTiny />}
60
+
actionText={actionText}
30
61
content={
31
62
<ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}>
32
-
<div className="flex gap-2 text-sm w-full">
33
-
<Avatar
34
-
src={author.avatar}
35
-
displayName={displayName}
36
-
/>
37
-
<pre
38
-
style={{ wordBreak: "break-word" }}
39
-
className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6"
40
-
>
41
-
{postText}
42
-
</pre>
43
-
</div>
63
+
{docRecord.description && docRecord.description}
44
64
</ContentLayout>
45
65
}
46
66
/>
+3
-3
app/(home-pages)/notifications/Notification.tsx
+3
-3
app/(home-pages)/notifications/Notification.tsx
···
69
69
<div
70
70
className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`}
71
71
>
72
-
<div className="text-tertiary text-sm italic font-bold pb-1">
72
+
<div className="text-tertiary text-sm italic font-bold ">
73
73
{props.postTitle}
74
74
</div>
75
-
{props.children}
75
+
{props.children && <div className="mb-2 text-sm">{props.children}</div>}
76
76
{props.pubRecord && (
77
77
<>
78
-
<hr className="mt-3 mb-1 border-border-light" />
78
+
<hr className="mt-1 mb-1 border-border-light" />
79
79
<a
80
80
href={`https://${props.pubRecord.base_path}`}
81
81
className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!"
+9
-1
app/(home-pages)/notifications/NotificationList.tsx
+9
-1
app/(home-pages)/notifications/NotificationList.tsx
···
7
7
import { ReplyNotification } from "./ReplyNotification";
8
8
import { useIdentityData } from "components/IdentityProvider";
9
9
import { FollowNotification } from "./FollowNotification";
10
-
import { QuoteNotification } from "./MentionNotification";
10
+
import { QuoteNotification } from "./QuoteNotification";
11
+
import { MentionNotification } from "./MentionNotification";
12
+
import { CommentMentionNotification } from "./CommentMentionNotification";
11
13
12
14
export function NotificationList({
13
15
notifications,
···
45
47
}
46
48
if (n.type === "quote") {
47
49
return <QuoteNotification key={n.id} {...n} />;
50
+
}
51
+
if (n.type === "mention") {
52
+
return <MentionNotification key={n.id} {...n} />;
53
+
}
54
+
if (n.type === "comment_mention") {
55
+
return <CommentMentionNotification key={n.id} {...n} />;
48
56
}
49
57
})}
50
58
</div>
+48
app/(home-pages)/notifications/QuoteNotification.tsx
+48
app/(home-pages)/notifications/QuoteNotification.tsx
···
1
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
2
+
import { ContentLayout, Notification } from "./Notification";
3
+
import { HydratedQuoteNotification } from "src/notifications";
4
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
5
+
import { AtUri } from "@atproto/api";
6
+
import { Avatar } from "components/Avatar";
7
+
8
+
export const QuoteNotification = (props: HydratedQuoteNotification) => {
9
+
const postView = props.bskyPost.post_view as any;
10
+
const author = postView.author;
11
+
const displayName = author.displayName || author.handle || "Someone";
12
+
const docRecord = props.document.data as PubLeafletDocument.Record;
13
+
const pubRecord = props.document.documents_in_publications[0]?.publications
14
+
?.record as PubLeafletPublication.Record | undefined;
15
+
const docUri = new AtUri(props.document.uri);
16
+
const rkey = docUri.rkey;
17
+
const did = docUri.host;
18
+
const postText = postView.record?.text || "";
19
+
20
+
const href = pubRecord
21
+
? `https://${pubRecord.base_path}/${rkey}`
22
+
: `/p/${did}/${rkey}`;
23
+
24
+
return (
25
+
<Notification
26
+
timestamp={props.created_at}
27
+
href={href}
28
+
icon={<QuoteTiny />}
29
+
actionText={<>{displayName} quoted your post</>}
30
+
content={
31
+
<ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}>
32
+
<div className="flex gap-2 text-sm w-full">
33
+
<Avatar
34
+
src={author.avatar}
35
+
displayName={displayName}
36
+
/>
37
+
<pre
38
+
style={{ wordBreak: "break-word" }}
39
+
className="whitespace-pre-wrap text-secondary line-clamp-3 sm:line-clamp-6"
40
+
>
41
+
{postText}
42
+
</pre>
43
+
</div>
44
+
</ContentLayout>
45
+
}
46
+
/>
47
+
);
48
+
};
+9
-195
app/(home-pages)/reader/ReaderContent.tsx
+9
-195
app/(home-pages)/reader/ReaderContent.tsx
···
1
1
"use client";
2
-
import { AtUri } from "@atproto/api";
3
-
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4
-
import { PubIcon } from "components/ActionBar/Publications";
5
2
import { ButtonPrimary } from "components/Buttons";
6
-
import { CommentTiny } from "components/Icons/CommentTiny";
7
3
import { DiscoverSmall } from "components/Icons/DiscoverSmall";
8
-
import { QuoteTiny } from "components/Icons/QuoteTiny";
9
-
import { Separator } from "components/Layout";
10
-
import { SpeedyLink } from "components/SpeedyLink";
11
-
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
12
-
import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
13
-
import { useSmoker } from "components/Toast";
14
-
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
15
-
import { blobRefToSrc } from "src/utils/blobRefToSrc";
16
-
import { Json } from "supabase/database.types";
17
4
import type { Cursor, Post } from "./getReaderFeed";
18
5
import useSWRInfinite from "swr/infinite";
19
6
import { getReaderFeed } from "./getReaderFeed";
20
7
import { useEffect, useRef } from "react";
21
-
import { useRouter } from "next/navigation";
22
8
import Link from "next/link";
23
-
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
24
-
import { EmptyState } from "components/EmptyState";
9
+
import { PostListing } from "components/PostListing";
25
10
26
11
export const ReaderContent = (props: {
27
12
posts: Post[];
···
29
14
}) => {
30
15
const getKey = (
31
16
pageIndex: number,
32
-
previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null,
17
+
previousPageData: {
18
+
posts: Post[];
19
+
nextCursor: Cursor | null;
20
+
} | null,
33
21
) => {
34
22
// Reached the end
35
23
if (previousPageData && !previousPageData.nextCursor) return null;
···
41
29
return ["reader-feed", previousPageData?.nextCursor] as const;
42
30
};
43
31
44
-
const { data, error, size, setSize, isValidating } = useSWRInfinite(
32
+
const { data, size, setSize, isValidating } = useSWRInfinite(
45
33
getKey,
46
34
([_, cursor]) => getReaderFeed(cursor),
47
35
{
···
80
68
return (
81
69
<div className="flex flex-col gap-3 relative">
82
70
{allPosts.map((p) => (
83
-
<Post {...p} key={p.documents.uri} />
71
+
<PostListing {...p} key={p.documents.uri} />
84
72
))}
85
73
{/* Trigger element for loading more posts */}
86
74
<div
···
97
85
);
98
86
};
99
87
100
-
const Post = (props: Post) => {
101
-
let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record;
102
-
103
-
let postRecord = props.documents.data as PubLeafletDocument.Record;
104
-
let postUri = new AtUri(props.documents.uri);
105
-
106
-
let theme = usePubTheme(pubRecord?.theme);
107
-
let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref
108
-
? blobRefToSrc(
109
-
pubRecord?.theme?.backgroundImage?.image?.ref,
110
-
new AtUri(props.publication.uri).host,
111
-
)
112
-
: null;
113
-
114
-
let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
115
-
let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
116
-
117
-
let showPageBackground = pubRecord.theme?.showPageBackground;
118
-
119
-
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
120
-
let comments =
121
-
pubRecord.preferences?.showComments === false
122
-
? 0
123
-
: props.documents.comments_on_documents?.[0]?.count || 0;
124
-
125
-
return (
126
-
<BaseThemeProvider {...theme} local>
127
-
<div
128
-
style={{
129
-
backgroundImage: `url(${backgroundImage})`,
130
-
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
131
-
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
132
-
}}
133
-
className={`no-underline! flex flex-row gap-2 w-full relative
134
-
bg-bg-leaflet
135
-
border border-border-light rounded-lg
136
-
sm:p-2 p-2 selected-outline
137
-
hover:outline-accent-contrast hover:border-accent-contrast
138
-
`}
139
-
>
140
-
<a
141
-
className="h-full w-full absolute top-0 left-0"
142
-
href={`${props.publication.href}/${postUri.rkey}`}
143
-
/>
144
-
<div
145
-
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
146
-
style={{
147
-
backgroundColor: showPageBackground
148
-
? "rgba(var(--bg-page), var(--bg-page-alpha))"
149
-
: "transparent",
150
-
}}
151
-
>
152
-
<h3 className="text-primary truncate">{postRecord.title}</h3>
153
-
154
-
<p className="text-secondary">{postRecord.description}</p>
155
-
<div className="flex gap-2 justify-between items-end">
156
-
<div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-start justify-start pt-1 md:pt-3">
157
-
<PubInfo
158
-
href={props.publication.href}
159
-
pubRecord={pubRecord}
160
-
uri={props.publication.uri}
161
-
/>
162
-
<Separator classname="h-4 !min-h-0 md:block hidden" />
163
-
<PostInfo
164
-
author={props.author || ""}
165
-
publishedAt={postRecord.publishedAt}
166
-
/>
167
-
</div>
168
-
169
-
<PostInterations
170
-
postUrl={`${props.publication.href}/${postUri.rkey}`}
171
-
quotesCount={quotes}
172
-
commentsCount={comments}
173
-
showComments={pubRecord.preferences?.showComments}
174
-
/>
175
-
</div>
176
-
</div>
177
-
</div>
178
-
</BaseThemeProvider>
179
-
);
180
-
};
181
-
182
-
const PubInfo = (props: {
183
-
href: string;
184
-
pubRecord: PubLeafletPublication.Record;
185
-
uri: string;
186
-
}) => {
187
-
return (
188
-
<a
189
-
href={props.href}
190
-
className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0"
191
-
>
192
-
<PubIcon small record={props.pubRecord} uri={props.uri} />
193
-
{props.pubRecord.name}
194
-
</a>
195
-
);
196
-
};
197
-
198
-
const PostInfo = (props: {
199
-
author: string;
200
-
publishedAt: string | undefined;
201
-
}) => {
202
-
const formattedDate = useLocalizedDate(
203
-
props.publishedAt || new Date().toISOString(),
204
-
{
205
-
year: "numeric",
206
-
month: "short",
207
-
day: "numeric",
208
-
},
209
-
);
210
-
211
-
return (
212
-
<div className="flex flex-wrap gap-2 grow items-center shrink-0">
213
-
{props.author}
214
-
{props.publishedAt && (
215
-
<>
216
-
<Separator classname="h-4 !min-h-0" />
217
-
{formattedDate}{" "}
218
-
</>
219
-
)}
220
-
</div>
221
-
);
222
-
};
223
-
224
-
const PostInterations = (props: {
225
-
quotesCount: number;
226
-
commentsCount: number;
227
-
postUrl: string;
228
-
showComments: boolean | undefined;
229
-
}) => {
230
-
let smoker = useSmoker();
231
-
let interactionsAvailable =
232
-
props.quotesCount > 0 ||
233
-
(props.showComments !== false && props.commentsCount > 0);
234
-
235
-
return (
236
-
<div className={`flex gap-2 text-tertiary text-sm items-center`}>
237
-
{props.quotesCount === 0 ? null : (
238
-
<div className={`flex gap-1 items-center `} aria-label="Post quotes">
239
-
<QuoteTiny aria-hidden /> {props.quotesCount}
240
-
</div>
241
-
)}
242
-
{props.showComments === false || props.commentsCount === 0 ? null : (
243
-
<div className={`flex gap-1 items-center`} aria-label="Post comments">
244
-
<CommentTiny aria-hidden /> {props.commentsCount}
245
-
</div>
246
-
)}
247
-
{interactionsAvailable && <Separator classname="h-4 !min-h-0" />}
248
-
<button
249
-
id={`copy-post-link-${props.postUrl}`}
250
-
className="flex gap-1 items-center hover:font-bold relative"
251
-
onClick={(e) => {
252
-
e.stopPropagation();
253
-
e.preventDefault();
254
-
let mouseX = e.clientX;
255
-
let mouseY = e.clientY;
256
-
257
-
if (!props.postUrl) return;
258
-
navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`);
259
-
260
-
smoker({
261
-
text: <strong>Copied Link!</strong>,
262
-
position: {
263
-
y: mouseY,
264
-
x: mouseX,
265
-
},
266
-
});
267
-
}}
268
-
>
269
-
Share
270
-
</button>
271
-
</div>
272
-
);
273
-
};
274
88
export const ReaderEmpty = () => {
275
89
return (
276
-
<EmptyState>
90
+
<div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
277
91
Nothing to read yetโฆ <br />
278
92
Subscribe to publications and find their posts here!
279
93
<Link href={"/discover"}>
···
281
95
<DiscoverSmall /> Discover Publications
282
96
</ButtonPrimary>
283
97
</Link>
284
-
</EmptyState>
98
+
</div>
285
99
);
286
100
};
+2
-3
app/(home-pages)/reader/SubscriptionsContent.tsx
+2
-3
app/(home-pages)/reader/SubscriptionsContent.tsx
···
8
8
import { useEffect, useRef } from "react";
9
9
import { Cursor } from "./getReaderFeed";
10
10
import Link from "next/link";
11
-
import { EmptyState } from "components/EmptyState";
12
11
13
12
export const SubscriptionsContent = (props: {
14
13
publications: PublicationSubscription[];
···
94
93
95
94
export const SubscriptionsEmpty = () => {
96
95
return (
97
-
<EmptyState>
96
+
<div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
98
97
You haven't subscribed to any publications yet!
99
98
<Link href={"/discover"}>
100
99
<ButtonPrimary className="mx-auto place-self-center">
101
100
<DiscoverSmall /> Discover Publications
102
101
</ButtonPrimary>
103
102
</Link>
104
-
</EmptyState>
103
+
</div>
105
104
);
106
105
};
+68
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
+68
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
···
1
+
"use server";
2
+
3
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4
+
import { supabaseServerClient } from "supabase/serverClient";
5
+
import { AtUri } from "@atproto/api";
6
+
import { Json } from "supabase/database.types";
7
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
8
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
9
+
10
+
export async function getDocumentsByTag(
11
+
tag: string,
12
+
): Promise<{ posts: Post[] }> {
13
+
// Query documents that have this tag
14
+
const { data: documents, error } = await supabaseServerClient
15
+
.from("documents")
16
+
.select(
17
+
`*,
18
+
comments_on_documents(count),
19
+
document_mentions_in_bsky(count),
20
+
documents_in_publications(publications(*))`,
21
+
)
22
+
.contains("data->tags", `["${tag}"]`)
23
+
.order("indexed_at", { ascending: false })
24
+
.limit(50);
25
+
26
+
if (error) {
27
+
console.error("Error fetching documents by tag:", error);
28
+
return { posts: [] };
29
+
}
30
+
31
+
const posts = await Promise.all(
32
+
documents.map(async (doc) => {
33
+
const pub = doc.documents_in_publications[0]?.publications;
34
+
35
+
// Skip if document doesn't have a publication
36
+
if (!pub) {
37
+
return null;
38
+
}
39
+
40
+
const uri = new AtUri(doc.uri);
41
+
const handle = await idResolver.did.resolve(uri.host);
42
+
43
+
const post: Post = {
44
+
publication: {
45
+
href: getPublicationURL(pub),
46
+
pubRecord: pub?.record || null,
47
+
uri: pub?.uri || "",
48
+
},
49
+
author: handle?.alsoKnownAs?.[0]
50
+
? `@${handle.alsoKnownAs[0].slice(5)}`
51
+
: null,
52
+
documents: {
53
+
comments_on_documents: doc.comments_on_documents,
54
+
document_mentions_in_bsky: doc.document_mentions_in_bsky,
55
+
data: doc.data,
56
+
uri: doc.uri,
57
+
indexed_at: doc.indexed_at,
58
+
},
59
+
};
60
+
return post;
61
+
}),
62
+
);
63
+
64
+
// Filter out null entries (documents without publications)
65
+
return {
66
+
posts: posts.filter((p): p is Post => p !== null),
67
+
};
68
+
}
+75
app/(home-pages)/tag/[tag]/page.tsx
+75
app/(home-pages)/tag/[tag]/page.tsx
···
1
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
2
+
import { Tag } from "components/Tags";
3
+
import { PostListing } from "components/PostListing";
4
+
import { getDocumentsByTag } from "./getDocumentsByTag";
5
+
import { TagTiny } from "components/Icons/TagTiny";
6
+
7
+
export default async function TagPage(props: {
8
+
params: Promise<{ tag: string }>;
9
+
}) {
10
+
const params = await props.params;
11
+
const decodedTag = decodeURIComponent(params.tag);
12
+
const { posts } = await getDocumentsByTag(decodedTag);
13
+
14
+
return (
15
+
<DashboardLayout
16
+
id="tag"
17
+
cardBorderHidden={false}
18
+
currentPage="tag"
19
+
defaultTab="default"
20
+
actions={null}
21
+
tabs={{
22
+
default: {
23
+
controls: null,
24
+
content: <TagContent tag={decodedTag} posts={posts} />,
25
+
},
26
+
}}
27
+
/>
28
+
);
29
+
}
30
+
31
+
const TagContent = (props: {
32
+
tag: string;
33
+
posts: Awaited<ReturnType<typeof getDocumentsByTag>>["posts"];
34
+
}) => {
35
+
return (
36
+
<div className="max-w-prose mx-auto w-full grow shrink-0">
37
+
<div className="discoverHeader flex flex-col gap-3 items-center text-center pt-2 px-4">
38
+
<TagHeader tag={props.tag} postCount={props.posts.length} />
39
+
</div>
40
+
<div className="pt-6 flex flex-col gap-3">
41
+
{props.posts.length === 0 ? (
42
+
<EmptyState tag={props.tag} />
43
+
) : (
44
+
props.posts.map((post) => (
45
+
<PostListing key={post.documents.uri} {...post} />
46
+
))
47
+
)}
48
+
</div>
49
+
</div>
50
+
);
51
+
};
52
+
53
+
const TagHeader = (props: { tag: string; postCount: number }) => {
54
+
return (
55
+
<div className="flex flex-col leading-tight items-center">
56
+
<div className="flex items-center gap-3 text-xl font-bold text-primary">
57
+
<TagTiny className="scale-150" />
58
+
<h1>{props.tag}</h1>
59
+
</div>
60
+
<div className="text-tertiary text-sm">
61
+
{props.postCount} {props.postCount === 1 ? "post" : "posts"}
62
+
</div>
63
+
</div>
64
+
);
65
+
};
66
+
67
+
const EmptyState = (props: { tag: string }) => {
68
+
return (
69
+
<div className="flex flex-col gap-2 items-center justify-center p-8 text-center">
70
+
<div className="text-tertiary">
71
+
No posts found with the tag "{props.tag}"
72
+
</div>
73
+
</div>
74
+
);
75
+
};
+1
-1
app/[leaflet_id]/actions/HomeButton.tsx
+1
-1
app/[leaflet_id]/actions/HomeButton.tsx
+15
-22
app/[leaflet_id]/actions/PublishButton.tsx
+15
-22
app/[leaflet_id]/actions/PublishButton.tsx
···
27
27
import { useState, useMemo } from "react";
28
28
import { useIsMobile } from "src/hooks/isMobile";
29
29
import { useReplicache, useEntity } from "src/replicache";
30
+
import { useSubscribe } from "src/replicache/useSubscribe";
30
31
import { Json } from "supabase/database.types";
31
32
import {
32
33
useBlocks,
···
34
35
} from "src/hooks/queries/useBlocks";
35
36
import * as Y from "yjs";
36
37
import * as base64 from "base64-js";
37
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
38
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
38
39
import { BlueskyLogin } from "app/login/LoginForm";
39
40
import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
40
-
import { saveLeafletDraft } from "actions/publications/saveLeafletDraft";
41
41
import { AddTiny } from "components/Icons/AddTiny";
42
42
43
43
export const PublishButton = (props: { entityID: string }) => {
···
64
64
const UpdateButton = () => {
65
65
let [isLoading, setIsLoading] = useState(false);
66
66
let { data: pub, mutate } = useLeafletPublicationData();
67
-
let { permission_token, rootEntity } = useReplicache();
67
+
let { permission_token, rootEntity, rep } = useReplicache();
68
68
let { identity } = useIdentityData();
69
69
let toaster = useToaster();
70
+
71
+
// Get tags from Replicache state (same as draft editor)
72
+
let tags = useSubscribe(rep, (tx) => tx.get<string[]>("publication_tags"));
73
+
const currentTags = Array.isArray(tags) ? tags : [];
70
74
71
75
return (
72
76
<ActionButton
···
82
86
leaflet_id: permission_token.id,
83
87
title: pub.title,
84
88
description: pub.description,
89
+
tags: currentTags,
85
90
});
86
91
setIsLoading(false);
87
92
mutate();
···
109
114
let { identity } = useIdentityData();
110
115
let { permission_token } = useReplicache();
111
116
let query = useSearchParams();
112
-
console.log(query.get("publish"));
113
117
let [open, setOpen] = useState(query.get("publish") !== null);
114
118
115
119
let isMobile = useIsMobile();
···
177
181
<hr className="border-border-light mt-3 mb-2" />
178
182
179
183
<div className="flex gap-2 items-center place-self-end">
180
-
{selectedPub && selectedPub !== "create" && (
184
+
{selectedPub !== "looseleaf" && selectedPub && (
181
185
<SaveAsDraftButton
182
186
selectedPub={selectedPub}
183
187
leafletId={permission_token.id}
···
230
234
if (props.selectedPub === "create") return;
231
235
e.preventDefault();
232
236
setIsLoading(true);
233
-
234
-
// Use different actions for looseleaf vs publication
235
-
if (props.selectedPub === "looseleaf") {
236
-
await saveLeafletDraft(
237
-
props.leafletId,
238
-
props.metadata,
239
-
props.entitiesToDelete,
240
-
);
241
-
} else {
242
-
await moveLeafletToPublication(
243
-
props.leafletId,
244
-
props.selectedPub,
245
-
props.metadata,
246
-
props.entitiesToDelete,
247
-
);
248
-
}
249
-
237
+
await moveLeafletToPublication(
238
+
props.leafletId,
239
+
props.selectedPub,
240
+
props.metadata,
241
+
props.entitiesToDelete,
242
+
);
250
243
await Promise.all([rep?.pull(), mutate()]);
251
244
setIsLoading(false);
252
245
}}
+1
-1
app/[leaflet_id]/page.tsx
+1
-1
app/[leaflet_id]/page.tsx
···
4
4
5
5
import type { Fact } from "src/replicache";
6
6
import type { Attribute } from "src/replicache/attributes";
7
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
7
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
8
8
import { Leaflet } from "./Leaflet";
9
9
import { scanIndexLocal } from "src/replicache/utils";
10
10
import { getRSVPData } from "actions/getRSVPData";
+144
-294
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
+144
-294
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
···
1
1
"use client";
2
-
import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api";
3
-
import {
4
-
useState,
5
-
useCallback,
6
-
useRef,
7
-
useLayoutEffect,
8
-
useEffect,
9
-
} from "react";
10
-
import { createPortal } from "react-dom";
11
-
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
12
-
import * as Popover from "@radix-ui/react-popover";
13
-
import { EditorState, TextSelection, Plugin } from "prosemirror-state";
2
+
import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api";
3
+
import { useState, useCallback, useRef, useLayoutEffect } from "react";
4
+
import { EditorState } from "prosemirror-state";
14
5
import { EditorView } from "prosemirror-view";
15
6
import { Schema, MarkSpec, Mark } from "prosemirror-model";
16
7
import { baseKeymap } from "prosemirror-commands";
···
19
10
import { inputRules, InputRule } from "prosemirror-inputrules";
20
11
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
21
12
import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox";
13
+
import { schema } from "components/Blocks/TextBlock/schema";
14
+
import { Mention, MentionAutocomplete } from "components/Mention";
22
15
23
16
// Schema with only links, mentions, and hashtags marks
24
17
const bskyPostSchema = new Schema({
···
134
127
return tr;
135
128
});
136
129
}
137
-
138
130
export function BlueskyPostEditorProsemirror(props: {
139
-
editorStateRef: React.MutableRefObject<EditorState | null>;
131
+
editorStateRef: React.RefObject<EditorState | null>;
140
132
initialContent?: string;
141
133
onCharCountChange?: (count: number) => void;
142
134
}) {
143
135
const mountRef = useRef<HTMLDivElement | null>(null);
144
136
const viewRef = useRef<EditorView | null>(null);
145
137
const [editorState, setEditorState] = useState<EditorState | null>(null);
146
-
const [mentionState, setMentionState] = useState<{
147
-
active: boolean;
148
-
range: { from: number; to: number } | null;
149
-
selectedMention: { handle: string; did: string } | null;
150
-
}>({ active: false, range: null, selectedMention: null });
138
+
const [mentionOpen, setMentionOpen] = useState(false);
139
+
const [mentionCoords, setMentionCoords] = useState<{
140
+
top: number;
141
+
left: number;
142
+
} | null>(null);
143
+
const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null);
144
+
145
+
const openMentionAutocomplete = useCallback(() => {
146
+
if (!viewRef.current) return;
147
+
const view = viewRef.current;
148
+
const pos = view.state.selection.from;
149
+
setMentionInsertPos(pos);
150
+
const coords = view.coordsAtPos(pos - 1);
151
+
152
+
// Get coordinates relative to the positioned parent container
153
+
const editorEl = view.dom;
154
+
const container = editorEl.closest(".relative") as HTMLElement | null;
155
+
156
+
if (container) {
157
+
const containerRect = container.getBoundingClientRect();
158
+
setMentionCoords({
159
+
top: coords.bottom - containerRect.top,
160
+
left: coords.left - containerRect.left,
161
+
});
162
+
} else {
163
+
setMentionCoords({
164
+
top: coords.bottom,
165
+
left: coords.left,
166
+
});
167
+
}
168
+
setMentionOpen(true);
169
+
}, []);
151
170
152
171
const handleMentionSelect = useCallback(
153
-
(
154
-
mention: { handle: string; did: string },
155
-
range: { from: number; to: number },
156
-
) => {
157
-
if (!viewRef.current) return;
172
+
(mention: Mention) => {
173
+
if (!viewRef.current || mentionInsertPos === null) return;
158
174
const view = viewRef.current;
159
-
const { from, to } = range;
175
+
const from = mentionInsertPos - 1;
176
+
const to = mentionInsertPos;
160
177
const tr = view.state.tr;
161
178
162
-
// Delete the query text (keep the @)
163
-
tr.delete(from + 1, to);
179
+
// Delete the @ symbol
180
+
tr.delete(from, to);
164
181
165
-
// Insert the mention text after the @
166
-
const mentionText = mention.handle;
167
-
tr.insertText(mentionText, from + 1);
168
-
169
-
// Apply mention mark to @ and handle
170
-
tr.addMark(
171
-
from,
172
-
from + 1 + mentionText.length,
173
-
bskyPostSchema.marks.mention.create({ did: mention.did }),
174
-
);
175
-
176
-
// Add a space after the mention
177
-
tr.insertText(" ", from + 1 + mentionText.length);
182
+
if (mention.type === "did") {
183
+
// Insert @handle with mention mark
184
+
const mentionText = "@" + mention.handle;
185
+
tr.insertText(mentionText, from);
186
+
tr.addMark(
187
+
from,
188
+
from + mentionText.length,
189
+
bskyPostSchema.marks.mention.create({ did: mention.did }),
190
+
);
191
+
tr.insertText(" ", from + mentionText.length);
192
+
} else if (mention.type === "publication") {
193
+
// Insert publication name as a link
194
+
const linkText = mention.name;
195
+
tr.insertText(linkText, from);
196
+
tr.addMark(
197
+
from,
198
+
from + linkText.length,
199
+
bskyPostSchema.marks.link.create({ href: mention.url }),
200
+
);
201
+
tr.insertText(" ", from + linkText.length);
202
+
} else if (mention.type === "post") {
203
+
// Insert post title as a link
204
+
const linkText = mention.title;
205
+
tr.insertText(linkText, from);
206
+
tr.addMark(
207
+
from,
208
+
from + linkText.length,
209
+
bskyPostSchema.marks.link.create({ href: mention.url }),
210
+
);
211
+
tr.insertText(" ", from + linkText.length);
212
+
}
178
213
179
214
view.dispatch(tr);
180
215
view.focus();
181
216
},
182
-
[],
217
+
[mentionInsertPos],
183
218
);
184
219
185
-
const mentionStateRef = useRef(mentionState);
186
-
mentionStateRef.current = mentionState;
220
+
const handleMentionOpenChange = useCallback((open: boolean) => {
221
+
setMentionOpen(open);
222
+
if (!open) {
223
+
setMentionCoords(null);
224
+
setMentionInsertPos(null);
225
+
}
226
+
}, []);
187
227
188
228
useLayoutEffect(() => {
189
229
if (!mountRef.current) return;
190
230
231
+
// Input rule to trigger mention autocomplete when @ is typed
232
+
const mentionInputRule = new InputRule(
233
+
/(?:^|\s)@$/,
234
+
(state, match, start, end) => {
235
+
setTimeout(() => openMentionAutocomplete(), 0);
236
+
return null;
237
+
},
238
+
);
239
+
191
240
const initialState = EditorState.create({
192
241
schema: bskyPostSchema,
193
242
doc: props.initialContent
···
200
249
})
201
250
: undefined,
202
251
plugins: [
203
-
inputRules({ rules: [createHashtagInputRule()] }),
252
+
inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }),
204
253
keymap({
205
254
"Mod-z": undo,
206
255
"Mod-y": redo,
207
256
"Shift-Mod-z": redo,
208
-
Enter: (state, dispatch) => {
209
-
// Check if mention autocomplete is active
210
-
const currentMentionState = mentionStateRef.current;
211
-
if (
212
-
currentMentionState.active &&
213
-
currentMentionState.selectedMention &&
214
-
currentMentionState.range
215
-
) {
216
-
handleMentionSelect(
217
-
currentMentionState.selectedMention,
218
-
currentMentionState.range,
219
-
);
220
-
return true;
221
-
}
222
-
// Otherwise let the default Enter behavior happen (new paragraph)
223
-
return false;
224
-
},
225
257
}),
226
258
keymap(baseKeymap),
227
259
autolink({
···
258
290
view.destroy();
259
291
viewRef.current = null;
260
292
};
261
-
}, [handleMentionSelect]);
293
+
}, [openMentionAutocomplete]);
262
294
263
295
return (
264
296
<div className="relative w-full h-full group">
265
-
{editorState && (
266
-
<MentionAutocomplete
267
-
editorState={editorState}
268
-
view={viewRef}
269
-
onSelect={handleMentionSelect}
270
-
onMentionStateChange={(active, range, selectedMention) => {
271
-
setMentionState({ active, range, selectedMention });
272
-
}}
273
-
/>
274
-
)}
297
+
<MentionAutocomplete
298
+
open={mentionOpen}
299
+
onOpenChange={handleMentionOpenChange}
300
+
view={viewRef}
301
+
onSelect={handleMentionSelect}
302
+
coords={mentionCoords}
303
+
placeholder="Search people..."
304
+
/>
275
305
{editorState?.doc.textContent.length === 0 && (
276
306
<div className="italic text-tertiary absolute top-0 left-0 pointer-events-none">
277
307
Write a post to share your writing!
···
279
309
)}
280
310
<div
281
311
ref={mountRef}
282
-
className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm"
312
+
className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm"
283
313
style={{
284
314
wordWrap: "break-word",
285
315
overflowWrap: "break-word",
···
290
320
);
291
321
}
292
322
293
-
function MentionAutocomplete(props: {
294
-
editorState: EditorState;
295
-
view: React.RefObject<EditorView | null>;
296
-
onSelect: (
297
-
mention: { handle: string; did: string },
298
-
range: { from: number; to: number },
299
-
) => void;
300
-
onMentionStateChange: (
301
-
active: boolean,
302
-
range: { from: number; to: number } | null,
303
-
selectedMention: { handle: string; did: string } | null,
304
-
) => void;
305
-
}) {
306
-
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
307
-
const [mentionRange, setMentionRange] = useState<{
308
-
from: number;
309
-
to: number;
310
-
} | null>(null);
311
-
const [mentionCoords, setMentionCoords] = useState<{
312
-
top: number;
313
-
left: number;
314
-
} | null>(null);
315
-
316
-
const { suggestionIndex, setSuggestionIndex, suggestions } =
317
-
useMentionSuggestions(mentionQuery);
318
-
319
-
// Check for mention pattern whenever editor state changes
320
-
useEffect(() => {
321
-
const { $from } = props.editorState.selection;
322
-
const textBefore = $from.parent.textBetween(
323
-
Math.max(0, $from.parentOffset - 50),
324
-
$from.parentOffset,
325
-
null,
326
-
"\ufffc",
327
-
);
328
-
329
-
// Look for @ followed by word characters before cursor
330
-
const match = textBefore.match(/@([\w.]*)$/);
331
-
332
-
if (match && props.view.current) {
333
-
const queryBefore = match[1];
334
-
const from = $from.pos - queryBefore.length - 1;
335
-
336
-
// Get text after cursor to find the rest of the handle
337
-
const textAfter = $from.parent.textBetween(
338
-
$from.parentOffset,
339
-
Math.min($from.parent.content.size, $from.parentOffset + 50),
340
-
null,
341
-
"\ufffc",
342
-
);
343
-
344
-
// Match word characters after cursor until space or end
345
-
const afterMatch = textAfter.match(/^([\w.]*)/);
346
-
const queryAfter = afterMatch ? afterMatch[1] : "";
347
-
348
-
// Combine the full handle
349
-
const query = queryBefore + queryAfter;
350
-
const to = $from.pos + queryAfter.length;
351
-
352
-
setMentionQuery(query);
353
-
setMentionRange({ from, to });
354
-
355
-
// Get coordinates for the autocomplete popup
356
-
const coords = props.view.current.coordsAtPos(from);
357
-
setMentionCoords({
358
-
top: coords.bottom + window.scrollY,
359
-
left: coords.left + window.scrollX,
360
-
});
361
-
setSuggestionIndex(0);
362
-
} else {
363
-
setMentionQuery(null);
364
-
setMentionRange(null);
365
-
setMentionCoords(null);
366
-
}
367
-
}, [props.editorState, props.view, setSuggestionIndex]);
368
-
369
-
// Update parent's mention state
370
-
useEffect(() => {
371
-
const active = mentionQuery !== null && suggestions.length > 0;
372
-
const selectedMention =
373
-
active && suggestions[suggestionIndex]
374
-
? suggestions[suggestionIndex]
375
-
: null;
376
-
props.onMentionStateChange(active, mentionRange, selectedMention);
377
-
}, [mentionQuery, suggestions, suggestionIndex, mentionRange]);
378
-
379
-
// Handle keyboard navigation for arrow keys only
380
-
useEffect(() => {
381
-
if (!mentionQuery || !props.view.current) return;
382
-
383
-
const handleKeyDown = (e: KeyboardEvent) => {
384
-
if (suggestions.length === 0) return;
385
-
386
-
if (e.key === "ArrowUp") {
387
-
e.preventDefault();
388
-
if (suggestionIndex > 0) {
389
-
setSuggestionIndex((i) => i - 1);
390
-
}
391
-
} else if (e.key === "ArrowDown") {
392
-
e.preventDefault();
393
-
if (suggestionIndex < suggestions.length - 1) {
394
-
setSuggestionIndex((i) => i + 1);
395
-
}
396
-
}
397
-
};
398
-
399
-
const dom = props.view.current.dom;
400
-
dom.addEventListener("keydown", handleKeyDown);
401
-
402
-
return () => {
403
-
dom.removeEventListener("keydown", handleKeyDown);
404
-
};
405
-
}, [
406
-
mentionQuery,
407
-
suggestions,
408
-
suggestionIndex,
409
-
props.view,
410
-
setSuggestionIndex,
411
-
]);
412
-
413
-
if (!mentionCoords || suggestions.length === 0) return null;
414
-
415
-
// The styles in this component should match the Menu styles in components/Layout.tsx
416
-
return (
417
-
<Popover.Root open>
418
-
{createPortal(
419
-
<Popover.Anchor
420
-
style={{
421
-
top: mentionCoords.top,
422
-
left: mentionCoords.left,
423
-
position: "absolute",
424
-
}}
425
-
/>,
426
-
document.body,
427
-
)}
428
-
<Popover.Portal>
429
-
<Popover.Content
430
-
side="bottom"
431
-
align="start"
432
-
sideOffset={4}
433
-
collisionPadding={20}
434
-
onOpenAutoFocus={(e) => e.preventDefault()}
435
-
className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`}
436
-
>
437
-
<ul className="list-none p-0 text-sm">
438
-
{suggestions.map((result, index) => {
439
-
return (
440
-
<div
441
-
className={`
442
-
MenuItem
443
-
font-bold z-10 py-1 px-3
444
-
text-left text-secondary
445
-
flex gap-2
446
-
${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""}
447
-
hover:bg-border-light hover:text-secondary
448
-
outline-none
449
-
`}
450
-
key={result.did}
451
-
onClick={() => {
452
-
if (mentionRange) {
453
-
props.onSelect(result, mentionRange);
454
-
setMentionQuery(null);
455
-
setMentionRange(null);
456
-
setMentionCoords(null);
457
-
}
458
-
}}
459
-
onMouseDown={(e) => e.preventDefault()}
460
-
>
461
-
@{result.handle}
462
-
</div>
463
-
);
464
-
})}
465
-
</ul>
466
-
</Popover.Content>
467
-
</Popover.Portal>
468
-
</Popover.Root>
469
-
);
470
-
}
471
-
472
-
function useMentionSuggestions(query: string | null) {
473
-
const [suggestionIndex, setSuggestionIndex] = useState(0);
474
-
const [suggestions, setSuggestions] = useState<
475
-
{ handle: string; did: string }[]
476
-
>([]);
477
-
478
-
useDebouncedEffect(
479
-
async () => {
480
-
if (!query) {
481
-
setSuggestions([]);
482
-
return;
483
-
}
484
-
485
-
const agent = new Agent("https://public.api.bsky.app");
486
-
const result = await agent.searchActorsTypeahead({
487
-
q: query,
488
-
limit: 8,
489
-
});
490
-
setSuggestions(
491
-
result.data.actors.map((actor) => ({
492
-
handle: actor.handle,
493
-
did: actor.did,
494
-
})),
495
-
);
496
-
},
497
-
300,
498
-
[query],
499
-
);
500
-
501
-
useEffect(() => {
502
-
if (suggestionIndex > suggestions.length - 1) {
503
-
setSuggestionIndex(Math.max(0, suggestions.length - 1));
504
-
}
505
-
}, [suggestionIndex, suggestions.length]);
506
-
507
-
return {
508
-
suggestions,
509
-
suggestionIndex,
510
-
setSuggestionIndex,
511
-
};
512
-
}
513
-
514
323
/**
515
324
* Converts a ProseMirror editor state to Bluesky post facets.
516
325
* Extracts mentions, links, and hashtags from the editor state and returns them
···
595
404
596
405
return features;
597
406
}
407
+
408
+
export const addMentionToEditor = (
409
+
mention: Mention,
410
+
range: { from: number; to: number },
411
+
view: EditorView,
412
+
) => {
413
+
console.log("view", view);
414
+
if (!view) return;
415
+
const { from, to } = range;
416
+
const tr = view.state.tr;
417
+
418
+
if (mention.type == "did") {
419
+
// Delete the @ and any query text
420
+
tr.delete(from, to);
421
+
// Insert didMention inline node
422
+
const mentionText = "@" + mention.handle;
423
+
const didMentionNode = schema.nodes.didMention.create({
424
+
did: mention.did,
425
+
text: mentionText,
426
+
});
427
+
tr.insert(from, didMentionNode);
428
+
}
429
+
if (mention.type === "publication" || mention.type === "post") {
430
+
// Delete the @ and any query text
431
+
tr.delete(from, to);
432
+
let name = mention.type == "post" ? mention.title : mention.name;
433
+
// Insert atMention inline node
434
+
const atMentionNode = schema.nodes.atMention.create({
435
+
atURI: mention.uri,
436
+
text: name,
437
+
});
438
+
tr.insert(from, atMentionNode);
439
+
}
440
+
console.log("yo", mention);
441
+
442
+
// Add a space after the mention
443
+
tr.insertText(" ", from + 1);
444
+
445
+
view.dispatch(tr);
446
+
view.focus();
447
+
};
+143
-83
app/[leaflet_id]/publish/PublishPost.tsx
+143
-83
app/[leaflet_id]/publish/PublishPost.tsx
···
6
6
import { Radio } from "components/Checkbox";
7
7
import { useParams } from "next/navigation";
8
8
import Link from "next/link";
9
-
import { AutosizeTextarea } from "components/utils/AutosizeTextarea";
9
+
10
10
import { PubLeafletPublication } from "lexicons/api";
11
11
import { publishPostToBsky } from "./publishBskyPost";
12
12
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
13
13
import { AtUri } from "@atproto/syntax";
14
14
import { PublishIllustration } from "./PublishIllustration/PublishIllustration";
15
15
import { useReplicache } from "src/replicache";
16
+
import { useSubscribe } from "src/replicache/useSubscribe";
16
17
import {
17
18
BlueskyPostEditorProsemirror,
18
19
editorStateToFacetedText,
19
20
} from "./BskyPostEditorProsemirror";
20
21
import { EditorState } from "prosemirror-state";
22
+
import { TagSelector } from "../../../components/Tags";
21
23
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
22
24
import { PubIcon } from "components/ActionBar/Publications";
23
25
···
31
33
record?: PubLeafletPublication.Record;
32
34
posts_in_pub?: number;
33
35
entitiesToDelete?: string[];
36
+
hasDraft: boolean;
34
37
};
35
38
36
39
export function PublishPost(props: Props) {
···
38
41
{ state: "default" } | { state: "success"; post_url: string }
39
42
>({ state: "default" });
40
43
return (
41
-
<div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center">
44
+
<div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center text-primary">
42
45
{publishState.state === "default" ? (
43
46
<PublishPostForm setPublishState={setPublishState} {...props} />
44
47
) : (
···
58
61
setPublishState: (s: { state: "success"; post_url: string }) => void;
59
62
} & Props,
60
63
) => {
64
+
let editorStateRef = useRef<EditorState | null>(null);
65
+
let [charCount, setCharCount] = useState(0);
61
66
let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
62
-
let editorStateRef = useRef<EditorState | null>(null);
63
67
let [isLoading, setIsLoading] = useState(false);
64
-
let [charCount, setCharCount] = useState(0);
65
68
let params = useParams();
66
69
let { rep } = useReplicache();
67
70
71
+
// For publications with drafts, use Replicache; otherwise use local state
72
+
let replicacheTags = useSubscribe(rep, (tx) =>
73
+
tx.get<string[]>("publication_tags"),
74
+
);
75
+
let [localTags, setLocalTags] = useState<string[]>([]);
76
+
77
+
// Use Replicache tags only when we have a draft
78
+
const hasDraft = props.hasDraft;
79
+
const currentTags = hasDraft
80
+
? Array.isArray(replicacheTags)
81
+
? replicacheTags
82
+
: []
83
+
: localTags;
84
+
85
+
// Update tags via Replicache mutation or local state depending on context
86
+
const handleTagsChange = async (newTags: string[]) => {
87
+
if (hasDraft) {
88
+
await rep?.mutate.updatePublicationDraft({
89
+
tags: newTags,
90
+
});
91
+
} else {
92
+
setLocalTags(newTags);
93
+
}
94
+
};
95
+
68
96
async function submit() {
69
97
if (isLoading) return;
70
98
setIsLoading(true);
···
75
103
leaflet_id: props.leaflet_id,
76
104
title: props.title,
77
105
description: props.description,
106
+
tags: currentTags,
78
107
entitiesToDelete: props.entitiesToDelete,
79
108
});
80
109
if (!doc) return;
···
109
138
submit();
110
139
}}
111
140
>
112
-
<div className="container flex flex-col gap-2 sm:p-3 p-4">
141
+
<div className="container flex flex-col gap-3 sm:p-3 p-4">
113
142
<PublishingTo
114
143
publication_uri={props.publication_uri}
115
144
record={props.record}
116
145
/>
117
-
<hr className="border-border-light my-1" />
118
-
<Radio
119
-
checked={shareOption === "quiet"}
120
-
onChange={(e) => {
121
-
if (e.target === e.currentTarget) {
122
-
setShareOption("quiet");
123
-
}
124
-
}}
125
-
name="share-options"
126
-
id="share-quietly"
127
-
value="Share Quietly"
128
-
>
129
-
<div className="flex flex-col">
130
-
<div className="font-bold">Share Quietly</div>
131
-
<div className="text-sm text-tertiary font-normal">
132
-
No one will be notified about this post
133
-
</div>
134
-
</div>
135
-
</Radio>
136
-
<Radio
137
-
checked={shareOption === "bluesky"}
138
-
onChange={(e) => {
139
-
if (e.target === e.currentTarget) {
140
-
setShareOption("bluesky");
141
-
}
142
-
}}
143
-
name="share-options"
144
-
id="share-bsky"
145
-
value="Share on Bluesky"
146
-
>
147
-
<div className="flex flex-col">
148
-
<div className="font-bold">Share on Bluesky</div>
149
-
<div className="text-sm text-tertiary font-normal">
150
-
Pub subscribers will be updated via a custom Bluesky feed
151
-
</div>
152
-
</div>
153
-
</Radio>
154
-
155
-
<div
156
-
className={`w-full pl-5 pb-4 ${shareOption !== "bluesky" ? "opacity-50" : ""}`}
157
-
>
158
-
<div className="opaque-container p-3 rounded-lg!">
159
-
<div className="flex gap-2">
160
-
<img
161
-
className="rounded-full w-[42px] h-[42px] shrink-0"
162
-
src={props.profile.avatar}
163
-
/>
164
-
<div className="flex flex-col w-full">
165
-
<div className="flex gap-2 pb-1">
166
-
<p className="font-bold">{props.profile.displayName}</p>
167
-
<p className="text-tertiary">@{props.profile.handle}</p>
168
-
</div>
169
-
<div className="flex flex-col">
170
-
<BlueskyPostEditorProsemirror
171
-
editorStateRef={editorStateRef}
172
-
onCharCountChange={setCharCount}
173
-
/>
174
-
</div>
175
-
<div className="opaque-container overflow-hidden flex flex-col mt-4 w-full">
176
-
<div className="flex flex-col p-2">
177
-
<div className="font-bold">{props.title}</div>
178
-
<div className="text-tertiary">{props.description}</div>
179
-
{props.record && (
180
-
<>
181
-
<hr className="border-border-light mt-2 mb-1" />
182
-
<p className="text-xs text-tertiary">
183
-
{props.record?.base_path}
184
-
</p>
185
-
</>
186
-
)}
187
-
</div>
188
-
</div>
189
-
<div className="text-xs text-secondary italic place-self-end pt-2">
190
-
{charCount}/300
191
-
</div>
192
-
</div>
193
-
</div>
194
-
</div>
146
+
<hr className="border-border" />
147
+
<ShareOptions
148
+
setShareOption={setShareOption}
149
+
shareOption={shareOption}
150
+
charCount={charCount}
151
+
setCharCount={setCharCount}
152
+
editorStateRef={editorStateRef}
153
+
{...props}
154
+
/>
155
+
<hr className="border-border " />
156
+
<div className="flex flex-col gap-2">
157
+
<h4>Tags</h4>
158
+
<TagSelector
159
+
selectedTags={currentTags}
160
+
setSelectedTags={handleTagsChange}
161
+
/>
195
162
</div>
163
+
<hr className="border-border mb-2" />
164
+
196
165
<div className="flex justify-between">
197
166
<Link
198
167
className="hover:no-underline! font-bold"
···
210
179
</div>
211
180
</div>
212
181
</form>
182
+
</div>
183
+
);
184
+
};
185
+
186
+
const ShareOptions = (props: {
187
+
shareOption: "quiet" | "bluesky";
188
+
setShareOption: (option: typeof props.shareOption) => void;
189
+
charCount: number;
190
+
setCharCount: (c: number) => void;
191
+
editorStateRef: React.MutableRefObject<EditorState | null>;
192
+
title: string;
193
+
profile: ProfileViewDetailed;
194
+
description: string;
195
+
record?: PubLeafletPublication.Record;
196
+
}) => {
197
+
return (
198
+
<div className="flex flex-col gap-2">
199
+
<h4>Notifications</h4>
200
+
<Radio
201
+
checked={props.shareOption === "quiet"}
202
+
onChange={(e) => {
203
+
if (e.target === e.currentTarget) {
204
+
props.setShareOption("quiet");
205
+
}
206
+
}}
207
+
name="share-options"
208
+
id="share-quietly"
209
+
value="Share Quietly"
210
+
>
211
+
<div className="flex flex-col">
212
+
<div className="font-bold">Share Quietly</div>
213
+
<div className="text-sm text-tertiary font-normal">
214
+
No one will be notified about this post
215
+
</div>
216
+
</div>
217
+
</Radio>
218
+
<Radio
219
+
checked={props.shareOption === "bluesky"}
220
+
onChange={(e) => {
221
+
if (e.target === e.currentTarget) {
222
+
props.setShareOption("bluesky");
223
+
}
224
+
}}
225
+
name="share-options"
226
+
id="share-bsky"
227
+
value="Share on Bluesky"
228
+
>
229
+
<div className="flex flex-col">
230
+
<div className="font-bold">Share on Bluesky</div>
231
+
<div className="text-sm text-tertiary font-normal">
232
+
Pub subscribers will be updated via a custom Bluesky feed
233
+
</div>
234
+
</div>
235
+
</Radio>
236
+
<div
237
+
className={`w-full pl-5 pb-4 ${props.shareOption !== "bluesky" ? "opacity-50" : ""}`}
238
+
>
239
+
<div className="opaque-container py-2 px-3 text-sm rounded-lg!">
240
+
<div className="flex gap-2">
241
+
<img
242
+
className="rounded-full w-6 h-6 sm:w-[42px] sm:h-[42px] shrink-0"
243
+
src={props.profile.avatar}
244
+
/>
245
+
<div className="flex flex-col w-full">
246
+
<div className="flex gap-2 ">
247
+
<p className="font-bold">{props.profile.displayName}</p>
248
+
<p className="text-tertiary">@{props.profile.handle}</p>
249
+
</div>
250
+
<div className="flex flex-col">
251
+
<BlueskyPostEditorProsemirror
252
+
editorStateRef={props.editorStateRef}
253
+
onCharCountChange={props.setCharCount}
254
+
/>
255
+
</div>
256
+
<div className="opaque-container !border-border overflow-hidden flex flex-col mt-4 w-full">
257
+
<div className="flex flex-col p-2">
258
+
<div className="font-bold">{props.title}</div>
259
+
<div className="text-tertiary">{props.description}</div>
260
+
<hr className="border-border mt-2 mb-1" />
261
+
<p className="text-xs text-tertiary">
262
+
{props.record?.base_path}
263
+
</p>
264
+
</div>
265
+
</div>
266
+
<div className="text-xs text-secondary italic place-self-end pt-2">
267
+
{props.charCount}/300
268
+
</div>
269
+
</div>
270
+
</div>
271
+
</div>
272
+
</div>
213
273
</div>
214
274
);
215
275
};
+8
-2
app/[leaflet_id]/publish/page.tsx
+8
-2
app/[leaflet_id]/publish/page.tsx
···
76
76
// Get title and description from either source
77
77
let title =
78
78
data.leaflets_in_publications[0]?.title ||
79
-
data.leaflets_to_documents?.title ||
79
+
data.leaflets_to_documents[0]?.title ||
80
80
decodeURIComponent((await props.searchParams).title || "");
81
81
let description =
82
82
data.leaflets_in_publications[0]?.description ||
83
-
data.leaflets_to_documents?.description ||
83
+
data.leaflets_to_documents[0]?.description ||
84
84
decodeURIComponent((await props.searchParams).description || "");
85
85
86
86
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
···
99
99
// If parsing fails, just use empty array
100
100
}
101
101
102
+
// Check if a draft record exists (either in a publication or standalone)
103
+
let hasDraft =
104
+
data.leaflets_in_publications.length > 0 ||
105
+
data.leaflets_to_documents.length > 0;
106
+
102
107
return (
103
108
<ReplicacheProvider
104
109
rootEntity={rootEntity}
···
116
121
record={publication?.record as PubLeafletPublication.Record | undefined}
117
122
posts_in_pub={publication?.documents_in_publications[0]?.count}
118
123
entitiesToDelete={entitiesToDelete}
124
+
hasDraft={hasDraft}
119
125
/>
120
126
</ReplicacheProvider>
121
127
);
+1
-1
app/api/link_previews/route.ts
+1
-1
app/api/link_previews/route.ts
+145
app/api/pub_icon/route.ts
+145
app/api/pub_icon/route.ts
···
1
+
import { AtUri } from "@atproto/syntax";
2
+
import { IdResolver } from "@atproto/identity";
3
+
import { NextRequest, NextResponse } from "next/server";
4
+
import { PubLeafletPublication } from "lexicons/api";
5
+
import { supabaseServerClient } from "supabase/serverClient";
6
+
import sharp from "sharp";
7
+
8
+
const idResolver = new IdResolver();
9
+
10
+
export const runtime = "nodejs";
11
+
12
+
export async function GET(req: NextRequest) {
13
+
const searchParams = req.nextUrl.searchParams;
14
+
const bgColor = searchParams.get("bg") || "#0000E1";
15
+
const fgColor = searchParams.get("fg") || "#FFFFFF";
16
+
17
+
try {
18
+
const at_uri = searchParams.get("at_uri");
19
+
20
+
if (!at_uri) {
21
+
return new NextResponse(null, { status: 400 });
22
+
}
23
+
24
+
// Parse the AT URI
25
+
let uri: AtUri;
26
+
try {
27
+
uri = new AtUri(at_uri);
28
+
} catch (e) {
29
+
return new NextResponse(null, { status: 400 });
30
+
}
31
+
32
+
let publicationRecord: PubLeafletPublication.Record | null = null;
33
+
let publicationUri: string;
34
+
35
+
// Check if it's a document or publication
36
+
if (uri.collection === "pub.leaflet.document") {
37
+
// Query the documents_in_publications table to get the publication
38
+
const { data: docInPub } = await supabaseServerClient
39
+
.from("documents_in_publications")
40
+
.select("publication, publications(record)")
41
+
.eq("document", at_uri)
42
+
.single();
43
+
44
+
if (!docInPub || !docInPub.publications) {
45
+
return new NextResponse(null, { status: 404 });
46
+
}
47
+
48
+
publicationUri = docInPub.publication;
49
+
publicationRecord = docInPub.publications
50
+
.record as PubLeafletPublication.Record;
51
+
} else if (uri.collection === "pub.leaflet.publication") {
52
+
// Query the publications table directly
53
+
const { data: publication } = await supabaseServerClient
54
+
.from("publications")
55
+
.select("record, uri")
56
+
.eq("uri", at_uri)
57
+
.single();
58
+
59
+
if (!publication || !publication.record) {
60
+
return new NextResponse(null, { status: 404 });
61
+
}
62
+
63
+
publicationUri = publication.uri;
64
+
publicationRecord = publication.record as PubLeafletPublication.Record;
65
+
} else {
66
+
// Not a supported collection
67
+
return new NextResponse(null, { status: 404 });
68
+
}
69
+
70
+
// Check if the publication has an icon
71
+
if (!publicationRecord?.icon) {
72
+
// Generate a placeholder with the first letter of the publication name
73
+
const firstLetter = (publicationRecord?.name || "?")
74
+
.slice(0, 1)
75
+
.toUpperCase();
76
+
77
+
// Create a simple SVG placeholder with theme colors
78
+
const svg = `<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg">
79
+
<rect width="96" height="96" rx="48" ry="48" fill="${bgColor}"/>
80
+
<text x="50%" y="50%" font-size="64" font-weight="bold" font-family="Arial, Helvetica, sans-serif" fill="${fgColor}" text-anchor="middle" dominant-baseline="central">${firstLetter}</text>
81
+
</svg>`;
82
+
83
+
return new NextResponse(svg, {
84
+
headers: {
85
+
"Content-Type": "image/svg+xml",
86
+
"Cache-Control":
87
+
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000",
88
+
"CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000",
89
+
},
90
+
});
91
+
}
92
+
93
+
// Parse the publication URI to get the DID
94
+
const pubUri = new AtUri(publicationUri);
95
+
96
+
// Get the CID from the icon blob
97
+
const cid = (publicationRecord.icon.ref as unknown as { $link: string })[
98
+
"$link"
99
+
];
100
+
101
+
// Fetch the blob from the PDS
102
+
const identity = await idResolver.did.resolve(pubUri.host);
103
+
const service = identity?.service?.find((f) => f.id === "#atproto_pds");
104
+
if (!service) return new NextResponse(null, { status: 404 });
105
+
106
+
const blobResponse = await fetch(
107
+
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${pubUri.host}&cid=${cid}`,
108
+
{
109
+
headers: {
110
+
"Accept-Encoding": "gzip, deflate, br, zstd",
111
+
},
112
+
},
113
+
);
114
+
115
+
if (!blobResponse.ok) {
116
+
return new NextResponse(null, { status: 404 });
117
+
}
118
+
119
+
// Get the image buffer
120
+
const imageBuffer = await blobResponse.arrayBuffer();
121
+
122
+
// Resize to 96x96 using Sharp
123
+
const resizedImage = await sharp(Buffer.from(imageBuffer))
124
+
.resize(96, 96, {
125
+
fit: "cover",
126
+
position: "center",
127
+
})
128
+
.webp({ quality: 90 })
129
+
.toBuffer();
130
+
131
+
// Return with caching headers
132
+
return new NextResponse(resizedImage, {
133
+
headers: {
134
+
"Content-Type": "image/webp",
135
+
// Cache for 1 hour, but serve stale for much longer while revalidating
136
+
"Cache-Control":
137
+
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=2592000",
138
+
"CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=2592000",
139
+
},
140
+
});
141
+
} catch (error) {
142
+
console.error("Error fetching publication icon:", error);
143
+
return new NextResponse(null, { status: 500 });
144
+
}
145
+
}
+1
-1
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
+1
-1
app/api/rpc/[command]/getFactsFromHomeLeaflets.ts
···
5
5
import type { Env } from "./route";
6
6
import { scanIndexLocal } from "src/replicache/utils";
7
7
import * as base64 from "base64-js";
8
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
8
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
9
9
import { applyUpdate, Doc } from "yjs";
10
10
11
11
export const getFactsFromHomeLeaflets = makeRoute({
+6
app/api/rpc/[command]/pull.ts
+6
app/api/rpc/[command]/pull.ts
···
73
73
let publication_data = data.publications as {
74
74
description: string;
75
75
title: string;
76
+
tags: string[];
76
77
}[];
77
78
let pub_patch = publication_data?.[0]
78
79
? [
···
85
86
op: "put",
86
87
key: "publication_title",
87
88
value: publication_data[0].title,
89
+
},
90
+
{
91
+
op: "put",
92
+
key: "publication_tags",
93
+
value: publication_data[0].tags || [],
88
94
},
89
95
]
90
96
: [];
+4
app/api/rpc/[command]/route.ts
+4
app/api/rpc/[command]/route.ts
···
11
11
} from "./domain_routes";
12
12
import { get_leaflet_data } from "./get_leaflet_data";
13
13
import { get_publication_data } from "./get_publication_data";
14
+
import { search_publication_names } from "./search_publication_names";
15
+
import { search_publication_documents } from "./search_publication_documents";
14
16
15
17
let supabase = createClient<Database>(
16
18
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
35
37
get_leaflet_subdomain_status,
36
38
get_leaflet_data,
37
39
get_publication_data,
40
+
search_publication_names,
41
+
search_publication_documents,
38
42
];
39
43
export async function POST(
40
44
req: Request,
+52
app/api/rpc/[command]/search_publication_documents.ts
+52
app/api/rpc/[command]/search_publication_documents.ts
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { z } from "zod";
3
+
import { makeRoute } from "../lib";
4
+
import type { Env } from "./route";
5
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
6
+
7
+
export type SearchPublicationDocumentsReturnType = Awaited<
8
+
ReturnType<(typeof search_publication_documents)["handler"]>
9
+
>;
10
+
11
+
export const search_publication_documents = makeRoute({
12
+
route: "search_publication_documents",
13
+
input: z.object({
14
+
publication_uri: z.string(),
15
+
query: z.string(),
16
+
limit: z.number().optional().default(10),
17
+
}),
18
+
handler: async (
19
+
{ publication_uri, query, limit },
20
+
{ supabase }: Pick<Env, "supabase">,
21
+
) => {
22
+
// Get documents in the publication, filtering by title using JSON operator
23
+
// Also join with publications to get the record for URL construction
24
+
const { data: documents, error } = await supabase
25
+
.from("documents_in_publications")
26
+
.select(
27
+
"document, documents!inner(uri, data), publications!inner(uri, record)",
28
+
)
29
+
.eq("publication", publication_uri)
30
+
.ilike("documents.data->>title", `%${query}%`)
31
+
.limit(limit);
32
+
33
+
if (error) {
34
+
throw new Error(
35
+
`Failed to search publication documents: ${error.message}`,
36
+
);
37
+
}
38
+
39
+
const result = documents.map((d) => {
40
+
const docUri = new AtUri(d.documents.uri);
41
+
const pubUrl = getPublicationURL(d.publications);
42
+
43
+
return {
44
+
uri: d.documents.uri,
45
+
title: (d.documents.data as { title?: string })?.title || "Untitled",
46
+
url: `${pubUrl}/${docUri.rkey}`,
47
+
};
48
+
});
49
+
50
+
return { result: { documents: result } };
51
+
},
52
+
});
+39
app/api/rpc/[command]/search_publication_names.ts
+39
app/api/rpc/[command]/search_publication_names.ts
···
1
+
import { z } from "zod";
2
+
import { makeRoute } from "../lib";
3
+
import type { Env } from "./route";
4
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
5
+
6
+
export type SearchPublicationNamesReturnType = Awaited<
7
+
ReturnType<(typeof search_publication_names)["handler"]>
8
+
>;
9
+
10
+
export const search_publication_names = makeRoute({
11
+
route: "search_publication_names",
12
+
input: z.object({
13
+
query: z.string(),
14
+
limit: z.number().optional().default(10),
15
+
}),
16
+
handler: async ({ query, limit }, { supabase }: Pick<Env, "supabase">) => {
17
+
// Search publications by name in record (case-insensitive partial match)
18
+
const { data: publications, error } = await supabase
19
+
.from("publications")
20
+
.select("uri, record")
21
+
.ilike("record->>name", `%${query}%`)
22
+
.limit(limit);
23
+
24
+
if (error) {
25
+
throw new Error(`Failed to search publications: ${error.message}`);
26
+
}
27
+
28
+
const result = publications.map((p) => {
29
+
const record = p.record as { name?: string };
30
+
return {
31
+
uri: p.uri,
32
+
name: record.name || "Untitled",
33
+
url: getPublicationURL(p),
34
+
};
35
+
});
36
+
37
+
return { result: { publications: result } };
38
+
},
39
+
});
+13
app/globals.css
+13
app/globals.css
···
211
211
212
212
/* END GLOBAL STYLING */
213
213
}
214
+
215
+
img {
216
+
font-size: 0;
217
+
}
218
+
214
219
button:hover {
215
220
cursor: pointer;
216
221
}
···
289
294
.selected .selection-highlight {
290
295
background-color: Highlight;
291
296
@apply py-[1.5px];
297
+
}
298
+
299
+
/* Underline mention nodes when selected in ProseMirror */
300
+
.ProseMirror .atMention.ProseMirror-selectednode,
301
+
.ProseMirror .didMention.ProseMirror-selectednode {
302
+
text-decoration: underline;
292
303
}
293
304
294
305
.ProseMirror:focus-within .selection-highlight {
···
414
425
outline: none !important;
415
426
cursor: pointer;
416
427
background-color: transparent;
428
+
display: flex;
429
+
gap: 0.5rem;
417
430
418
431
:hover {
419
432
text-decoration: none !important;
+36
-206
app/lish/Subscribe.tsx
+36
-206
app/lish/Subscribe.tsx
···
23
23
import { useSearchParams } from "next/navigation";
24
24
import LoginForm from "app/login/LoginForm";
25
25
import { RSSSmall } from "components/Icons/RSSSmall";
26
-
import { SpeedyLink } from "components/SpeedyLink";
27
-
28
-
type State =
29
-
| { state: "email" }
30
-
| { state: "code"; token: string }
31
-
| { state: "success" };
32
-
export const SubscribeButton = (props: {
33
-
compact?: boolean;
34
-
publication: string;
35
-
}) => {
36
-
let { identity, mutate } = useIdentityData();
37
-
let [emailInputValue, setEmailInputValue] = useState("");
38
-
let [codeInputValue, setCodeInputValue] = useState("");
39
-
let [state, setState] = useState<State>({ state: "email" });
40
-
41
-
if (state.state === "email") {
42
-
return (
43
-
<div className="flex gap-2">
44
-
<div className="flex relative w-full max-w-sm">
45
-
<Input
46
-
type="email"
47
-
className="input-with-border pr-[104px]! py-1! grow w-full"
48
-
placeholder={
49
-
props.compact ? "subscribe with email..." : "email here..."
50
-
}
51
-
disabled={!!identity?.email}
52
-
value={identity?.email ? identity.email : emailInputValue}
53
-
onChange={(e) => {
54
-
setEmailInputValue(e.currentTarget.value);
55
-
}}
56
-
/>
57
-
<ButtonPrimary
58
-
compact
59
-
className="absolute right-1 top-1 outline-0!"
60
-
onClick={async () => {
61
-
if (identity?.email) {
62
-
await subscribeToPublicationWithEmail(props.publication);
63
-
//optimistically could add!
64
-
await mutate();
65
-
return;
66
-
}
67
-
let tokenID = await requestAuthEmailToken(emailInputValue);
68
-
setState({ state: "code", token: tokenID });
69
-
}}
70
-
>
71
-
{props.compact ? (
72
-
<ArrowRightTiny className="w-4 h-6" />
73
-
) : (
74
-
"Subscribe"
75
-
)}
76
-
</ButtonPrimary>
77
-
</div>
78
-
{/* <ShareButton /> */}
79
-
</div>
80
-
);
81
-
}
82
-
if (state.state === "code") {
83
-
return (
84
-
<div
85
-
className="w-full flex flex-col justify-center place-items-center p-4 rounded-md"
86
-
style={{
87
-
background:
88
-
"color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
89
-
}}
90
-
>
91
-
<div className="flex flex-col leading-snug text-secondary">
92
-
<div>Please enter the code we sent to </div>
93
-
<div className="italic font-bold">{emailInputValue}</div>
94
-
</div>
95
-
96
-
<ConfirmCodeInput
97
-
publication={props.publication}
98
-
token={state.token}
99
-
codeInputValue={codeInputValue}
100
-
setCodeInputValue={setCodeInputValue}
101
-
setState={setState}
102
-
/>
103
-
104
-
<button
105
-
className="text-accent-contrast text-sm mt-1"
106
-
onClick={() => {
107
-
setState({ state: "email" });
108
-
}}
109
-
>
110
-
Re-enter Email
111
-
</button>
112
-
</div>
113
-
);
114
-
}
115
-
116
-
if (state.state === "success") {
117
-
return (
118
-
<div
119
-
className={`w-full flex flex-col gap-2 justify-center place-items-center p-4 rounded-md text-secondary ${props.compact ? "py-1 animate-bounce" : "p-4"}`}
120
-
style={{
121
-
background:
122
-
"color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
123
-
}}
124
-
>
125
-
<div className="flex gap-2 leading-snug font-bold italic">
126
-
<div>You're subscribed!</div>
127
-
{/* <ShareButton /> */}
128
-
</div>
129
-
</div>
130
-
);
131
-
}
132
-
};
133
-
134
-
export const ShareButton = () => {
135
-
return (
136
-
<button className="text-accent-contrast">
137
-
<ShareSmall />
138
-
</button>
139
-
);
140
-
};
141
-
142
-
const ConfirmCodeInput = (props: {
143
-
codeInputValue: string;
144
-
token: string;
145
-
setCodeInputValue: (value: string) => void;
146
-
setState: (state: State) => void;
147
-
publication: string;
148
-
}) => {
149
-
let { mutate } = useIdentityData();
150
-
return (
151
-
<div className="relative w-fit mt-2">
152
-
<Input
153
-
type="text"
154
-
pattern="[0-9]"
155
-
className="input-with-border pr-[88px]! py-1! max-w-[156px]"
156
-
placeholder="000000"
157
-
value={props.codeInputValue}
158
-
onChange={(e) => {
159
-
props.setCodeInputValue(e.currentTarget.value);
160
-
}}
161
-
/>
162
-
<ButtonPrimary
163
-
compact
164
-
className="absolute right-1 top-1 outline-0!"
165
-
onClick={async () => {
166
-
console.log(
167
-
await confirmEmailAuthToken(props.token, props.codeInputValue),
168
-
);
169
-
170
-
await subscribeToPublicationWithEmail(props.publication);
171
-
//optimistically could add!
172
-
await mutate();
173
-
props.setState({ state: "success" });
174
-
return;
175
-
}}
176
-
>
177
-
Confirm
178
-
</ButtonPrimary>
179
-
</div>
180
-
);
181
-
};
182
26
183
27
export const SubscribeWithBluesky = (props: {
184
-
isPost?: boolean;
185
28
pubName: string;
186
29
pub_uri: string;
187
30
base_url: string;
···
208
51
}
209
52
return (
210
53
<div className="flex flex-col gap-2 text-center justify-center">
211
-
{props.isPost && (
212
-
<div className="text-sm text-tertiary font-bold">
213
-
Get updates from {props.pubName}!
214
-
</div>
215
-
)}
216
54
<div className="flex flex-row gap-2 place-self-center">
217
55
<BlueskySubscribeButton
218
56
pub_uri={props.pub_uri}
···
231
69
);
232
70
};
233
71
234
-
const ManageSubscription = (props: {
235
-
isPost?: boolean;
236
-
pubName: string;
72
+
export const ManageSubscription = (props: {
237
73
pub_uri: string;
238
74
subscribers: { identity: string }[];
239
75
base_url: string;
···
248
84
});
249
85
}, null);
250
86
return (
251
-
<div
252
-
className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`}
87
+
<Popover
88
+
trigger={
89
+
<div className="text-accent-contrast text-sm">Manage Subscription</div>
90
+
}
253
91
>
254
-
<div className="font-bold text-tertiary text-sm">
255
-
You're Subscribed{props.isPost ? ` to ` : "!"}
256
-
{props.isPost && (
257
-
<SpeedyLink href={props.base_url} className="text-accent-contrast">
258
-
{props.pubName}
259
-
</SpeedyLink>
260
-
)}
261
-
</div>
262
-
<Popover
263
-
trigger={<div className="text-accent-contrast text-sm">Manage</div>}
264
-
>
265
-
<div className="max-w-sm flex flex-col gap-1">
266
-
<h4>Update Options</h4>
92
+
<div className="max-w-sm flex flex-col gap-1">
93
+
<h4>Update Options</h4>
267
94
268
-
{!hasFeed && (
269
-
<a
270
-
href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"
271
-
target="_blank"
272
-
className=" place-self-center"
273
-
>
274
-
<ButtonPrimary fullWidth compact className="!px-4">
275
-
View Bluesky Custom Feed
276
-
</ButtonPrimary>
277
-
</a>
278
-
)}
279
-
95
+
{!hasFeed && (
280
96
<a
281
-
href={`${props.base_url}/rss`}
282
-
className="flex"
97
+
href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"
283
98
target="_blank"
284
-
aria-label="Subscribe to RSS"
99
+
className=" place-self-center"
285
100
>
286
-
<ButtonPrimary fullWidth compact>
287
-
Get RSS
101
+
<ButtonPrimary fullWidth compact className="!px-4">
102
+
View Bluesky Custom Feed
288
103
</ButtonPrimary>
289
104
</a>
105
+
)}
290
106
291
-
<hr className="border-border-light my-1" />
107
+
<a
108
+
href={`${props.base_url}/rss`}
109
+
className="flex"
110
+
target="_blank"
111
+
aria-label="Subscribe to RSS"
112
+
>
113
+
<ButtonPrimary fullWidth compact>
114
+
Get RSS
115
+
</ButtonPrimary>
116
+
</a>
292
117
293
-
<form action={unsubscribe}>
294
-
<button className="font-bold text-accent-contrast w-max place-self-center">
295
-
{unsubscribePending ? <DotLoader /> : "Unsubscribe"}
296
-
</button>
297
-
</form>
298
-
</div>{" "}
299
-
</Popover>
300
-
</div>
118
+
<hr className="border-border-light my-1" />
119
+
120
+
<form action={unsubscribe}>
121
+
<button className="font-bold text-accent-contrast w-max place-self-center">
122
+
{unsubscribePending ? <DotLoader /> : "Unsubscribe"}
123
+
</button>
124
+
</form>
125
+
</div>
126
+
</Popover>
301
127
);
302
128
};
303
129
···
430
256
</Dialog.Root>
431
257
);
432
258
};
259
+
260
+
export const SubscribeOnPost = () => {
261
+
return <div></div>;
262
+
};
+30
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
+30
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
···
1
1
import { UnicodeString } from "@atproto/api";
2
2
import { PubLeafletRichtextFacet } from "lexicons/api";
3
+
import { didToBlueskyUrl } from "src/utils/mentionUtils";
4
+
import { AtMentionLink } from "components/AtMentionLink";
3
5
4
6
type Facet = PubLeafletRichtextFacet.Main;
5
7
export function BaseTextBlock(props: {
···
21
23
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
22
24
let isStrikethrough = segment.facet?.find(
23
25
PubLeafletRichtextFacet.isStrikethrough,
26
+
);
27
+
let isDidMention = segment.facet?.find(
28
+
PubLeafletRichtextFacet.isDidMention,
29
+
);
30
+
let isAtMention = segment.facet?.find(
31
+
PubLeafletRichtextFacet.isAtMention,
24
32
);
25
33
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
26
34
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
···
47
55
<code key={counter} className={className} id={id?.id}>
48
56
{renderedText}
49
57
</code>,
58
+
);
59
+
} else if (isDidMention) {
60
+
children.push(
61
+
<a
62
+
key={counter}
63
+
href={didToBlueskyUrl(isDidMention.did)}
64
+
className={`text-accent-contrast hover:underline cursor-pointer ${className}`}
65
+
target="_blank"
66
+
rel="noopener noreferrer"
67
+
>
68
+
{renderedText}
69
+
</a>,
70
+
);
71
+
} else if (isAtMention) {
72
+
children.push(
73
+
<AtMentionLink
74
+
key={counter}
75
+
atURI={isAtMention.atURI}
76
+
className={className}
77
+
>
78
+
{renderedText}
79
+
</AtMentionLink>,
50
80
);
51
81
} else if (link) {
52
82
children.push(
+6
-5
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+6
-5
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
22
22
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
23
23
import { PollData } from "./fetchPollData";
24
24
import { SharedPageProps } from "./PostPages";
25
+
import { useIsMobile } from "src/hooks/isMobile";
25
26
26
27
export function CanvasPage({
27
28
blocks,
···
206
207
quotesCount: number | undefined;
207
208
commentsCount: number | undefined;
208
209
}) => {
210
+
let isMobile = useIsMobile();
209
211
return (
210
-
<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">
212
+
<div className="flex flex-row gap-3 items-center absolute top-3 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
211
213
<Interactions
212
214
quotesCount={props.quotesCount || 0}
213
215
commentsCount={props.commentsCount || 0}
214
-
compact
215
216
showComments={props.preferences.showComments}
216
217
pageId={props.pageId}
217
218
/>
···
219
220
<>
220
221
<Separator classname="h-5" />
221
222
<Popover
222
-
side="left"
223
-
align="start"
224
-
className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]"
223
+
side="bottom"
224
+
align="end"
225
+
className={`flex flex-col gap-2 p-0! text-primary ${isMobile ? "w-full" : "max-w-sm w-[1000px] t"}`}
225
226
trigger={<InfoSmall />}
226
227
>
227
228
<PostHeader
+223
-11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
+223
-11
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
···
8
8
import { EditorState, TextSelection } from "prosemirror-state";
9
9
import { EditorView } from "prosemirror-view";
10
10
import { history, redo, undo } from "prosemirror-history";
11
+
import { InputRule, inputRules } from "prosemirror-inputrules";
11
12
import {
12
13
MutableRefObject,
13
14
RefObject,
15
+
useCallback,
14
16
useEffect,
15
17
useLayoutEffect,
16
18
useRef,
···
36
38
import { CloseTiny } from "components/Icons/CloseTiny";
37
39
import { CloseFillTiny } from "components/Icons/CloseFillTiny";
38
40
import { betterIsUrl } from "src/utils/isURL";
41
+
import { Mention, MentionAutocomplete } from "components/Mention";
42
+
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
43
+
44
+
const addMentionToEditor = (
45
+
mention: Mention,
46
+
range: { from: number; to: number },
47
+
view: EditorView,
48
+
) => {
49
+
if (!view) return;
50
+
const { from, to } = range;
51
+
const tr = view.state.tr;
52
+
53
+
if (mention.type === "did") {
54
+
// Delete the @ and any query text
55
+
tr.delete(from, to);
56
+
// Insert didMention inline node
57
+
const mentionText = "@" + mention.handle;
58
+
const didMentionNode = multiBlockSchema.nodes.didMention.create({
59
+
did: mention.did,
60
+
text: mentionText,
61
+
});
62
+
tr.insert(from, didMentionNode);
63
+
// Add a space after the mention
64
+
tr.insertText(" ", from + 1);
65
+
}
66
+
if (mention.type === "publication" || mention.type === "post") {
67
+
// Delete the @ and any query text
68
+
tr.delete(from, to);
69
+
let name = mention.type === "post" ? mention.title : mention.name;
70
+
// Insert atMention inline node
71
+
const atMentionNode = multiBlockSchema.nodes.atMention.create({
72
+
atURI: mention.uri,
73
+
text: name,
74
+
});
75
+
tr.insert(from, atMentionNode);
76
+
// Add a space after the mention
77
+
tr.insertText(" ", from + 1);
78
+
}
79
+
80
+
view.dispatch(tr);
81
+
view.focus();
82
+
};
39
83
40
84
export function CommentBox(props: {
41
85
doc_uri: string;
···
50
94
commentBox: { quote },
51
95
} = useInteractionState(props.doc_uri);
52
96
let [loading, setLoading] = useState(false);
97
+
let view = useRef<null | EditorView>(null);
98
+
99
+
// Mention autocomplete state
100
+
const [mentionOpen, setMentionOpen] = useState(false);
101
+
const [mentionCoords, setMentionCoords] = useState<{
102
+
top: number;
103
+
left: number;
104
+
} | null>(null);
105
+
// Use a ref for insert position to avoid stale closure issues
106
+
const mentionInsertPosRef = useRef<number | null>(null);
107
+
108
+
// Use a ref for the callback so input rules can access it
109
+
const openMentionAutocompleteRef = useRef<() => void>(() => {});
110
+
openMentionAutocompleteRef.current = () => {
111
+
if (!view.current) return;
53
112
54
-
const handleSubmit = async () => {
113
+
const pos = view.current.state.selection.from;
114
+
mentionInsertPosRef.current = pos;
115
+
116
+
// Get coordinates for the popup relative to the positioned parent
117
+
const coords = view.current.coordsAtPos(pos - 1);
118
+
119
+
// Find the relative positioned parent container
120
+
const editorEl = view.current.dom;
121
+
const container = editorEl.closest(".relative") as HTMLElement | null;
122
+
123
+
if (container) {
124
+
const containerRect = container.getBoundingClientRect();
125
+
setMentionCoords({
126
+
top: coords.bottom - containerRect.top,
127
+
left: coords.left - containerRect.left,
128
+
});
129
+
} else {
130
+
setMentionCoords({
131
+
top: coords.bottom,
132
+
left: coords.left,
133
+
});
134
+
}
135
+
setMentionOpen(true);
136
+
};
137
+
138
+
const handleMentionSelect = useCallback((mention: Mention) => {
139
+
if (!view.current || mentionInsertPosRef.current === null) return;
140
+
141
+
const from = mentionInsertPosRef.current - 1;
142
+
const to = mentionInsertPosRef.current;
143
+
144
+
addMentionToEditor(mention, { from, to }, view.current);
145
+
view.current.focus();
146
+
}, []);
147
+
148
+
const handleMentionOpenChange = useCallback((open: boolean) => {
149
+
setMentionOpen(open);
150
+
if (!open) {
151
+
setMentionCoords(null);
152
+
mentionInsertPosRef.current = null;
153
+
}
154
+
}, []);
155
+
156
+
// Use a ref for handleSubmit so keyboard shortcuts can access it
157
+
const handleSubmitRef = useRef<() => Promise<void>>(async () => {});
158
+
handleSubmitRef.current = async () => {
55
159
if (loading || !view.current) return;
56
160
57
161
setLoading(true);
···
114
218
"Mod-y": redo,
115
219
"Shift-Mod-z": redo,
116
220
"Ctrl-Enter": () => {
117
-
handleSubmit();
221
+
handleSubmitRef.current();
118
222
return true;
119
223
},
120
224
"Meta-Enter": () => {
121
-
handleSubmit();
225
+
handleSubmitRef.current();
122
226
return true;
123
227
},
124
228
}),
···
128
232
shouldAutoLink: () => true,
129
233
defaultProtocol: "https",
130
234
}),
235
+
// Input rules for @ mentions
236
+
inputRules({
237
+
rules: [
238
+
// @ at start of line or after space
239
+
new InputRule(/(?:^|\s)@$/, (state, match, start, end) => {
240
+
setTimeout(() => openMentionAutocompleteRef.current(), 0);
241
+
return null;
242
+
}),
243
+
],
244
+
}),
131
245
history(),
132
246
],
133
247
}),
134
248
);
135
-
let view = useRef<null | EditorView>(null);
136
249
useLayoutEffect(() => {
137
250
if (!mountRef.current) return;
138
251
view.current = new EditorView(
···
187
300
handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
188
301
if (!direct) return;
189
302
if (node.nodeSize - 2 <= _pos) return;
303
+
304
+
const nodeAt1 = node.nodeAt(_pos - 1);
305
+
const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
306
+
307
+
// Check for link marks
190
308
let mark =
191
-
node
192
-
.nodeAt(_pos - 1)
193
-
?.marks.find((f) => f.type === multiBlockSchema.marks.link) ||
194
-
node
195
-
.nodeAt(Math.max(_pos - 2, 0))
196
-
?.marks.find((f) => f.type === multiBlockSchema.marks.link);
309
+
nodeAt1?.marks.find(
310
+
(f) => f.type === multiBlockSchema.marks.link,
311
+
) ||
312
+
nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link);
197
313
if (mark) {
198
314
window.open(mark.attrs.href, "_blank");
315
+
return;
316
+
}
317
+
318
+
// Check for didMention inline nodes
319
+
if (nodeAt1?.type === multiBlockSchema.nodes.didMention) {
320
+
window.open(
321
+
didToBlueskyUrl(nodeAt1.attrs.did),
322
+
"_blank",
323
+
"noopener,noreferrer",
324
+
);
325
+
return;
326
+
}
327
+
if (nodeAt2?.type === multiBlockSchema.nodes.didMention) {
328
+
window.open(
329
+
didToBlueskyUrl(nodeAt2.attrs.did),
330
+
"_blank",
331
+
"noopener,noreferrer",
332
+
);
333
+
return;
334
+
}
335
+
336
+
// Check for atMention inline nodes (publications/documents)
337
+
if (nodeAt1?.type === multiBlockSchema.nodes.atMention) {
338
+
window.open(
339
+
atUriToUrl(nodeAt1.attrs.atURI),
340
+
"_blank",
341
+
"noopener,noreferrer",
342
+
);
343
+
return;
344
+
}
345
+
if (nodeAt2?.type === multiBlockSchema.nodes.atMention) {
346
+
window.open(
347
+
atUriToUrl(nodeAt2.attrs.atURI),
348
+
"_blank",
349
+
"noopener,noreferrer",
350
+
);
351
+
return;
199
352
}
200
353
},
201
354
dispatchTransaction(tr) {
···
236
389
<div className="w-full relative group">
237
390
<pre
238
391
ref={mountRef}
392
+
onFocus={() => {
393
+
// Close mention dropdown when editor gains focus (reset stale state)
394
+
handleMentionOpenChange(false);
395
+
}}
396
+
onBlur={(e) => {
397
+
// Close mention dropdown when editor loses focus
398
+
// But not if focus moved to the mention autocomplete
399
+
const relatedTarget = e.relatedTarget as HTMLElement | null;
400
+
if (!relatedTarget?.closest(".dropdownMenu")) {
401
+
handleMentionOpenChange(false);
402
+
}
403
+
}}
239
404
className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`}
240
405
/>
241
406
<IOSBS view={view} />
407
+
<MentionAutocomplete
408
+
open={mentionOpen}
409
+
onOpenChange={handleMentionOpenChange}
410
+
view={view}
411
+
onSelect={handleMentionSelect}
412
+
coords={mentionCoords}
413
+
/>
242
414
</div>
243
415
<div className="flex justify-between pt-1">
244
416
<div className="flex gap-1">
···
261
433
view={view}
262
434
/>
263
435
</div>
264
-
<ButtonPrimary compact onClick={handleSubmit}>
436
+
<ButtonPrimary compact onClick={() => handleSubmitRef.current()}>
265
437
{loading ? <DotLoader /> : <ShareSmall />}
266
438
</ButtonPrimary>
267
439
</div>
···
328
500
facets.push(facet);
329
501
}
330
502
}
503
+
504
+
fullText += text;
505
+
byteOffset += unicodeString.length;
506
+
} else if (node.type.name === "didMention") {
507
+
// Handle DID mention nodes
508
+
const text = node.attrs.text || "";
509
+
const unicodeString = new UnicodeString(text);
510
+
511
+
facets.push({
512
+
index: {
513
+
byteStart: byteOffset,
514
+
byteEnd: byteOffset + unicodeString.length,
515
+
},
516
+
features: [
517
+
{
518
+
$type: "pub.leaflet.richtext.facet#didMention",
519
+
did: node.attrs.did,
520
+
},
521
+
],
522
+
});
523
+
524
+
fullText += text;
525
+
byteOffset += unicodeString.length;
526
+
} else if (node.type.name === "atMention") {
527
+
// Handle AT-URI mention nodes (publications and documents)
528
+
const text = node.attrs.text || "";
529
+
const unicodeString = new UnicodeString(text);
530
+
531
+
facets.push({
532
+
index: {
533
+
byteStart: byteOffset,
534
+
byteEnd: byteOffset + unicodeString.length,
535
+
},
536
+
features: [
537
+
{
538
+
$type: "pub.leaflet.richtext.facet#atMention",
539
+
atURI: node.attrs.atURI,
540
+
},
541
+
],
542
+
});
331
543
332
544
fullText += text;
333
545
byteOffset += unicodeString.length;
+98
-1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
+98
-1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
···
10
10
import { Json } from "supabase/database.types";
11
11
import {
12
12
Notification,
13
+
NotificationData,
13
14
pingIdentityToUpdateNotification,
14
15
} from "src/notifications";
15
16
import { v7 } from "uuid";
···
84
85
parent_uri: args.comment.replyTo,
85
86
},
86
87
});
88
+
}
89
+
90
+
// Create mention notifications from comment facets
91
+
const mentionNotifications = createCommentMentionNotifications(
92
+
args.comment.facets,
93
+
uri.toString(),
94
+
credentialSession.did!,
95
+
);
96
+
notifications.push(...mentionNotifications);
97
+
98
+
// Insert all notifications and ping recipients
99
+
if (notifications.length > 0) {
87
100
// SOMEDAY: move this out the action with inngest or workflows
88
101
await supabaseServerClient.from("notifications").insert(notifications);
89
-
await pingIdentityToUpdateNotification(recipient);
102
+
103
+
// Ping all unique recipients
104
+
const uniqueRecipients = [...new Set(notifications.map((n) => n.recipient))];
105
+
await Promise.all(
106
+
uniqueRecipients.map((r) => pingIdentityToUpdateNotification(r)),
107
+
);
90
108
}
91
109
92
110
return {
···
95
113
uri: uri.toString(),
96
114
};
97
115
}
116
+
117
+
/**
118
+
* Creates mention notifications from comment facets
119
+
* Handles didMention (people) and atMention (publications/documents)
120
+
*/
121
+
function createCommentMentionNotifications(
122
+
facets: PubLeafletRichtextFacet.Main[],
123
+
commentUri: string,
124
+
commenterDid: string,
125
+
): Notification[] {
126
+
const notifications: Notification[] = [];
127
+
const notifiedRecipients = new Set<string>(); // Avoid duplicate notifications
128
+
129
+
for (const facet of facets) {
130
+
for (const feature of facet.features) {
131
+
if (PubLeafletRichtextFacet.isDidMention(feature)) {
132
+
// DID mention - notify the mentioned person directly
133
+
const recipientDid = feature.did;
134
+
135
+
// Don't notify yourself
136
+
if (recipientDid === commenterDid) continue;
137
+
// Avoid duplicate notifications to the same person
138
+
if (notifiedRecipients.has(recipientDid)) continue;
139
+
notifiedRecipients.add(recipientDid);
140
+
141
+
notifications.push({
142
+
id: v7(),
143
+
recipient: recipientDid,
144
+
data: {
145
+
type: "comment_mention",
146
+
comment_uri: commentUri,
147
+
mention_type: "did",
148
+
},
149
+
});
150
+
} else if (PubLeafletRichtextFacet.isAtMention(feature)) {
151
+
// AT-URI mention - notify the owner of the publication/document
152
+
try {
153
+
const mentionedUri = new AtUri(feature.atURI);
154
+
const recipientDid = mentionedUri.host;
155
+
156
+
// Don't notify yourself
157
+
if (recipientDid === commenterDid) continue;
158
+
// Avoid duplicate notifications to the same person for the same mentioned item
159
+
const dedupeKey = `${recipientDid}:${feature.atURI}`;
160
+
if (notifiedRecipients.has(dedupeKey)) continue;
161
+
notifiedRecipients.add(dedupeKey);
162
+
163
+
if (mentionedUri.collection === "pub.leaflet.publication") {
164
+
notifications.push({
165
+
id: v7(),
166
+
recipient: recipientDid,
167
+
data: {
168
+
type: "comment_mention",
169
+
comment_uri: commentUri,
170
+
mention_type: "publication",
171
+
mentioned_uri: feature.atURI,
172
+
},
173
+
});
174
+
} else if (mentionedUri.collection === "pub.leaflet.document") {
175
+
notifications.push({
176
+
id: v7(),
177
+
recipient: recipientDid,
178
+
data: {
179
+
type: "comment_mention",
180
+
comment_uri: commentUri,
181
+
mention_type: "document",
182
+
mentioned_uri: feature.atURI,
183
+
},
184
+
});
185
+
}
186
+
} catch (error) {
187
+
console.error("Failed to parse AT-URI for mention:", feature.atURI, error);
188
+
}
189
+
}
190
+
}
191
+
}
192
+
193
+
return notifications;
194
+
}
+208
-30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
+208
-30
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
···
9
9
import { useContext } from "react";
10
10
import { PostPageContext } from "../PostPageContext";
11
11
import { scrollIntoView } from "src/utils/scrollIntoView";
12
+
import { TagTiny } from "components/Icons/TagTiny";
13
+
import { Tag } from "components/Tags";
14
+
import { Popover } from "components/Popover";
12
15
import { PostPageData } from "../getPostPageData";
13
-
import { PubLeafletComment } from "lexicons/api";
16
+
import { PubLeafletComment, PubLeafletPublication } from "lexicons/api";
14
17
import { prefetchQuotesData } from "./Quotes";
18
+
import { useIdentityData } from "components/IdentityProvider";
19
+
import { ManageSubscription, SubscribeWithBluesky } from "app/lish/Subscribe";
20
+
import { EditTiny } from "components/Icons/EditTiny";
21
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
15
22
16
23
export type InteractionState = {
17
24
drawerOpen: undefined | boolean;
···
99
106
export const Interactions = (props: {
100
107
quotesCount: number;
101
108
commentsCount: number;
102
-
compact?: boolean;
103
109
className?: string;
104
110
showComments?: boolean;
105
111
pageId?: string;
106
112
}) => {
107
113
const data = useContext(PostPageContext);
108
114
const document_uri = data?.uri;
115
+
let { identity } = useIdentityData();
109
116
if (!document_uri)
110
117
throw new Error("document_uri not available in PostPageContext");
111
118
···
117
124
}
118
125
};
119
126
127
+
const tags = (data?.data as any)?.tags as string[] | undefined;
128
+
const tagCount = tags?.length || 0;
129
+
120
130
return (
121
-
<div
122
-
className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : "px-3 sm:px-4"} ${props.className}`}
123
-
>
124
-
<button
125
-
className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`}
126
-
onClick={() => {
127
-
if (!drawerOpen || drawer !== "quotes")
128
-
openInteractionDrawer("quotes", document_uri, props.pageId);
129
-
else setInteractionState(document_uri, { drawerOpen: false });
130
-
}}
131
-
onMouseEnter={handleQuotePrefetch}
132
-
onTouchStart={handleQuotePrefetch}
133
-
aria-label="Post quotes"
134
-
>
135
-
<QuoteTiny aria-hidden /> {props.quotesCount}{" "}
136
-
{!props.compact && (
137
-
<span
138
-
aria-hidden
139
-
>{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span>
140
-
)}
141
-
</button>
131
+
<div className={`flex gap-2 text-tertiary text-sm ${props.className}`}>
132
+
{tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />}
133
+
134
+
{props.quotesCount > 0 && (
135
+
<button
136
+
className="flex w-fit gap-2 items-center"
137
+
onClick={() => {
138
+
if (!drawerOpen || drawer !== "quotes")
139
+
openInteractionDrawer("quotes", document_uri, props.pageId);
140
+
else setInteractionState(document_uri, { drawerOpen: false });
141
+
}}
142
+
onMouseEnter={handleQuotePrefetch}
143
+
onTouchStart={handleQuotePrefetch}
144
+
aria-label="Post quotes"
145
+
>
146
+
<QuoteTiny aria-hidden /> {props.quotesCount}
147
+
</button>
148
+
)}
142
149
{props.showComments === false ? null : (
143
150
<button
144
-
className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`}
151
+
className="flex gap-2 items-center w-fit"
145
152
onClick={() => {
146
153
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
147
154
openInteractionDrawer("comments", document_uri, props.pageId);
···
149
156
}}
150
157
aria-label="Post comments"
151
158
>
152
-
<CommentTiny aria-hidden /> {props.commentsCount}{" "}
153
-
{!props.compact && (
154
-
<span
155
-
aria-hidden
156
-
>{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span>
157
-
)}
159
+
<CommentTiny aria-hidden /> {props.commentsCount}
158
160
</button>
159
161
)}
160
162
</div>
161
163
);
162
164
};
163
165
166
+
export const ExpandedInteractions = (props: {
167
+
quotesCount: number;
168
+
commentsCount: number;
169
+
className?: string;
170
+
showComments?: boolean;
171
+
pageId?: string;
172
+
}) => {
173
+
const data = useContext(PostPageContext);
174
+
let { identity } = useIdentityData();
175
+
176
+
const document_uri = data?.uri;
177
+
if (!document_uri)
178
+
throw new Error("document_uri not available in PostPageContext");
179
+
180
+
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
181
+
182
+
const handleQuotePrefetch = () => {
183
+
if (data?.quotesAndMentions) {
184
+
prefetchQuotesData(data.quotesAndMentions);
185
+
}
186
+
};
187
+
let publication = data?.documents_in_publications[0]?.publications;
188
+
189
+
const tags = (data?.data as any)?.tags as string[] | undefined;
190
+
const tagCount = tags?.length || 0;
191
+
192
+
let subscribed =
193
+
identity?.atp_did &&
194
+
publication?.publication_subscriptions &&
195
+
publication?.publication_subscriptions.find(
196
+
(s) => s.identity === identity.atp_did,
197
+
);
198
+
199
+
let isAuthor =
200
+
identity &&
201
+
identity.atp_did ===
202
+
data.documents_in_publications[0]?.publications?.identity_did &&
203
+
data.leaflets_in_publications[0];
204
+
205
+
return (
206
+
<div
207
+
className={`text-tertiary px-3 sm:px-4 flex flex-col ${props.className}`}
208
+
>
209
+
{!subscribed && !isAuthor && publication && publication.record && (
210
+
<div className="text-center flex flex-col accent-container rounded-md mb-3">
211
+
<div className="flex flex-col py-4">
212
+
<div className="leading-snug flex flex-col pb-2 text-sm">
213
+
<div className="font-bold">Subscribe to {publication.name}</div>{" "}
214
+
to get updates in Reader, RSS, or via Bluesky Feed
215
+
</div>
216
+
<SubscribeWithBluesky
217
+
pubName={publication.name}
218
+
pub_uri={publication.uri}
219
+
base_url={
220
+
(publication.record as PubLeafletPublication.Record)
221
+
.base_path || ""
222
+
}
223
+
subscribers={publication?.publication_subscriptions}
224
+
/>
225
+
</div>
226
+
</div>
227
+
)}
228
+
{tagCount > 0 && (
229
+
<>
230
+
<hr className="border-border-light mb-3" />
231
+
232
+
<TagList tags={tags} className="mb-3" />
233
+
</>
234
+
)}
235
+
<hr className="border-border-light mb-3 " />
236
+
<div className="flex gap-2 justify-between">
237
+
<div className="flex gap-2">
238
+
{props.quotesCount > 0 && (
239
+
<button
240
+
className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
241
+
onClick={() => {
242
+
if (!drawerOpen || drawer !== "quotes")
243
+
openInteractionDrawer("quotes", document_uri, props.pageId);
244
+
else setInteractionState(document_uri, { drawerOpen: false });
245
+
}}
246
+
onMouseEnter={handleQuotePrefetch}
247
+
onTouchStart={handleQuotePrefetch}
248
+
aria-label="Post quotes"
249
+
>
250
+
<QuoteTiny aria-hidden /> {props.quotesCount}{" "}
251
+
<span
252
+
aria-hidden
253
+
>{`Quote${props.quotesCount === 1 ? "" : "s"}`}</span>
254
+
</button>
255
+
)}
256
+
{props.showComments === false ? null : (
257
+
<button
258
+
className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"
259
+
onClick={() => {
260
+
if (
261
+
!drawerOpen ||
262
+
drawer !== "comments" ||
263
+
pageId !== props.pageId
264
+
)
265
+
openInteractionDrawer("comments", document_uri, props.pageId);
266
+
else setInteractionState(document_uri, { drawerOpen: false });
267
+
}}
268
+
aria-label="Post comments"
269
+
>
270
+
<CommentTiny aria-hidden />{" "}
271
+
{props.commentsCount > 0 ? (
272
+
<span aria-hidden>
273
+
{`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`}
274
+
</span>
275
+
) : (
276
+
"Comment"
277
+
)}
278
+
</button>
279
+
)}
280
+
</div>
281
+
<EditButton document={data} />
282
+
{subscribed && publication && (
283
+
<ManageSubscription
284
+
base_url={getPublicationURL(publication)}
285
+
pub_uri={publication.uri}
286
+
subscribers={publication.publication_subscriptions}
287
+
/>
288
+
)}
289
+
</div>
290
+
</div>
291
+
);
292
+
};
293
+
294
+
const TagPopover = (props: {
295
+
tagCount: number;
296
+
tags: string[] | undefined;
297
+
}) => {
298
+
return (
299
+
<Popover
300
+
className="p-2! max-w-xs"
301
+
trigger={
302
+
<div className="tags flex gap-1 items-center ">
303
+
<TagTiny /> {props.tagCount}
304
+
</div>
305
+
}
306
+
>
307
+
<TagList tags={props.tags} className="text-secondary!" />
308
+
</Popover>
309
+
);
310
+
};
311
+
312
+
const TagList = (props: { className?: string; tags: string[] | undefined }) => {
313
+
if (!props.tags) return;
314
+
return (
315
+
<div className="flex gap-1 flex-wrap">
316
+
{props.tags.map((tag, index) => (
317
+
<Tag name={tag} key={index} className={props.className} />
318
+
))}
319
+
</div>
320
+
);
321
+
};
164
322
export function getQuoteCount(document: PostPageData, pageId?: string) {
165
323
if (!document) return;
166
324
return getQuoteCountFromArray(document.quotesAndMentions, pageId);
···
198
356
(c) => !(c.record as PubLeafletComment.Record)?.onPage,
199
357
).length;
200
358
}
359
+
360
+
const EditButton = (props: { document: PostPageData }) => {
361
+
let { identity } = useIdentityData();
362
+
if (!props.document) return;
363
+
if (
364
+
identity &&
365
+
identity.atp_did ===
366
+
props.document.documents_in_publications[0]?.publications?.identity_did &&
367
+
props.document.leaflets_in_publications[0]
368
+
)
369
+
return (
370
+
<a
371
+
href={`https://leaflet.pub/${props.document.leaflets_in_publications[0]?.leaflet}`}
372
+
className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1"
373
+
>
374
+
<EditTiny /> Edit Post
375
+
</a>
376
+
);
377
+
return;
378
+
};
+4
-40
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+4
-40
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
···
11
11
import { SubscribeWithBluesky } from "app/lish/Subscribe";
12
12
import { EditTiny } from "components/Icons/EditTiny";
13
13
import {
14
+
ExpandedInteractions,
14
15
getCommentCount,
15
16
getQuoteCount,
16
17
Interactions,
···
47
48
fullPageScroll,
48
49
hasPageBackground,
49
50
} = props;
50
-
let { identity } = useIdentityData();
51
51
let drawer = useDrawerOpen(document_uri);
52
52
53
53
if (!document) return null;
···
84
84
did={did}
85
85
prerenderedCodeBlocks={prerenderedCodeBlocks}
86
86
/>
87
-
<Interactions
87
+
88
+
<ExpandedInteractions
88
89
pageId={pageId}
89
90
showComments={preferences.showComments}
90
91
commentsCount={getCommentCount(document, pageId) || 0}
91
92
quotesCount={getQuoteCount(document, pageId) || 0}
92
93
/>
93
-
{!isSubpage && (
94
-
<>
95
-
<hr className="border-border-light mb-4 mt-4 sm:mx-4 mx-3" />
96
-
<div className="sm:px-4 px-3">
97
-
{identity &&
98
-
identity.atp_did ===
99
-
document.documents_in_publications[0]?.publications
100
-
?.identity_did &&
101
-
document.leaflets_in_publications[0] ? (
102
-
<a
103
-
href={`https://leaflet.pub/${document.leaflets_in_publications[0]?.leaflet}`}
104
-
className="flex gap-2 items-center hover:!no-underline selected-outline px-2 py-0.5 bg-accent-1 text-accent-2 font-bold w-fit rounded-lg !border-accent-1 !outline-accent-1 mx-auto"
105
-
>
106
-
<EditTiny /> Edit Post
107
-
</a>
108
-
) : (
109
-
document.documents_in_publications[0]?.publications && (
110
-
<SubscribeWithBluesky
111
-
isPost
112
-
base_url={getPublicationURL(
113
-
document.documents_in_publications[0].publications,
114
-
)}
115
-
pub_uri={
116
-
document.documents_in_publications[0].publications.uri
117
-
}
118
-
subscribers={
119
-
document.documents_in_publications[0].publications
120
-
.publication_subscriptions
121
-
}
122
-
pubName={
123
-
document.documents_in_publications[0].publications.name
124
-
}
125
-
/>
126
-
)
127
-
)}
128
-
</div>
129
-
</>
130
-
)}
94
+
{!hasPageBackground && <div className={`spacer h-8 w-full`} />}
131
95
</PageWrapper>
132
96
</>
133
97
);
+11
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+11
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
···
59
59
return (
60
60
<div
61
61
//The postContent class is important for QuoteHandler
62
-
className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-6 ${className}`}
62
+
className={`postContent flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 ${className}`}
63
63
>
64
64
{blocks.map((b, index) => {
65
65
return (
···
293
293
}
294
294
case PubLeafletBlocksImage.isMain(b.block): {
295
295
return (
296
-
<div className={`relative flex ${alignment}`} {...blockProps}>
296
+
<div
297
+
className={`imageBlock relative flex ${alignment}`}
298
+
{...blockProps}
299
+
>
297
300
<img
298
301
alt={b.block.alt}
299
302
height={b.block.aspectRatio?.height}
···
321
324
return (
322
325
// all this margin stuff is a highly unfortunate hack so that the border-l on blockquote is the height of just the text rather than the height of the block, which includes padding.
323
326
<blockquote
324
-
className={` blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`}
327
+
className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`}
325
328
{...blockProps}
326
329
>
327
330
<TextBlock
···
336
339
}
337
340
case PubLeafletBlocksText.isMain(b.block):
338
341
return (
339
-
<p className={` ${className}`} {...blockProps}>
342
+
<p className={`textBlock ${className}`} {...blockProps}>
340
343
<TextBlock
341
344
facets={b.block.facets}
342
345
plaintext={b.block.plaintext}
···
349
352
case PubLeafletBlocksHeader.isMain(b.block): {
350
353
if (b.block.level === 1)
351
354
return (
352
-
<h2 className={`${className}`} {...blockProps}>
355
+
<h2 className={`h1Block ${className}`} {...blockProps}>
353
356
<TextBlock
354
357
{...b.block}
355
358
index={index}
···
360
363
);
361
364
if (b.block.level === 2)
362
365
return (
363
-
<h3 className={`${className}`} {...blockProps}>
366
+
<h3 className={`h2Block ${className}`} {...blockProps}>
364
367
<TextBlock
365
368
{...b.block}
366
369
index={index}
···
371
374
);
372
375
if (b.block.level === 3)
373
376
return (
374
-
<h4 className={`${className}`} {...blockProps}>
377
+
<h4 className={`h3Block ${className}`} {...blockProps}>
375
378
<TextBlock
376
379
{...b.block}
377
380
index={index}
···
383
386
// if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>;
384
387
// if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>;
385
388
return (
386
-
<h6 className={`${className}`} {...blockProps}>
389
+
<h6 className={`h6Block ${className}`} {...blockProps}>
387
390
<TextBlock
388
391
{...b.block}
389
392
index={index}
-63
app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx
-63
app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx
···
1
-
"use client";
2
-
3
-
import { Media } from "components/Media";
4
-
import {
5
-
Interactions,
6
-
useInteractionState,
7
-
} from "../Interactions/Interactions";
8
-
import { useState, useEffect } from "react";
9
-
import { Json } from "supabase/database.types";
10
-
11
-
// export const CollapsedPostHeader = (props: {
12
-
// title: string;
13
-
// pubIcon?: string;
14
-
// quotes: { link: string; bsky_posts: { post_view: Json } | null }[];
15
-
// }) => {
16
-
// let [headerVisible, setHeaderVisible] = useState(false);
17
-
// let { drawerOpen: open } = useInteractionState();
18
-
19
-
// useEffect(() => {
20
-
// let post = window.document.getElementById("post-page");
21
-
22
-
// function handleScroll() {
23
-
// let postHeader = window.document
24
-
// .getElementById("post-header")
25
-
// ?.getBoundingClientRect();
26
-
// if (postHeader && postHeader.bottom <= 0) {
27
-
// setHeaderVisible(true);
28
-
// } else {
29
-
// setHeaderVisible(false);
30
-
// }
31
-
// }
32
-
// post?.addEventListener("scroll", handleScroll);
33
-
// return () => {
34
-
// post?.removeEventListener("scroll", handleScroll);
35
-
// };
36
-
// }, []);
37
-
// if (!headerVisible) return;
38
-
// if (open) return;
39
-
// return (
40
-
// <Media
41
-
// mobile
42
-
// className="sticky top-0 left-0 right-0 w-full bg-bg-page border-b border-border-light -mx-3"
43
-
// >
44
-
// <div className="flex gap-2 items-center justify-between px-3 pt-2 pb-0.5 ">
45
-
// <div className="text-tertiary font-bold text-sm truncate pr-1 grow">
46
-
// {props.title}
47
-
// </div>
48
-
// <div className="flex gap-2 ">
49
-
// <Interactions compact quotes={props.quotes.length} />
50
-
// <div
51
-
// style={{
52
-
// backgroundRepeat: "no-repeat",
53
-
// backgroundPosition: "center",
54
-
// backgroundSize: "cover",
55
-
// backgroundImage: `url(${props.pubIcon})`,
56
-
// }}
57
-
// className="shrink-0 w-4 h-4 rounded-full mt-[2px]"
58
-
// />
59
-
// </div>
60
-
// </div>
61
-
// </Media>
62
-
// );
63
-
// };
+62
-32
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+62
-32
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
···
16
16
import { EditTiny } from "components/Icons/EditTiny";
17
17
import { SpeedyLink } from "components/SpeedyLink";
18
18
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
19
+
import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page";
20
+
import { Separator } from "components/Layout";
19
21
20
22
export function PostHeader(props: {
21
23
data: PostPageData;
···
40
42
41
43
if (!document?.data) return;
42
44
return (
43
-
<div
44
-
className="max-w-prose w-full mx-auto px-3 sm:px-4 sm:pt-3 pt-2"
45
-
id="post-header"
46
-
>
47
-
<div className="pubHeader flex flex-col pb-5">
48
-
<div className="flex justify-between w-full">
45
+
<PostHeaderLayout
46
+
pubLink={
47
+
<>
49
48
{pub && (
50
49
<SpeedyLink
51
50
className="font-bold hover:no-underline text-accent-contrast"
···
65
64
<EditTiny className="shrink-0" />
66
65
</a>
67
66
)}
68
-
</div>
69
-
<h2 className="">{record.title}</h2>
70
-
{record.description ? (
71
-
<p className="italic text-secondary">{record.description}</p>
72
-
) : null}
73
-
74
-
<div className="text-sm text-tertiary pt-3 flex gap-1 flex-wrap">
75
-
{profile ? (
76
-
<>
77
-
<a
78
-
className="text-tertiary"
79
-
href={`https://bsky.app/profile/${profile.handle}`}
80
-
>
81
-
by {profile.displayName || profile.handle}
82
-
</a>
83
-
</>
84
-
) : null}
85
-
{record.publishedAt ? (
86
-
<>
87
-
|<p>{formattedDate}</p>
88
-
</>
89
-
) : null}
90
-
|{" "}
67
+
</>
68
+
}
69
+
postTitle={record.title}
70
+
postDescription={record.description}
71
+
postInfo={
72
+
<>
73
+
<div className="flex flex-row gap-2 items-center">
74
+
{profile ? (
75
+
<>
76
+
<a
77
+
className="text-tertiary"
78
+
href={`https://bsky.app/profile/${profile.handle}`}
79
+
>
80
+
{profile.displayName || profile.handle}
81
+
</a>
82
+
</>
83
+
) : null}
84
+
{record.publishedAt ? (
85
+
<>
86
+
<Separator classname="h-4!" />
87
+
<p>{formattedDate}</p>
88
+
</>
89
+
) : null}
90
+
</div>
91
91
<Interactions
92
92
showComments={props.preferences.showComments}
93
-
compact
94
93
quotesCount={getQuoteCount(document) || 0}
95
94
commentsCount={getCommentCount(document) || 0}
96
95
/>
97
-
</div>
96
+
</>
97
+
}
98
+
/>
99
+
);
100
+
}
101
+
102
+
export const PostHeaderLayout = (props: {
103
+
pubLink: React.ReactNode;
104
+
postTitle: React.ReactNode | undefined;
105
+
postDescription: React.ReactNode | undefined;
106
+
postInfo: React.ReactNode;
107
+
}) => {
108
+
return (
109
+
<div
110
+
className="postHeader max-w-prose w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5"
111
+
id="post-header"
112
+
>
113
+
<div className="pubInfo flex text-accent-contrast font-bold justify-between w-full">
114
+
{props.pubLink}
115
+
</div>
116
+
<h2
117
+
className={`postTitle text-xl leading-tight pt-0.5 font-bold outline-hidden bg-transparent ${!props.postTitle && "text-tertiary italic"}`}
118
+
>
119
+
{props.postTitle ? props.postTitle : "Untitled"}
120
+
</h2>
121
+
{props.postDescription ? (
122
+
<p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
123
+
{props.postDescription}
124
+
</p>
125
+
) : null}
126
+
<div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between">
127
+
{props.postInfo}
98
128
</div>
99
129
</div>
100
130
);
101
-
}
131
+
};
+8
app/lish/[did]/[publication]/[rkey]/page.tsx
+8
app/lish/[did]/[publication]/[rkey]/page.tsx
+20
-33
app/lish/[did]/[publication]/dashboard/DraftList.tsx
+20
-33
app/lish/[did]/[publication]/dashboard/DraftList.tsx
···
4
4
import React from "react";
5
5
import { usePublicationData } from "./PublicationSWRProvider";
6
6
import { LeafletList } from "app/(home-pages)/home/HomeLayout";
7
-
import { EmptyState } from "components/EmptyState";
8
7
9
8
export function DraftList(props: {
10
9
searchValue: string;
···
13
12
let { data: pub_data } = usePublicationData();
14
13
if (!pub_data?.publication) return null;
15
14
let { leaflets_in_publications, ...publication } = pub_data.publication;
16
-
let filteredLeaflets = leaflets_in_publications
17
-
.filter((l) => !l.documents)
18
-
.filter((l) => !l.archived)
19
-
.map((l) => {
20
-
return {
21
-
archived: l.archived,
22
-
added_at: "",
23
-
token: {
24
-
...l.permission_tokens!,
25
-
leaflets_in_publications: [
26
-
{
27
-
...l,
28
-
publications: {
29
-
...publication,
30
-
},
31
-
},
32
-
],
33
-
},
34
-
};
35
-
});
36
-
37
-
38
-
39
-
if (!filteredLeaflets || filteredLeaflets.length === 0)
40
-
return (
41
-
<EmptyState>
42
-
No drafts yet!
43
-
<NewDraftSecondaryButton publication={pub_data?.publication?.uri} />
44
-
</EmptyState>
45
-
);
46
-
47
15
return (
48
16
<div className="flex flex-col gap-4">
49
17
<NewDraftSecondaryButton
···
56
24
showPreview={false}
57
25
defaultDisplay="list"
58
26
cardBorderHidden={!props.showPageBackground}
59
-
leaflets={filteredLeaflets}
27
+
leaflets={leaflets_in_publications
28
+
.filter((l) => !l.documents)
29
+
.filter((l) => !l.archived)
30
+
.map((l) => {
31
+
return {
32
+
archived: l.archived,
33
+
added_at: "",
34
+
token: {
35
+
...l.permission_tokens!,
36
+
leaflets_in_publications: [
37
+
{
38
+
...l,
39
+
publications: {
40
+
...publication,
41
+
},
42
+
},
43
+
],
44
+
},
45
+
};
46
+
})}
60
47
initialFacts={pub_data.leaflet_data.facts || {}}
61
48
titles={{
62
49
...leaflets_in_publications.reduce(
-1
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
-1
app/lish/[did]/[publication]/dashboard/NewDraftButton.tsx
+25
-31
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
+25
-31
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
···
1
1
"use client";
2
2
import { AtUri } from "@atproto/syntax";
3
-
import { PubLeafletDocument } from "lexicons/api";
3
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
4
4
import { EditTiny } from "components/Icons/EditTiny";
5
5
6
6
import { usePublicationData } from "./PublicationSWRProvider";
···
17
17
import { SpeedyLink } from "components/SpeedyLink";
18
18
import { QuoteTiny } from "components/Icons/QuoteTiny";
19
19
import { CommentTiny } from "components/Icons/CommentTiny";
20
+
import { InteractionPreview } from "components/InteractionsPreview";
20
21
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
21
22
import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions";
22
23
import { StaticLeafletDataContext } from "components/PageSWRDataProvider";
23
-
import { EmptyState } from "components/EmptyState";
24
24
25
25
export function PublishedPostsList(props: {
26
26
searchValue: string;
···
29
29
let { data } = usePublicationData();
30
30
let params = useParams();
31
31
let { publication } = data!;
32
+
let pubRecord = publication?.record as PubLeafletPublication.Record;
33
+
32
34
if (!publication) return null;
33
35
if (publication.documents_in_publications.length === 0)
34
-
return <EmptyState>Nothing's been published yet...</EmptyState>;
36
+
return (
37
+
<div className="italic text-tertiary w-full container text-center place-items-center flex flex-col gap-3 p-3">
38
+
Nothing's been published yet...
39
+
</div>
40
+
);
35
41
return (
36
42
<div className="publishedList w-full flex flex-col gap-2 pb-4">
37
43
{publication.documents_in_publications
···
52
58
(l) => doc.documents && l.doc === doc.documents.uri,
53
59
);
54
60
let uri = new AtUri(doc.documents.uri);
55
-
let record = doc.documents.data as PubLeafletDocument.Record;
61
+
let postRecord = doc.documents.data as PubLeafletDocument.Record;
56
62
let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0;
57
63
let comments = doc.documents.comments_on_documents[0]?.count || 0;
64
+
let tags = (postRecord?.tags as string[] | undefined) || [];
58
65
59
66
let postLink = data?.publication
60
67
? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}`
···
78
85
href={`${getPublicationURL(publication)}/${uri.rkey}`}
79
86
>
80
87
<h3 className="text-primary grow leading-snug">
81
-
{record.title}
88
+
{postRecord.title}
82
89
</h3>
83
90
</a>
84
91
<div className="flex justify-start align-top flex-row gap-1">
···
107
114
: null,
108
115
},
109
116
],
110
-
leaflets_to_documents: null,
117
+
leaflets_to_documents: [],
111
118
blocked_by_admin: null,
112
119
custom_domain_routes: [],
113
120
}}
···
119
126
</div>
120
127
</div>
121
128
122
-
{record.description ? (
129
+
{postRecord.description ? (
123
130
<p className="italic text-secondary">
124
-
{record.description}
131
+
{postRecord.description}
125
132
</p>
126
133
) : null}
127
-
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3">
128
-
{record.publishedAt ? (
129
-
<PublishedDate dateString={record.publishedAt} />
134
+
<div className="text-sm text-tertiary flex gap-3 justify-between sm:justify-start items-center pt-3">
135
+
{postRecord.publishedAt ? (
136
+
<PublishedDate dateString={postRecord.publishedAt} />
130
137
) : null}
131
-
{(comments > 0 || quotes > 0) && record.publishedAt
132
-
? " | "
133
-
: ""}
134
-
{quotes > 0 && (
135
-
<SpeedyLink
136
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`}
137
-
className="flex flex-row gap-1 text-sm text-tertiary items-center"
138
-
>
139
-
<QuoteTiny /> {quotes}
140
-
</SpeedyLink>
141
-
)}
142
-
{comments > 0 && quotes > 0 ? " " : ""}
143
-
{comments > 0 && (
144
-
<SpeedyLink
145
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`}
146
-
className="flex flex-row gap-1 text-sm text-tertiary items-center"
147
-
>
148
-
<CommentTiny /> {comments}
149
-
</SpeedyLink>
150
-
)}
138
+
<InteractionPreview
139
+
quotesCount={quotes}
140
+
commentsCount={comments}
141
+
tags={tags}
142
+
showComments={pubRecord?.preferences?.showComments}
143
+
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
144
+
/>
151
145
</div>
152
146
</div>
153
147
</div>
+67
app/lish/[did]/[publication]/icon/route.ts
+67
app/lish/[did]/[publication]/icon/route.ts
···
1
+
import { NextRequest } from "next/server";
2
+
import { IdResolver } from "@atproto/identity";
3
+
import { AtUri } from "@atproto/syntax";
4
+
import { PubLeafletPublication } from "lexicons/api";
5
+
import { supabaseServerClient } from "supabase/serverClient";
6
+
import sharp from "sharp";
7
+
import { redirect } from "next/navigation";
8
+
9
+
let idResolver = new IdResolver();
10
+
11
+
export const dynamic = "force-dynamic";
12
+
13
+
export async function GET(
14
+
request: NextRequest,
15
+
props: { params: Promise<{ did: string; publication: string }> },
16
+
) {
17
+
console.log("are we getting here?");
18
+
const params = await props.params;
19
+
try {
20
+
let did = decodeURIComponent(params.did);
21
+
let uri;
22
+
if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) {
23
+
uri = AtUri.make(
24
+
did,
25
+
"pub.leaflet.publication",
26
+
params.publication,
27
+
).toString();
28
+
}
29
+
let { data: publication } = await supabaseServerClient
30
+
.from("publications")
31
+
.select(
32
+
`*,
33
+
publication_subscriptions(*),
34
+
documents_in_publications(documents(*))
35
+
`,
36
+
)
37
+
.eq("identity_did", did)
38
+
.or(`name.eq."${params.publication}", uri.eq."${uri}"`)
39
+
.single();
40
+
41
+
let record = publication?.record as PubLeafletPublication.Record | null;
42
+
if (!record?.icon) return redirect("/icon.png");
43
+
44
+
let identity = await idResolver.did.resolve(did);
45
+
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
46
+
if (!service) return redirect("/icon.png");
47
+
let cid = (record.icon.ref as unknown as { $link: string })["$link"];
48
+
const response = await fetch(
49
+
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
50
+
);
51
+
let blob = await response.blob();
52
+
let resizedImage = await sharp(await blob.arrayBuffer())
53
+
.resize({ width: 32, height: 32 })
54
+
.toBuffer();
55
+
return new Response(new Uint8Array(resizedImage), {
56
+
headers: {
57
+
"Content-Type": "image/png",
58
+
"CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400",
59
+
"Cache-Control":
60
+
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
61
+
},
62
+
});
63
+
} catch (e) {
64
+
console.log(e);
65
+
return redirect("/icon.png");
66
+
}
67
+
}
-68
app/lish/[did]/[publication]/icon.ts
-68
app/lish/[did]/[publication]/icon.ts
···
1
-
import { NextRequest } from "next/server";
2
-
import { IdResolver } from "@atproto/identity";
3
-
import { AtUri } from "@atproto/syntax";
4
-
import { PubLeafletPublication } from "lexicons/api";
5
-
import { supabaseServerClient } from "supabase/serverClient";
6
-
import sharp from "sharp";
7
-
import { redirect } from "next/navigation";
8
-
9
-
let idResolver = new IdResolver();
10
-
11
-
export const size = {
12
-
width: 32,
13
-
height: 32,
14
-
};
15
-
16
-
export const contentType = "image/png";
17
-
export default async function Icon(props: {
18
-
params: Promise<{ did: string; publication: string }>;
19
-
}) {
20
-
const params = await props.params;
21
-
try {
22
-
let did = decodeURIComponent(params.did);
23
-
let uri;
24
-
if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) {
25
-
uri = AtUri.make(
26
-
did,
27
-
"pub.leaflet.publication",
28
-
params.publication,
29
-
).toString();
30
-
}
31
-
let { data: publication } = await supabaseServerClient
32
-
.from("publications")
33
-
.select(
34
-
`*,
35
-
publication_subscriptions(*),
36
-
documents_in_publications(documents(*))
37
-
`,
38
-
)
39
-
.eq("identity_did", did)
40
-
.or(`name.eq."${params.publication}", uri.eq."${uri}"`)
41
-
.single();
42
-
43
-
let record = publication?.record as PubLeafletPublication.Record | null;
44
-
if (!record?.icon) return redirect("/icon.png");
45
-
46
-
let identity = await idResolver.did.resolve(did);
47
-
let service = identity?.service?.find((f) => f.id === "#atproto_pds");
48
-
if (!service) return null;
49
-
let cid = (record.icon.ref as unknown as { $link: string })["$link"];
50
-
const response = await fetch(
51
-
`${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
52
-
);
53
-
let blob = await response.blob();
54
-
let resizedImage = await sharp(await blob.arrayBuffer())
55
-
.resize({ width: 32, height: 32 })
56
-
.toBuffer();
57
-
return new Response(new Uint8Array(resizedImage), {
58
-
headers: {
59
-
"Content-Type": "image/png",
60
-
"CDN-Cache-Control": "s-maxage=86400, stale-while-revalidate=86400",
61
-
"Cache-Control":
62
-
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
63
-
},
64
-
});
65
-
} catch (e) {
66
-
return redirect("/icon.png");
67
-
}
68
-
}
+8
app/lish/[did]/[publication]/layout.tsx
+8
app/lish/[did]/[publication]/layout.tsx
···
47
47
title: pubRecord?.name || "Untitled Publication",
48
48
description: pubRecord?.description || "",
49
49
icons: {
50
+
icon: {
51
+
url:
52
+
process.env.NODE_ENV === "development"
53
+
? `/lish/${did}/${publication_name}/icon`
54
+
: "/icon",
55
+
sizes: "32x32",
56
+
type: "image/png",
57
+
},
50
58
other: {
51
59
rel: "alternate",
52
60
url: publication.uri,
+9
-17
app/lish/[did]/[publication]/page.tsx
+9
-17
app/lish/[did]/[publication]/page.tsx
···
14
14
import { SpeedyLink } from "components/SpeedyLink";
15
15
import { QuoteTiny } from "components/Icons/QuoteTiny";
16
16
import { CommentTiny } from "components/Icons/CommentTiny";
17
+
import { InteractionPreview } from "components/InteractionsPreview";
17
18
import { LocalizedDate } from "./LocalizedDate";
18
19
import { PublicationHomeLayout } from "./PublicationHomeLayout";
19
20
···
134
135
record?.preferences?.showComments === false
135
136
? 0
136
137
: doc.documents.comments_on_documents[0].count || 0;
138
+
let tags = (doc_record?.tags as string[] | undefined) || [];
137
139
138
140
return (
139
141
<React.Fragment key={doc.documents?.uri}>
···
162
164
)}{" "}
163
165
</p>
164
166
{comments > 0 || quotes > 0 ? "| " : ""}
165
-
{quotes > 0 && (
166
-
<SpeedyLink
167
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=quotes`}
168
-
className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap"
169
-
>
170
-
<QuoteTiny /> {quotes}
171
-
</SpeedyLink>
172
-
)}
173
-
{comments > 0 &&
174
-
record?.preferences?.showComments !== false && (
175
-
<SpeedyLink
176
-
href={`${getPublicationURL(publication)}/${uri.rkey}?interactionDrawer=comments`}
177
-
className="flex flex-row gap-0 text-sm text-tertiary items-center flex-wrap"
178
-
>
179
-
<CommentTiny /> {comments}
180
-
</SpeedyLink>
181
-
)}
167
+
<InteractionPreview
168
+
quotesCount={quotes}
169
+
commentsCount={comments}
170
+
tags={tags}
171
+
postUrl=""
172
+
showComments={record?.preferences?.showComments}
173
+
/>
182
174
</div>
183
175
</div>
184
176
<hr className="last:hidden border-border-light" />
+91
app/lish/uri/[uri]/route.ts
+91
app/lish/uri/[uri]/route.ts
···
1
+
import { NextRequest, NextResponse } from "next/server";
2
+
import { AtUri } from "@atproto/api";
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { PubLeafletPublication } from "lexicons/api";
5
+
6
+
/**
7
+
* Redirect route for AT URIs (publications and documents)
8
+
* Redirects to the actual hosted domains from publication records
9
+
*/
10
+
export async function GET(
11
+
request: NextRequest,
12
+
{ params }: { params: Promise<{ uri: string }> }
13
+
) {
14
+
try {
15
+
const { uri: uriParam } = await params;
16
+
const atUriString = decodeURIComponent(uriParam);
17
+
const uri = new AtUri(atUriString);
18
+
19
+
if (uri.collection === "pub.leaflet.publication") {
20
+
// Get the publication record to retrieve base_path
21
+
const { data: publication } = await supabaseServerClient
22
+
.from("publications")
23
+
.select("record")
24
+
.eq("uri", atUriString)
25
+
.single();
26
+
27
+
if (!publication?.record) {
28
+
return new NextResponse("Publication not found", { status: 404 });
29
+
}
30
+
31
+
const record = publication.record as PubLeafletPublication.Record;
32
+
const basePath = record.base_path;
33
+
34
+
if (!basePath) {
35
+
return new NextResponse("Publication has no base_path", { status: 404 });
36
+
}
37
+
38
+
// Redirect to the publication's hosted domain (temporary redirect since base_path can change)
39
+
return NextResponse.redirect(basePath, 307);
40
+
} else if (uri.collection === "pub.leaflet.document") {
41
+
// Document link - need to find the publication it belongs to
42
+
const { data: docInPub } = await supabaseServerClient
43
+
.from("documents_in_publications")
44
+
.select("publication, publications!inner(record)")
45
+
.eq("document", atUriString)
46
+
.single();
47
+
48
+
if (docInPub?.publication && docInPub.publications) {
49
+
// Document is in a publication - redirect to domain/rkey
50
+
const record = docInPub.publications.record as PubLeafletPublication.Record;
51
+
const basePath = record.base_path;
52
+
53
+
if (!basePath) {
54
+
return new NextResponse("Publication has no base_path", { status: 404 });
55
+
}
56
+
57
+
// Ensure basePath ends without trailing slash
58
+
const cleanBasePath = basePath.endsWith("/")
59
+
? basePath.slice(0, -1)
60
+
: basePath;
61
+
62
+
// Redirect to the document on the publication's domain (temporary redirect since base_path can change)
63
+
return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307);
64
+
}
65
+
66
+
// If not in a publication, check if it's a standalone document
67
+
const { data: doc } = await supabaseServerClient
68
+
.from("documents")
69
+
.select("uri")
70
+
.eq("uri", atUriString)
71
+
.single();
72
+
73
+
if (doc) {
74
+
// Standalone document - redirect to /p/did/rkey (temporary redirect)
75
+
return NextResponse.redirect(
76
+
new URL(`/p/${uri.host}/${uri.rkey}`, request.url),
77
+
307
78
+
);
79
+
}
80
+
81
+
// Document not found
82
+
return new NextResponse("Document not found", { status: 404 });
83
+
}
84
+
85
+
// Unsupported collection type
86
+
return new NextResponse("Unsupported URI type", { status: 400 });
87
+
} catch (error) {
88
+
console.error("Error resolving AT URI:", error);
89
+
return new NextResponse("Invalid URI", { status: 400 });
90
+
}
91
+
}
+12
-14
components/ActionBar/ActionButton.tsx
+12
-14
components/ActionBar/ActionButton.tsx
···
3
3
import { useContext, useEffect } from "react";
4
4
import { SidebarContext } from "./Sidebar";
5
5
import React, { forwardRef, type JSX } from "react";
6
-
import { PopoverOpenContext } from "components/Popover";
6
+
import { PopoverOpenContext } from "components/Popover/PopoverContext";
7
7
8
8
type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">;
9
9
···
11
11
_props: ButtonProps & {
12
12
id?: string;
13
13
icon: React.ReactNode;
14
-
label?: React.ReactNode;
14
+
label: React.ReactNode;
15
15
primary?: boolean;
16
16
secondary?: boolean;
17
17
nav?: boolean;
···
69
69
`}
70
70
>
71
71
<div className="shrink-0">{icon}</div>
72
-
{label && (
73
-
<div
74
-
className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`}
75
-
>
76
-
<div className="truncate text-left pt-[1px]">{label}</div>
77
-
{subtext && (
78
-
<div className="text-xs text-tertiary font-normal text-left">
79
-
{subtext}
80
-
</div>
81
-
)}
82
-
</div>
83
-
)}
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"}`}
74
+
>
75
+
<div className="truncate text-left pt-[1px]">{label}</div>
76
+
{subtext && (
77
+
<div className="text-xs text-tertiary font-normal text-left">
78
+
{subtext}
79
+
</div>
80
+
)}
81
+
</div>
84
82
</button>
85
83
);
86
84
};
+2
-3
components/ActionBar/Publications.tsx
+2
-3
components/ActionBar/Publications.tsx
···
23
23
currentPubUri: string | undefined;
24
24
}) => {
25
25
let { identity } = useIdentityData();
26
-
let hasLooseleafs = identity?.permission_token_on_homepage.find(
26
+
let hasLooseleafs = !!identity?.permission_token_on_homepage.find(
27
27
(f) =>
28
28
f.permission_tokens.leaflets_to_documents &&
29
-
f.permission_tokens.leaflets_to_documents.document,
29
+
f.permission_tokens.leaflets_to_documents[0]?.document,
30
30
);
31
-
console.log(hasLooseleafs);
32
31
33
32
// don't show pub list button if not logged in or no pub list
34
33
// we show a "start a pub" banner instead
+46
components/AtMentionLink.tsx
+46
components/AtMentionLink.tsx
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { atUriToUrl } from "src/utils/mentionUtils";
3
+
4
+
/**
5
+
* Component for rendering at-uri mentions (publications and documents) as clickable links.
6
+
* NOTE: This component's styling and behavior should match the ProseMirror schema rendering
7
+
* in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other.
8
+
*/
9
+
export function AtMentionLink({
10
+
atURI,
11
+
children,
12
+
className = "",
13
+
}: {
14
+
atURI: string;
15
+
children: React.ReactNode;
16
+
className?: string;
17
+
}) {
18
+
const aturi = new AtUri(atURI);
19
+
const isPublication = aturi.collection === "pub.leaflet.publication";
20
+
const isDocument = aturi.collection === "pub.leaflet.document";
21
+
22
+
// Show publication icon if available
23
+
const icon =
24
+
isPublication || isDocument ? (
25
+
<img
26
+
src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
27
+
className="inline-block w-5 h-5 rounded-full mr-1 align-text-top"
28
+
alt=""
29
+
width="20"
30
+
height="20"
31
+
loading="lazy"
32
+
/>
33
+
) : null;
34
+
35
+
return (
36
+
<a
37
+
href={atUriToUrl(atURI)}
38
+
target="_blank"
39
+
rel="noopener noreferrer"
40
+
className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
41
+
>
42
+
{icon}
43
+
{children}
44
+
</a>
45
+
);
46
+
}
+4
-2
components/Blocks/BlockCommandBar.tsx
+4
-2
components/Blocks/BlockCommandBar.tsx
···
37
37
const clearCommandSearchText = () => {
38
38
if (!props.entityID) return;
39
39
const entityID = props.entityID;
40
-
40
+
41
41
const existingState = useEditorStates.getState().editorStates[entityID];
42
42
if (!existingState) return;
43
43
···
69
69
setHighlighted(commandResults[0].name);
70
70
}
71
71
}, [commandResults, setHighlighted, highlighted]);
72
+
72
73
useEffect(() => {
73
74
let listener = async (e: KeyboardEvent) => {
74
75
let reverseDir = ref.current?.dataset.side === "top";
···
118
119
return;
119
120
}
120
121
};
122
+
121
123
window.addEventListener("keydown", listener);
122
124
123
125
return () => window.removeEventListener("keydown", listener);
···
200
202
201
203
return (
202
204
<button
203
-
className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`}
205
+
className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`}
204
206
onMouseOver={() => {
205
207
props.setHighlighted(props.name);
206
208
}}
+3
-3
components/Blocks/BlockCommands.tsx
+3
-3
components/Blocks/BlockCommands.tsx
···
2
2
import { useUIState } from "src/useUIState";
3
3
4
4
import { generateKeyBetween } from "fractional-indexing";
5
-
import { focusPage } from "components/Pages";
5
+
import { focusPage } from "src/utils/focusPage";
6
6
import { v7 } from "uuid";
7
7
import { Replicache } from "replicache";
8
8
import { useEditorStates } from "src/state/useEditorState";
9
9
import { elementId } from "src/utils/elementId";
10
10
import { UndoManager } from "src/undoManager";
11
11
import { focusBlock } from "src/utils/focusBlock";
12
-
import { usePollBlockUIState } from "./PollBlock";
13
-
import { focusElement } from "components/Input";
12
+
import { usePollBlockUIState } from "./PollBlock/pollBlockState";
13
+
import { focusElement } from "src/utils/focusElement";
14
14
import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall";
15
15
import { BlockButtonSmall } from "components/Icons/BlockButtonSmall";
16
16
import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
+2
-120
components/Blocks/DeleteBlock.tsx
+2
-120
components/Blocks/DeleteBlock.tsx
···
1
-
import {
2
-
Fact,
3
-
ReplicacheMutators,
4
-
useEntity,
5
-
useReplicache,
6
-
} from "src/replicache";
7
-
import { Replicache } from "replicache";
8
-
import { useUIState } from "src/useUIState";
9
-
import { scanIndex } from "src/replicache/utils";
10
-
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
11
-
import { focusBlock } from "src/utils/focusBlock";
1
+
import { Fact, useReplicache } from "src/replicache";
12
2
import { ButtonPrimary } from "components/Buttons";
13
3
import { CloseTiny } from "components/Icons/CloseTiny";
4
+
import { deleteBlock } from "src/utils/deleteBlock";
14
5
15
6
export const AreYouSure = (props: {
16
7
entityID: string[] | string;
···
82
73
);
83
74
};
84
75
85
-
export async function deleteBlock(
86
-
entities: string[],
87
-
rep: Replicache<ReplicacheMutators>,
88
-
) {
89
-
// get what pagess we need to close as a result of deleting this block
90
-
let pagesToClose = [] as string[];
91
-
for (let entity of entities) {
92
-
let [type] = await rep.query((tx) =>
93
-
scanIndex(tx).eav(entity, "block/type"),
94
-
);
95
-
if (type.data.value === "card") {
96
-
let [childPages] = await rep?.query(
97
-
(tx) => scanIndex(tx).eav(entity, "block/card") || [],
98
-
);
99
-
pagesToClose = [childPages?.data.value];
100
-
}
101
-
if (type.data.value === "mailbox") {
102
-
let [archive] = await rep?.query(
103
-
(tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [],
104
-
);
105
-
let [draft] = await rep?.query(
106
-
(tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [],
107
-
);
108
-
pagesToClose = [archive?.data.value, draft?.data.value];
109
-
}
110
-
}
111
-
112
-
// the next and previous blocks in the block list
113
-
// if the focused thing is a page and not a block, return
114
-
let focusedBlock = useUIState.getState().focusedEntity;
115
-
let parent =
116
-
focusedBlock?.entityType === "page"
117
-
? focusedBlock.entityID
118
-
: focusedBlock?.parent;
119
-
120
-
if (parent) {
121
-
let parentType = await rep?.query((tx) =>
122
-
scanIndex(tx).eav(parent, "page/type"),
123
-
);
124
-
if (parentType[0]?.data.value === "canvas") {
125
-
useUIState
126
-
.getState()
127
-
.setFocusedBlock({ entityType: "page", entityID: parent });
128
-
useUIState.getState().setSelectedBlocks([]);
129
-
} else {
130
-
let siblings =
131
-
(await rep?.query((tx) => getBlocksWithType(tx, parent))) || [];
132
-
133
-
let selectedBlocks = useUIState.getState().selectedBlocks;
134
-
let firstSelected = selectedBlocks[0];
135
-
let lastSelected = selectedBlocks[entities.length - 1];
136
-
137
-
let prevBlock =
138
-
siblings?.[
139
-
siblings.findIndex((s) => s.value === firstSelected?.value) - 1
140
-
];
141
-
let prevBlockType = await rep?.query((tx) =>
142
-
scanIndex(tx).eav(prevBlock?.value, "block/type"),
143
-
);
144
-
145
-
let nextBlock =
146
-
siblings?.[
147
-
siblings.findIndex((s) => s.value === lastSelected.value) + 1
148
-
];
149
-
let nextBlockType = await rep?.query((tx) =>
150
-
scanIndex(tx).eav(nextBlock?.value, "block/type"),
151
-
);
152
-
153
-
if (prevBlock) {
154
-
useUIState.getState().setSelectedBlock({
155
-
value: prevBlock.value,
156
-
parent: prevBlock.parent,
157
-
});
158
-
159
-
focusBlock(
160
-
{
161
-
value: prevBlock.value,
162
-
type: prevBlockType?.[0].data.value,
163
-
parent: prevBlock.parent,
164
-
},
165
-
{ type: "end" },
166
-
);
167
-
} else {
168
-
useUIState.getState().setSelectedBlock({
169
-
value: nextBlock.value,
170
-
parent: nextBlock.parent,
171
-
});
172
-
173
-
focusBlock(
174
-
{
175
-
value: nextBlock.value,
176
-
type: nextBlockType?.[0]?.data.value,
177
-
parent: nextBlock.parent,
178
-
},
179
-
{ type: "start" },
180
-
);
181
-
}
182
-
}
183
-
}
184
-
185
-
pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
186
-
await Promise.all(
187
-
entities.map((entity) =>
188
-
rep?.mutate.removeBlock({
189
-
blockEntity: entity,
190
-
}),
191
-
),
192
-
);
193
-
}
+2
-1
components/Blocks/ExternalLinkBlock.tsx
+2
-1
components/Blocks/ExternalLinkBlock.tsx
···
8
8
import { v7 } from "uuid";
9
9
import { useSmoker } from "components/Toast";
10
10
import { Separator } from "components/Layout";
11
-
import { focusElement, Input } from "components/Input";
11
+
import { Input } from "components/Input";
12
+
import { focusElement } from "src/utils/focusElement";
12
13
import { isUrl } from "src/utils/isURL";
13
14
import { elementId } from "src/utils/elementId";
14
15
import { focusBlock } from "src/utils/focusBlock";
+1
-1
components/Blocks/MailboxBlock.tsx
+1
-1
components/Blocks/MailboxBlock.tsx
···
9
9
import { useEntitySetContext } from "components/EntitySetProvider";
10
10
import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail";
11
11
import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription";
12
-
import { focusPage } from "components/Pages";
12
+
import { focusPage } from "src/utils/focusPage";
13
13
import { v7 } from "uuid";
14
14
import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers";
15
15
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
+1
-1
components/Blocks/PageLinkBlock.tsx
+1
-1
components/Blocks/PageLinkBlock.tsx
···
2
2
import { BlockProps, BaseBlock, ListMarker, Block } from "./Block";
3
3
import { focusBlock } from "src/utils/focusBlock";
4
4
5
-
import { focusPage } from "components/Pages";
5
+
import { focusPage } from "src/utils/focusPage";
6
6
import { useEntity, useReplicache } from "src/replicache";
7
7
import { useUIState } from "src/useUIState";
8
8
import { RenderedTextBlock } from "components/Blocks/TextBlock";
+501
components/Blocks/PollBlock/index.tsx
+501
components/Blocks/PollBlock/index.tsx
···
1
+
import { useUIState } from "src/useUIState";
2
+
import { BlockProps } from "../Block";
3
+
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
4
+
import { useCallback, useEffect, useState } from "react";
5
+
import { Input } from "components/Input";
6
+
import { focusElement } from "src/utils/focusElement";
7
+
import { Separator } from "components/Layout";
8
+
import { useEntitySetContext } from "components/EntitySetProvider";
9
+
import { theme } from "tailwind.config";
10
+
import { useEntity, useReplicache } from "src/replicache";
11
+
import { v7 } from "uuid";
12
+
import {
13
+
useLeafletPublicationData,
14
+
usePollData,
15
+
} from "components/PageSWRDataProvider";
16
+
import { voteOnPoll } from "actions/pollActions";
17
+
import { elementId } from "src/utils/elementId";
18
+
import { CheckTiny } from "components/Icons/CheckTiny";
19
+
import { CloseTiny } from "components/Icons/CloseTiny";
20
+
import { PublicationPollBlock } from "../PublicationPollBlock";
21
+
import { usePollBlockUIState } from "./pollBlockState";
22
+
23
+
export const PollBlock = (props: BlockProps) => {
24
+
let { data: pub } = useLeafletPublicationData();
25
+
if (!pub) return <LeafletPollBlock {...props} />;
26
+
return <PublicationPollBlock {...props} />;
27
+
};
28
+
29
+
export const LeafletPollBlock = (props: BlockProps) => {
30
+
let isSelected = useUIState((s) =>
31
+
s.selectedBlocks.find((b) => b.value === props.entityID),
32
+
);
33
+
let { permissions } = useEntitySetContext();
34
+
35
+
let { data: pollData } = usePollData();
36
+
let hasVoted =
37
+
pollData?.voter_token &&
38
+
pollData.polls.find(
39
+
(v) =>
40
+
v.poll_votes_on_entity.voter_token === pollData.voter_token &&
41
+
v.poll_votes_on_entity.poll_entity === props.entityID,
42
+
);
43
+
44
+
let pollState = usePollBlockUIState((s) => s[props.entityID]?.state);
45
+
if (!pollState) {
46
+
if (hasVoted) pollState = "results";
47
+
else pollState = "voting";
48
+
}
49
+
50
+
const setPollState = useCallback(
51
+
(state: "editing" | "voting" | "results") => {
52
+
usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } }));
53
+
},
54
+
[],
55
+
);
56
+
57
+
let votes =
58
+
pollData?.polls.filter(
59
+
(v) => v.poll_votes_on_entity.poll_entity === props.entityID,
60
+
) || [];
61
+
let totalVotes = votes.length;
62
+
63
+
return (
64
+
<div
65
+
className={`poll flex flex-col gap-2 p-3 w-full
66
+
${isSelected ? "block-border-selected " : "block-border"}`}
67
+
style={{
68
+
backgroundColor:
69
+
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
70
+
}}
71
+
>
72
+
{pollState === "editing" ? (
73
+
<EditPoll
74
+
totalVotes={totalVotes}
75
+
votes={votes.map((v) => v.poll_votes_on_entity)}
76
+
entityID={props.entityID}
77
+
close={() => {
78
+
if (hasVoted) setPollState("results");
79
+
else setPollState("voting");
80
+
}}
81
+
/>
82
+
) : pollState === "results" ? (
83
+
<PollResults
84
+
entityID={props.entityID}
85
+
pollState={pollState}
86
+
setPollState={setPollState}
87
+
hasVoted={!!hasVoted}
88
+
/>
89
+
) : (
90
+
<PollVote
91
+
entityID={props.entityID}
92
+
onSubmit={() => setPollState("results")}
93
+
pollState={pollState}
94
+
setPollState={setPollState}
95
+
hasVoted={!!hasVoted}
96
+
/>
97
+
)}
98
+
</div>
99
+
);
100
+
};
101
+
102
+
const PollVote = (props: {
103
+
entityID: string;
104
+
onSubmit: () => void;
105
+
pollState: "editing" | "voting" | "results";
106
+
setPollState: (pollState: "editing" | "voting" | "results") => void;
107
+
hasVoted: boolean;
108
+
}) => {
109
+
let { data, mutate } = usePollData();
110
+
let { permissions } = useEntitySetContext();
111
+
112
+
let pollOptions = useEntity(props.entityID, "poll/options");
113
+
let currentVotes = data?.voter_token
114
+
? data.polls
115
+
.filter(
116
+
(p) =>
117
+
p.poll_votes_on_entity.poll_entity === props.entityID &&
118
+
p.poll_votes_on_entity.voter_token === data.voter_token,
119
+
)
120
+
.map((v) => v.poll_votes_on_entity.option_entity)
121
+
: [];
122
+
let [selectedPollOptions, setSelectedPollOptions] =
123
+
useState<string[]>(currentVotes);
124
+
125
+
return (
126
+
<>
127
+
{pollOptions.map((option, index) => (
128
+
<PollVoteButton
129
+
key={option.data.value}
130
+
selected={selectedPollOptions.includes(option.data.value)}
131
+
toggleSelected={() =>
132
+
setSelectedPollOptions((s) =>
133
+
s.includes(option.data.value)
134
+
? s.filter((s) => s !== option.data.value)
135
+
: [...s, option.data.value],
136
+
)
137
+
}
138
+
entityID={option.data.value}
139
+
/>
140
+
))}
141
+
<div className="flex justify-between items-center">
142
+
<div className="flex justify-end gap-2">
143
+
{permissions.write && (
144
+
<button
145
+
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
146
+
onClick={() => {
147
+
props.setPollState("editing");
148
+
}}
149
+
>
150
+
Edit Options
151
+
</button>
152
+
)}
153
+
154
+
{permissions.write && <Separator classname="h-6" />}
155
+
<PollStateToggle
156
+
setPollState={props.setPollState}
157
+
pollState={props.pollState}
158
+
hasVoted={props.hasVoted}
159
+
/>
160
+
</div>
161
+
<ButtonPrimary
162
+
className="place-self-end"
163
+
onClick={async () => {
164
+
await voteOnPoll(props.entityID, selectedPollOptions);
165
+
mutate((oldState) => {
166
+
if (!oldState || !oldState.voter_token) return;
167
+
return {
168
+
...oldState,
169
+
polls: [
170
+
...oldState.polls.filter(
171
+
(p) =>
172
+
!(
173
+
p.poll_votes_on_entity.voter_token ===
174
+
oldState.voter_token &&
175
+
p.poll_votes_on_entity.poll_entity == props.entityID
176
+
),
177
+
),
178
+
...selectedPollOptions.map((option_entity) => ({
179
+
poll_votes_on_entity: {
180
+
option_entity,
181
+
entities: { set: "" },
182
+
poll_entity: props.entityID,
183
+
voter_token: oldState.voter_token!,
184
+
},
185
+
})),
186
+
],
187
+
};
188
+
});
189
+
props.onSubmit();
190
+
}}
191
+
disabled={
192
+
selectedPollOptions.length === 0 ||
193
+
(selectedPollOptions.length === currentVotes.length &&
194
+
selectedPollOptions.every((s) => currentVotes.includes(s)))
195
+
}
196
+
>
197
+
Vote!
198
+
</ButtonPrimary>
199
+
</div>
200
+
</>
201
+
);
202
+
};
203
+
const PollVoteButton = (props: {
204
+
entityID: string;
205
+
selected: boolean;
206
+
toggleSelected: () => void;
207
+
}) => {
208
+
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
209
+
if (!optionName) return null;
210
+
if (props.selected)
211
+
return (
212
+
<div className="flex gap-2 items-center">
213
+
<ButtonPrimary
214
+
className={`pollOption grow max-w-full flex`}
215
+
onClick={() => {
216
+
props.toggleSelected();
217
+
}}
218
+
>
219
+
{optionName}
220
+
</ButtonPrimary>
221
+
</div>
222
+
);
223
+
return (
224
+
<div className="flex gap-2 items-center">
225
+
<ButtonSecondary
226
+
className={`pollOption grow max-w-full flex`}
227
+
onClick={() => {
228
+
props.toggleSelected();
229
+
}}
230
+
>
231
+
{optionName}
232
+
</ButtonSecondary>
233
+
</div>
234
+
);
235
+
};
236
+
237
+
const PollResults = (props: {
238
+
entityID: string;
239
+
pollState: "editing" | "voting" | "results";
240
+
setPollState: (pollState: "editing" | "voting" | "results") => void;
241
+
hasVoted: boolean;
242
+
}) => {
243
+
let { data } = usePollData();
244
+
let { permissions } = useEntitySetContext();
245
+
let pollOptions = useEntity(props.entityID, "poll/options");
246
+
let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID);
247
+
let votesByOptions = pollData?.votesByOption || {};
248
+
let highestVotes = Math.max(...Object.values(votesByOptions));
249
+
let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>(
250
+
(winningEntities, [entity, votes]) => {
251
+
if (votes === highestVotes) winningEntities.push(entity);
252
+
return winningEntities;
253
+
},
254
+
[],
255
+
);
256
+
return (
257
+
<>
258
+
{pollOptions.map((p) => (
259
+
<PollResult
260
+
key={p.id}
261
+
winner={winningOptionEntities.includes(p.data.value)}
262
+
entityID={p.data.value}
263
+
totalVotes={pollData?.unique_votes || 0}
264
+
votes={pollData?.votesByOption[p.data.value] || 0}
265
+
/>
266
+
))}
267
+
<div className="flex gap-2">
268
+
{permissions.write && (
269
+
<button
270
+
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
271
+
onClick={() => {
272
+
props.setPollState("editing");
273
+
}}
274
+
>
275
+
Edit Options
276
+
</button>
277
+
)}
278
+
279
+
{permissions.write && <Separator classname="h-6" />}
280
+
<PollStateToggle
281
+
setPollState={props.setPollState}
282
+
pollState={props.pollState}
283
+
hasVoted={props.hasVoted}
284
+
/>
285
+
</div>
286
+
</>
287
+
);
288
+
};
289
+
290
+
const PollResult = (props: {
291
+
entityID: string;
292
+
votes: number;
293
+
totalVotes: number;
294
+
winner: boolean;
295
+
}) => {
296
+
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
297
+
return (
298
+
<div
299
+
className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
300
+
>
301
+
<div
302
+
style={{
303
+
WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`,
304
+
paintOrder: "stroke fill",
305
+
}}
306
+
className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`}
307
+
>
308
+
<div className="grow max-w-full truncate">{optionName}</div>
309
+
<div>{props.votes}</div>
310
+
</div>
311
+
<div
312
+
className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`}
313
+
>
314
+
<div
315
+
className={`bg-accent-contrast rounded-[2px] m-0.5`}
316
+
style={{
317
+
maskImage: "var(--hatchSVG)",
318
+
maskRepeat: "repeat repeat",
319
+
320
+
...(props.votes === 0
321
+
? { width: "4px" }
322
+
: { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
323
+
}}
324
+
/>
325
+
<div />
326
+
</div>
327
+
</div>
328
+
);
329
+
};
330
+
331
+
const EditPoll = (props: {
332
+
votes: { option_entity: string }[];
333
+
totalVotes: number;
334
+
entityID: string;
335
+
close: () => void;
336
+
}) => {
337
+
let pollOptions = useEntity(props.entityID, "poll/options");
338
+
let { rep } = useReplicache();
339
+
let permission_set = useEntitySetContext();
340
+
let [localPollOptionNames, setLocalPollOptionNames] = useState<{
341
+
[k: string]: string;
342
+
}>({});
343
+
return (
344
+
<>
345
+
{props.totalVotes > 0 && (
346
+
<div className="text-sm italic text-tertiary">
347
+
You can't edit options people already voted for!
348
+
</div>
349
+
)}
350
+
351
+
{pollOptions.length === 0 && (
352
+
<div className="text-center italic text-tertiary text-sm">
353
+
no options yet...
354
+
</div>
355
+
)}
356
+
{pollOptions.map((p) => (
357
+
<EditPollOption
358
+
key={p.id}
359
+
entityID={p.data.value}
360
+
pollEntity={props.entityID}
361
+
disabled={!!props.votes.find((v) => v.option_entity === p.data.value)}
362
+
localNameState={localPollOptionNames[p.data.value]}
363
+
setLocalNameState={setLocalPollOptionNames}
364
+
/>
365
+
))}
366
+
367
+
<button
368
+
className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
369
+
onClick={async () => {
370
+
let pollOptionEntity = v7();
371
+
await rep?.mutate.addPollOption({
372
+
pollEntity: props.entityID,
373
+
pollOptionEntity,
374
+
pollOptionName: "",
375
+
permission_set: permission_set.set,
376
+
factID: v7(),
377
+
});
378
+
379
+
focusElement(
380
+
document.getElementById(
381
+
elementId.block(props.entityID).pollInput(pollOptionEntity),
382
+
) as HTMLInputElement | null,
383
+
);
384
+
}}
385
+
>
386
+
Add an Option
387
+
</button>
388
+
389
+
<hr className="border-border" />
390
+
<ButtonPrimary
391
+
className="place-self-end"
392
+
onClick={async () => {
393
+
// remove any poll options that have no name
394
+
// look through the localPollOptionNames object and remove any options that have no name
395
+
let emptyOptions = Object.entries(localPollOptionNames).filter(
396
+
([optionEntity, optionName]) => optionName === "",
397
+
);
398
+
await Promise.all(
399
+
emptyOptions.map(
400
+
async ([entity]) =>
401
+
await rep?.mutate.removePollOption({
402
+
optionEntity: entity,
403
+
}),
404
+
),
405
+
);
406
+
407
+
await rep?.mutate.assertFact(
408
+
Object.entries(localPollOptionNames)
409
+
.filter(([, name]) => !!name)
410
+
.map(([entity, name]) => ({
411
+
entity,
412
+
attribute: "poll-option/name",
413
+
data: { type: "string", value: name },
414
+
})),
415
+
);
416
+
props.close();
417
+
}}
418
+
>
419
+
Save <CheckTiny />
420
+
</ButtonPrimary>
421
+
</>
422
+
);
423
+
};
424
+
425
+
const EditPollOption = (props: {
426
+
entityID: string;
427
+
pollEntity: string;
428
+
localNameState: string | undefined;
429
+
setLocalNameState: (
430
+
s: (s: { [k: string]: string }) => { [k: string]: string },
431
+
) => void;
432
+
disabled: boolean;
433
+
}) => {
434
+
let { rep } = useReplicache();
435
+
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
436
+
useEffect(() => {
437
+
props.setLocalNameState((s) => ({
438
+
...s,
439
+
[props.entityID]: optionName || "",
440
+
}));
441
+
}, [optionName, props.setLocalNameState, props.entityID]);
442
+
443
+
return (
444
+
<div className="flex gap-2 items-center">
445
+
<Input
446
+
id={elementId.block(props.pollEntity).pollInput(props.entityID)}
447
+
type="text"
448
+
className="pollOptionInput w-full input-with-border"
449
+
placeholder="Option here..."
450
+
disabled={props.disabled}
451
+
value={
452
+
props.localNameState === undefined ? optionName : props.localNameState
453
+
}
454
+
onChange={(e) => {
455
+
props.setLocalNameState((s) => ({
456
+
...s,
457
+
[props.entityID]: e.target.value,
458
+
}));
459
+
}}
460
+
onKeyDown={(e) => {
461
+
if (e.key === "Backspace" && !e.currentTarget.value) {
462
+
e.preventDefault();
463
+
rep?.mutate.removePollOption({ optionEntity: props.entityID });
464
+
}
465
+
}}
466
+
/>
467
+
468
+
<button
469
+
tabIndex={-1}
470
+
disabled={props.disabled}
471
+
className="text-accent-contrast disabled:text-border"
472
+
onMouseDown={async () => {
473
+
await rep?.mutate.removePollOption({ optionEntity: props.entityID });
474
+
}}
475
+
>
476
+
<CloseTiny />
477
+
</button>
478
+
</div>
479
+
);
480
+
};
481
+
482
+
const PollStateToggle = (props: {
483
+
setPollState: (pollState: "editing" | "voting" | "results") => void;
484
+
hasVoted: boolean;
485
+
pollState: "editing" | "voting" | "results";
486
+
}) => {
487
+
return (
488
+
<button
489
+
className="text-sm text-accent-contrast sm:hover:underline"
490
+
onClick={() => {
491
+
props.setPollState(props.pollState === "voting" ? "results" : "voting");
492
+
}}
493
+
>
494
+
{props.pollState === "voting"
495
+
? "See Results"
496
+
: props.hasVoted
497
+
? "Change Vote"
498
+
: "Back to Poll"}
499
+
</button>
500
+
);
501
+
};
+8
components/Blocks/PollBlock/pollBlockState.ts
+8
components/Blocks/PollBlock/pollBlockState.ts
-507
components/Blocks/PollBlock.tsx
-507
components/Blocks/PollBlock.tsx
···
1
-
import { useUIState } from "src/useUIState";
2
-
import { BlockProps } from "./Block";
3
-
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
4
-
import { useCallback, useEffect, useState } from "react";
5
-
import { focusElement, Input } from "components/Input";
6
-
import { Separator } from "components/Layout";
7
-
import { useEntitySetContext } from "components/EntitySetProvider";
8
-
import { theme } from "tailwind.config";
9
-
import { useEntity, useReplicache } from "src/replicache";
10
-
import { v7 } from "uuid";
11
-
import {
12
-
useLeafletPublicationData,
13
-
usePollData,
14
-
} from "components/PageSWRDataProvider";
15
-
import { voteOnPoll } from "actions/pollActions";
16
-
import { create } from "zustand";
17
-
import { elementId } from "src/utils/elementId";
18
-
import { CheckTiny } from "components/Icons/CheckTiny";
19
-
import { CloseTiny } from "components/Icons/CloseTiny";
20
-
import { PublicationPollBlock } from "./PublicationPollBlock";
21
-
22
-
export let usePollBlockUIState = create(
23
-
() =>
24
-
({}) as {
25
-
[entity: string]: { state: "editing" | "voting" | "results" } | undefined;
26
-
},
27
-
);
28
-
29
-
export const PollBlock = (props: BlockProps) => {
30
-
let { data: pub } = useLeafletPublicationData();
31
-
if (!pub) return <LeafletPollBlock {...props} />;
32
-
return <PublicationPollBlock {...props} />;
33
-
};
34
-
35
-
export const LeafletPollBlock = (props: BlockProps) => {
36
-
let isSelected = useUIState((s) =>
37
-
s.selectedBlocks.find((b) => b.value === props.entityID),
38
-
);
39
-
let { permissions } = useEntitySetContext();
40
-
41
-
let { data: pollData } = usePollData();
42
-
let hasVoted =
43
-
pollData?.voter_token &&
44
-
pollData.polls.find(
45
-
(v) =>
46
-
v.poll_votes_on_entity.voter_token === pollData.voter_token &&
47
-
v.poll_votes_on_entity.poll_entity === props.entityID,
48
-
);
49
-
50
-
let pollState = usePollBlockUIState((s) => s[props.entityID]?.state);
51
-
if (!pollState) {
52
-
if (hasVoted) pollState = "results";
53
-
else pollState = "voting";
54
-
}
55
-
56
-
const setPollState = useCallback(
57
-
(state: "editing" | "voting" | "results") => {
58
-
usePollBlockUIState.setState((s) => ({ [props.entityID]: { state } }));
59
-
},
60
-
[],
61
-
);
62
-
63
-
let votes =
64
-
pollData?.polls.filter(
65
-
(v) => v.poll_votes_on_entity.poll_entity === props.entityID,
66
-
) || [];
67
-
let totalVotes = votes.length;
68
-
69
-
return (
70
-
<div
71
-
className={`poll flex flex-col gap-2 p-3 w-full
72
-
${isSelected ? "block-border-selected " : "block-border"}`}
73
-
style={{
74
-
backgroundColor:
75
-
"color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)",
76
-
}}
77
-
>
78
-
{pollState === "editing" ? (
79
-
<EditPoll
80
-
totalVotes={totalVotes}
81
-
votes={votes.map((v) => v.poll_votes_on_entity)}
82
-
entityID={props.entityID}
83
-
close={() => {
84
-
if (hasVoted) setPollState("results");
85
-
else setPollState("voting");
86
-
}}
87
-
/>
88
-
) : pollState === "results" ? (
89
-
<PollResults
90
-
entityID={props.entityID}
91
-
pollState={pollState}
92
-
setPollState={setPollState}
93
-
hasVoted={!!hasVoted}
94
-
/>
95
-
) : (
96
-
<PollVote
97
-
entityID={props.entityID}
98
-
onSubmit={() => setPollState("results")}
99
-
pollState={pollState}
100
-
setPollState={setPollState}
101
-
hasVoted={!!hasVoted}
102
-
/>
103
-
)}
104
-
</div>
105
-
);
106
-
};
107
-
108
-
const PollVote = (props: {
109
-
entityID: string;
110
-
onSubmit: () => void;
111
-
pollState: "editing" | "voting" | "results";
112
-
setPollState: (pollState: "editing" | "voting" | "results") => void;
113
-
hasVoted: boolean;
114
-
}) => {
115
-
let { data, mutate } = usePollData();
116
-
let { permissions } = useEntitySetContext();
117
-
118
-
let pollOptions = useEntity(props.entityID, "poll/options");
119
-
let currentVotes = data?.voter_token
120
-
? data.polls
121
-
.filter(
122
-
(p) =>
123
-
p.poll_votes_on_entity.poll_entity === props.entityID &&
124
-
p.poll_votes_on_entity.voter_token === data.voter_token,
125
-
)
126
-
.map((v) => v.poll_votes_on_entity.option_entity)
127
-
: [];
128
-
let [selectedPollOptions, setSelectedPollOptions] =
129
-
useState<string[]>(currentVotes);
130
-
131
-
return (
132
-
<>
133
-
{pollOptions.map((option, index) => (
134
-
<PollVoteButton
135
-
key={option.data.value}
136
-
selected={selectedPollOptions.includes(option.data.value)}
137
-
toggleSelected={() =>
138
-
setSelectedPollOptions((s) =>
139
-
s.includes(option.data.value)
140
-
? s.filter((s) => s !== option.data.value)
141
-
: [...s, option.data.value],
142
-
)
143
-
}
144
-
entityID={option.data.value}
145
-
/>
146
-
))}
147
-
<div className="flex justify-between items-center">
148
-
<div className="flex justify-end gap-2">
149
-
{permissions.write && (
150
-
<button
151
-
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
152
-
onClick={() => {
153
-
props.setPollState("editing");
154
-
}}
155
-
>
156
-
Edit Options
157
-
</button>
158
-
)}
159
-
160
-
{permissions.write && <Separator classname="h-6" />}
161
-
<PollStateToggle
162
-
setPollState={props.setPollState}
163
-
pollState={props.pollState}
164
-
hasVoted={props.hasVoted}
165
-
/>
166
-
</div>
167
-
<ButtonPrimary
168
-
className="place-self-end"
169
-
onClick={async () => {
170
-
await voteOnPoll(props.entityID, selectedPollOptions);
171
-
mutate((oldState) => {
172
-
if (!oldState || !oldState.voter_token) return;
173
-
return {
174
-
...oldState,
175
-
polls: [
176
-
...oldState.polls.filter(
177
-
(p) =>
178
-
!(
179
-
p.poll_votes_on_entity.voter_token ===
180
-
oldState.voter_token &&
181
-
p.poll_votes_on_entity.poll_entity == props.entityID
182
-
),
183
-
),
184
-
...selectedPollOptions.map((option_entity) => ({
185
-
poll_votes_on_entity: {
186
-
option_entity,
187
-
entities: { set: "" },
188
-
poll_entity: props.entityID,
189
-
voter_token: oldState.voter_token!,
190
-
},
191
-
})),
192
-
],
193
-
};
194
-
});
195
-
props.onSubmit();
196
-
}}
197
-
disabled={
198
-
selectedPollOptions.length === 0 ||
199
-
(selectedPollOptions.length === currentVotes.length &&
200
-
selectedPollOptions.every((s) => currentVotes.includes(s)))
201
-
}
202
-
>
203
-
Vote!
204
-
</ButtonPrimary>
205
-
</div>
206
-
</>
207
-
);
208
-
};
209
-
const PollVoteButton = (props: {
210
-
entityID: string;
211
-
selected: boolean;
212
-
toggleSelected: () => void;
213
-
}) => {
214
-
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
215
-
if (!optionName) return null;
216
-
if (props.selected)
217
-
return (
218
-
<div className="flex gap-2 items-center">
219
-
<ButtonPrimary
220
-
className={`pollOption grow max-w-full flex`}
221
-
onClick={() => {
222
-
props.toggleSelected();
223
-
}}
224
-
>
225
-
{optionName}
226
-
</ButtonPrimary>
227
-
</div>
228
-
);
229
-
return (
230
-
<div className="flex gap-2 items-center">
231
-
<ButtonSecondary
232
-
className={`pollOption grow max-w-full flex`}
233
-
onClick={() => {
234
-
props.toggleSelected();
235
-
}}
236
-
>
237
-
{optionName}
238
-
</ButtonSecondary>
239
-
</div>
240
-
);
241
-
};
242
-
243
-
const PollResults = (props: {
244
-
entityID: string;
245
-
pollState: "editing" | "voting" | "results";
246
-
setPollState: (pollState: "editing" | "voting" | "results") => void;
247
-
hasVoted: boolean;
248
-
}) => {
249
-
let { data } = usePollData();
250
-
let { permissions } = useEntitySetContext();
251
-
let pollOptions = useEntity(props.entityID, "poll/options");
252
-
let pollData = data?.pollVotes.find((p) => p.poll_entity === props.entityID);
253
-
let votesByOptions = pollData?.votesByOption || {};
254
-
let highestVotes = Math.max(...Object.values(votesByOptions));
255
-
let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>(
256
-
(winningEntities, [entity, votes]) => {
257
-
if (votes === highestVotes) winningEntities.push(entity);
258
-
return winningEntities;
259
-
},
260
-
[],
261
-
);
262
-
return (
263
-
<>
264
-
{pollOptions.map((p) => (
265
-
<PollResult
266
-
key={p.id}
267
-
winner={winningOptionEntities.includes(p.data.value)}
268
-
entityID={p.data.value}
269
-
totalVotes={pollData?.unique_votes || 0}
270
-
votes={pollData?.votesByOption[p.data.value] || 0}
271
-
/>
272
-
))}
273
-
<div className="flex gap-2">
274
-
{permissions.write && (
275
-
<button
276
-
className="pollEditOptions w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
277
-
onClick={() => {
278
-
props.setPollState("editing");
279
-
}}
280
-
>
281
-
Edit Options
282
-
</button>
283
-
)}
284
-
285
-
{permissions.write && <Separator classname="h-6" />}
286
-
<PollStateToggle
287
-
setPollState={props.setPollState}
288
-
pollState={props.pollState}
289
-
hasVoted={props.hasVoted}
290
-
/>
291
-
</div>
292
-
</>
293
-
);
294
-
};
295
-
296
-
const PollResult = (props: {
297
-
entityID: string;
298
-
votes: number;
299
-
totalVotes: number;
300
-
winner: boolean;
301
-
}) => {
302
-
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
303
-
return (
304
-
<div
305
-
className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`}
306
-
>
307
-
<div
308
-
style={{
309
-
WebkitTextStroke: `${props.winner ? "6px" : "6px"} ${theme.colors["bg-page"]}`,
310
-
paintOrder: "stroke fill",
311
-
}}
312
-
className={`pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10`}
313
-
>
314
-
<div className="grow max-w-full truncate">{optionName}</div>
315
-
<div>{props.votes}</div>
316
-
</div>
317
-
<div
318
-
className={`pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0`}
319
-
>
320
-
<div
321
-
className={`bg-accent-contrast rounded-[2px] m-0.5`}
322
-
style={{
323
-
maskImage: "var(--hatchSVG)",
324
-
maskRepeat: "repeat repeat",
325
-
326
-
...(props.votes === 0
327
-
? { width: "4px" }
328
-
: { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }),
329
-
}}
330
-
/>
331
-
<div />
332
-
</div>
333
-
</div>
334
-
);
335
-
};
336
-
337
-
const EditPoll = (props: {
338
-
votes: { option_entity: string }[];
339
-
totalVotes: number;
340
-
entityID: string;
341
-
close: () => void;
342
-
}) => {
343
-
let pollOptions = useEntity(props.entityID, "poll/options");
344
-
let { rep } = useReplicache();
345
-
let permission_set = useEntitySetContext();
346
-
let [localPollOptionNames, setLocalPollOptionNames] = useState<{
347
-
[k: string]: string;
348
-
}>({});
349
-
return (
350
-
<>
351
-
{props.totalVotes > 0 && (
352
-
<div className="text-sm italic text-tertiary">
353
-
You can't edit options people already voted for!
354
-
</div>
355
-
)}
356
-
357
-
{pollOptions.length === 0 && (
358
-
<div className="text-center italic text-tertiary text-sm">
359
-
no options yet...
360
-
</div>
361
-
)}
362
-
{pollOptions.map((p) => (
363
-
<EditPollOption
364
-
key={p.id}
365
-
entityID={p.data.value}
366
-
pollEntity={props.entityID}
367
-
disabled={!!props.votes.find((v) => v.option_entity === p.data.value)}
368
-
localNameState={localPollOptionNames[p.data.value]}
369
-
setLocalNameState={setLocalPollOptionNames}
370
-
/>
371
-
))}
372
-
373
-
<button
374
-
className="pollAddOption w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
375
-
onClick={async () => {
376
-
let pollOptionEntity = v7();
377
-
await rep?.mutate.addPollOption({
378
-
pollEntity: props.entityID,
379
-
pollOptionEntity,
380
-
pollOptionName: "",
381
-
permission_set: permission_set.set,
382
-
factID: v7(),
383
-
});
384
-
385
-
focusElement(
386
-
document.getElementById(
387
-
elementId.block(props.entityID).pollInput(pollOptionEntity),
388
-
) as HTMLInputElement | null,
389
-
);
390
-
}}
391
-
>
392
-
Add an Option
393
-
</button>
394
-
395
-
<hr className="border-border" />
396
-
<ButtonPrimary
397
-
className="place-self-end"
398
-
onClick={async () => {
399
-
// remove any poll options that have no name
400
-
// look through the localPollOptionNames object and remove any options that have no name
401
-
let emptyOptions = Object.entries(localPollOptionNames).filter(
402
-
([optionEntity, optionName]) => optionName === "",
403
-
);
404
-
await Promise.all(
405
-
emptyOptions.map(
406
-
async ([entity]) =>
407
-
await rep?.mutate.removePollOption({
408
-
optionEntity: entity,
409
-
}),
410
-
),
411
-
);
412
-
413
-
await rep?.mutate.assertFact(
414
-
Object.entries(localPollOptionNames)
415
-
.filter(([, name]) => !!name)
416
-
.map(([entity, name]) => ({
417
-
entity,
418
-
attribute: "poll-option/name",
419
-
data: { type: "string", value: name },
420
-
})),
421
-
);
422
-
props.close();
423
-
}}
424
-
>
425
-
Save <CheckTiny />
426
-
</ButtonPrimary>
427
-
</>
428
-
);
429
-
};
430
-
431
-
const EditPollOption = (props: {
432
-
entityID: string;
433
-
pollEntity: string;
434
-
localNameState: string | undefined;
435
-
setLocalNameState: (
436
-
s: (s: { [k: string]: string }) => { [k: string]: string },
437
-
) => void;
438
-
disabled: boolean;
439
-
}) => {
440
-
let { rep } = useReplicache();
441
-
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
442
-
useEffect(() => {
443
-
props.setLocalNameState((s) => ({
444
-
...s,
445
-
[props.entityID]: optionName || "",
446
-
}));
447
-
}, [optionName, props.setLocalNameState, props.entityID]);
448
-
449
-
return (
450
-
<div className="flex gap-2 items-center">
451
-
<Input
452
-
id={elementId.block(props.pollEntity).pollInput(props.entityID)}
453
-
type="text"
454
-
className="pollOptionInput w-full input-with-border"
455
-
placeholder="Option here..."
456
-
disabled={props.disabled}
457
-
value={
458
-
props.localNameState === undefined ? optionName : props.localNameState
459
-
}
460
-
onChange={(e) => {
461
-
props.setLocalNameState((s) => ({
462
-
...s,
463
-
[props.entityID]: e.target.value,
464
-
}));
465
-
}}
466
-
onKeyDown={(e) => {
467
-
if (e.key === "Backspace" && !e.currentTarget.value) {
468
-
e.preventDefault();
469
-
rep?.mutate.removePollOption({ optionEntity: props.entityID });
470
-
}
471
-
}}
472
-
/>
473
-
474
-
<button
475
-
tabIndex={-1}
476
-
disabled={props.disabled}
477
-
className="text-accent-contrast disabled:text-border"
478
-
onMouseDown={async () => {
479
-
await rep?.mutate.removePollOption({ optionEntity: props.entityID });
480
-
}}
481
-
>
482
-
<CloseTiny />
483
-
</button>
484
-
</div>
485
-
);
486
-
};
487
-
488
-
const PollStateToggle = (props: {
489
-
setPollState: (pollState: "editing" | "voting" | "results") => void;
490
-
hasVoted: boolean;
491
-
pollState: "editing" | "voting" | "results";
492
-
}) => {
493
-
return (
494
-
<button
495
-
className="text-sm text-accent-contrast sm:hover:underline"
496
-
onClick={() => {
497
-
props.setPollState(props.pollState === "voting" ? "results" : "voting");
498
-
}}
499
-
>
500
-
{props.pollState === "voting"
501
-
? "See Results"
502
-
: props.hasVoted
503
-
? "Change Vote"
504
-
: "Back to Poll"}
505
-
</button>
506
-
);
507
-
};
+2
-1
components/Blocks/PublicationPollBlock.tsx
+2
-1
components/Blocks/PublicationPollBlock.tsx
···
1
1
import { useUIState } from "src/useUIState";
2
2
import { BlockProps } from "./Block";
3
3
import { useMemo } from "react";
4
-
import { focusElement, AsyncValueInput } from "components/Input";
4
+
import { AsyncValueInput } from "components/Input";
5
+
import { focusElement } from "src/utils/focusElement";
5
6
import { useEntitySetContext } from "components/EntitySetProvider";
6
7
import { useEntity, useReplicache } from "src/replicache";
7
8
import { v7 } from "uuid";
+31
-35
components/Blocks/TextBlock/RenderYJSFragment.tsx
+31
-35
components/Blocks/TextBlock/RenderYJSFragment.tsx
···
3
3
import { CSSProperties, Fragment } from "react";
4
4
import { theme } from "tailwind.config";
5
5
import * as base64 from "base64-js";
6
+
import { didToBlueskyUrl } from "src/utils/mentionUtils";
7
+
import { AtMentionLink } from "components/AtMentionLink";
8
+
import { Delta } from "src/utils/yjsFragmentToString";
6
9
7
10
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
8
11
export function RenderYJSFragment({
···
64
67
return <br key={index} />;
65
68
}
66
69
70
+
// Handle didMention inline nodes
71
+
if (node.constructor === XmlElement && node.nodeName === "didMention") {
72
+
const did = node.getAttribute("did") || "";
73
+
const text = node.getAttribute("text") || "";
74
+
return (
75
+
<a
76
+
href={didToBlueskyUrl(did)}
77
+
target="_blank"
78
+
rel="noopener noreferrer"
79
+
key={index}
80
+
className="text-accent-contrast hover:underline cursor-pointer"
81
+
>
82
+
{text}
83
+
</a>
84
+
);
85
+
}
86
+
87
+
// Handle atMention inline nodes
88
+
if (node.constructor === XmlElement && node.nodeName === "atMention") {
89
+
const atURI = node.getAttribute("atURI") || "";
90
+
const text = node.getAttribute("text") || "";
91
+
return (
92
+
<AtMentionLink key={index} atURI={atURI}>
93
+
{text}
94
+
</AtMentionLink>
95
+
);
96
+
}
97
+
67
98
return null;
68
99
})
69
100
)}
···
101
132
}
102
133
};
103
134
104
-
export type Delta = {
105
-
insert: string;
106
-
attributes?: {
107
-
strong?: {};
108
-
code?: {};
109
-
em?: {};
110
-
underline?: {};
111
-
strikethrough?: {};
112
-
highlight?: { color: string };
113
-
link?: { href: string };
114
-
};
115
-
};
116
-
117
135
function attributesToStyle(d: Delta) {
118
136
let props = {
119
137
style: {},
···
144
162
return props;
145
163
}
146
164
147
-
export function YJSFragmentToString(
148
-
node: XmlElement | XmlText | XmlHook,
149
-
): string {
150
-
if (node.constructor === XmlElement) {
151
-
// Handle hard_break nodes specially
152
-
if (node.nodeName === "hard_break") {
153
-
return "\n";
154
-
}
155
-
return node
156
-
.toArray()
157
-
.map((f) => YJSFragmentToString(f))
158
-
.join("");
159
-
}
160
-
if (node.constructor === XmlText) {
161
-
return (node.toDelta() as Delta[])
162
-
.map((d) => {
163
-
return d.insert;
164
-
})
165
-
.join("");
166
-
}
167
-
return "";
168
-
}
+109
-14
components/Blocks/TextBlock/index.tsx
+109
-14
components/Blocks/TextBlock/index.tsx
···
1
-
import { useRef, useEffect, useState } from "react";
1
+
import { useRef, useEffect, useState, useCallback } from "react";
2
2
import { elementId } from "src/utils/elementId";
3
3
import { useReplicache, useEntity } from "src/replicache";
4
4
import { isVisible } from "src/utils/isVisible";
5
5
import { EditorState, TextSelection } from "prosemirror-state";
6
+
import { EditorView } from "prosemirror-view";
6
7
import { RenderYJSFragment } from "./RenderYJSFragment";
7
8
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
8
9
import { BlockProps } from "../Block";
···
23
24
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
24
25
import { DotLoader } from "components/utils/DotLoader";
25
26
import { useMountProsemirror } from "./mountProsemirror";
27
+
import { schema } from "./schema";
28
+
29
+
import { Mention, MentionAutocomplete } from "components/Mention";
30
+
import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
26
31
27
32
const HeadingStyle = {
28
33
1: "text-xl font-bold",
···
183
188
let editorState = useEditorStates(
184
189
(s) => s.editorStates[props.entityID],
185
190
)?.editor;
191
+
const {
192
+
viewRef,
193
+
mentionOpen,
194
+
mentionCoords,
195
+
openMentionAutocomplete,
196
+
handleMentionSelect,
197
+
handleMentionOpenChange,
198
+
} = useMentionState(props.entityID);
186
199
187
200
let { mountRef, actionTimeout } = useMountProsemirror({
188
201
props,
202
+
openMentionAutocomplete,
189
203
});
190
204
191
205
return (
···
199
213
? "blockquote pt-3"
200
214
: "blockquote"
201
215
: ""
202
-
}
203
-
204
-
`}
216
+
}`}
205
217
>
206
218
<pre
207
219
data-entityid={props.entityID}
···
224
236
}
225
237
}}
226
238
onFocus={() => {
239
+
handleMentionOpenChange(false);
227
240
setTimeout(() => {
228
241
useUIState.getState().setSelectedBlock(props);
229
242
useUIState.setState(() => ({
···
249
262
${props.className}`}
250
263
ref={mountRef}
251
264
/>
265
+
{focused && (
266
+
<MentionAutocomplete
267
+
open={mentionOpen}
268
+
onOpenChange={handleMentionOpenChange}
269
+
view={viewRef}
270
+
onSelect={handleMentionSelect}
271
+
coords={mentionCoords}
272
+
/>
273
+
)}
252
274
{editorState?.doc.textContent.length === 0 &&
253
275
props.previousBlock === null &&
254
276
props.nextBlock === null ? (
···
439
461
);
440
462
};
441
463
442
-
const useMentionState = () => {
443
-
const [editorState, setEditorState] = useState<EditorState | null>(null);
444
-
const [mentionState, setMentionState] = useState<{
445
-
active: boolean;
446
-
range: { from: number; to: number } | null;
447
-
selectedMention: { handle: string; did: string } | null;
448
-
}>({ active: false, range: null, selectedMention: null });
449
-
const mentionStateRef = useRef(mentionState);
450
-
mentionStateRef.current = mentionState;
451
-
return { mentionStateRef };
464
+
const useMentionState = (entityID: string) => {
465
+
let view = useEditorStates((s) => s.editorStates[entityID])?.view;
466
+
let viewRef = useRef(view || null);
467
+
viewRef.current = view || null;
468
+
469
+
const [mentionOpen, setMentionOpen] = useState(false);
470
+
const [mentionCoords, setMentionCoords] = useState<{
471
+
top: number;
472
+
left: number;
473
+
} | null>(null);
474
+
const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null);
475
+
476
+
// Close autocomplete when this block is no longer focused
477
+
const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID);
478
+
useEffect(() => {
479
+
if (!isFocused) {
480
+
setMentionOpen(false);
481
+
setMentionCoords(null);
482
+
setMentionInsertPos(null);
483
+
}
484
+
}, [isFocused]);
485
+
486
+
const openMentionAutocomplete = useCallback(() => {
487
+
const view = useEditorStates.getState().editorStates[entityID]?.view;
488
+
if (!view) return;
489
+
490
+
// Get the position right after the @ we just inserted
491
+
const pos = view.state.selection.from;
492
+
setMentionInsertPos(pos);
493
+
494
+
// Get coordinates for the popup relative to the positioned parent
495
+
const coords = view.coordsAtPos(pos - 1); // Position of the @
496
+
497
+
// Find the relative positioned parent container
498
+
const editorEl = view.dom;
499
+
const container = editorEl.closest('.relative') as HTMLElement | null;
500
+
501
+
if (container) {
502
+
const containerRect = container.getBoundingClientRect();
503
+
setMentionCoords({
504
+
top: coords.bottom - containerRect.top,
505
+
left: coords.left - containerRect.left,
506
+
});
507
+
} else {
508
+
setMentionCoords({
509
+
top: coords.bottom,
510
+
left: coords.left,
511
+
});
512
+
}
513
+
setMentionOpen(true);
514
+
}, [entityID]);
515
+
516
+
const handleMentionSelect = useCallback(
517
+
(mention: Mention) => {
518
+
const view = useEditorStates.getState().editorStates[entityID]?.view;
519
+
if (!view || mentionInsertPos === null) return;
520
+
521
+
// The @ is at mentionInsertPos - 1, we need to replace it with the mention
522
+
const from = mentionInsertPos - 1;
523
+
const to = mentionInsertPos;
524
+
525
+
addMentionToEditor(mention, { from, to }, view);
526
+
view.focus();
527
+
},
528
+
[entityID, mentionInsertPos],
529
+
);
530
+
531
+
const handleMentionOpenChange = useCallback((open: boolean) => {
532
+
setMentionOpen(open);
533
+
if (!open) {
534
+
setMentionCoords(null);
535
+
setMentionInsertPos(null);
536
+
}
537
+
}, []);
538
+
539
+
return {
540
+
viewRef,
541
+
mentionOpen,
542
+
mentionCoords,
543
+
openMentionAutocomplete,
544
+
handleMentionSelect,
545
+
handleMentionOpenChange,
546
+
};
452
547
};
+20
components/Blocks/TextBlock/inputRules.ts
+20
components/Blocks/TextBlock/inputRules.ts
···
15
15
export const inputrules = (
16
16
propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>,
17
17
repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>,
18
+
openMentionAutocomplete?: () => void,
18
19
) =>
19
20
inputRules({
20
21
//Strikethrough
···
189
190
data: { type: "number", value: headingLevel },
190
191
});
191
192
return tr;
193
+
}),
194
+
195
+
// Mention - @ at start of line, after space, or after hard break
196
+
new InputRule(/(?:^|\s)@$/, (state, match, start, end) => {
197
+
if (!openMentionAutocomplete) return null;
198
+
// Schedule opening the autocomplete after the transaction is applied
199
+
setTimeout(() => openMentionAutocomplete(), 0);
200
+
return null; // Let the @ be inserted normally
201
+
}),
202
+
// Mention - @ immediately after a hard break (hard breaks are nodes, not text)
203
+
new InputRule(/@$/, (state, match, start, end) => {
204
+
if (!openMentionAutocomplete) return null;
205
+
// Check if the character before @ is a hard break node
206
+
const $pos = state.doc.resolve(start);
207
+
const nodeBefore = $pos.nodeBefore;
208
+
if (nodeBefore && nodeBefore.type.name === "hard_break") {
209
+
setTimeout(() => openMentionAutocomplete(), 0);
210
+
}
211
+
return null; // Let the @ be inserted normally
192
212
}),
193
213
],
194
214
});
+5
-8
components/Blocks/TextBlock/keymap.ts
+5
-8
components/Blocks/TextBlock/keymap.ts
···
17
17
import { schema } from "./schema";
18
18
import { useUIState } from "src/useUIState";
19
19
import { setEditorState, useEditorStates } from "src/state/useEditorState";
20
-
import { focusPage } from "components/Pages";
20
+
import { focusPage } from "src/utils/focusPage";
21
21
import { v7 } from "uuid";
22
22
import { scanIndex } from "src/replicache/utils";
23
23
import { indent, outdent } from "src/utils/list-operations";
24
24
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
25
25
import { isTextBlock } from "src/utils/isTextBlock";
26
26
import { UndoManager } from "src/undoManager";
27
-
28
27
type PropsRef = RefObject<
29
28
BlockProps & {
30
29
entity_set: { set: string };
···
35
34
propsRef: PropsRef,
36
35
repRef: RefObject<Replicache<ReplicacheMutators> | null>,
37
36
um: UndoManager,
38
-
multiLine?: boolean,
37
+
openMentionAutocomplete: () => void,
39
38
) =>
40
39
({
41
40
"Meta-b": toggleMark(schema.marks.strong),
···
138
137
),
139
138
"Shift-Backspace": backspace(propsRef, repRef),
140
139
Enter: (state, dispatch, view) => {
141
-
if (multiLine && state.doc.content.size - state.selection.anchor > 1)
142
-
return false;
143
-
return um.withUndoGroup(() =>
144
-
enter(propsRef, repRef)(state, dispatch, view),
145
-
);
140
+
return um.withUndoGroup(() => {
141
+
return enter(propsRef, repRef)(state, dispatch, view);
142
+
});
146
143
},
147
144
"Shift-Enter": (state, dispatch, view) => {
148
145
// Insert a hard break
+48
-12
components/Blocks/TextBlock/mountProsemirror.ts
+48
-12
components/Blocks/TextBlock/mountProsemirror.ts
···
23
23
import { useHandlePaste } from "./useHandlePaste";
24
24
import { BlockProps } from "../Block";
25
25
import { useEntitySetContext } from "components/EntitySetProvider";
26
+
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
26
27
27
-
export function useMountProsemirror({ props }: { props: BlockProps }) {
28
+
export function useMountProsemirror({
29
+
props,
30
+
openMentionAutocomplete,
31
+
}: {
32
+
props: BlockProps;
33
+
openMentionAutocomplete: () => void;
34
+
}) {
28
35
let { entityID, parent } = props;
29
36
let rep = useReplicache();
30
37
let mountRef = useRef<HTMLPreElement | null>(null);
···
44
51
useLayoutEffect(() => {
45
52
if (!mountRef.current) return;
46
53
47
-
const km = TextBlockKeymap(propsRef, repRef, rep.undoManager);
54
+
const km = TextBlockKeymap(
55
+
propsRef,
56
+
repRef,
57
+
rep.undoManager,
58
+
openMentionAutocomplete,
59
+
);
48
60
const editor = EditorState.create({
49
61
schema: schema,
50
62
plugins: [
51
63
ySyncPlugin(value),
52
64
keymap(km),
53
-
inputrules(propsRef, repRef),
65
+
inputrules(propsRef, repRef, openMentionAutocomplete),
54
66
keymap(baseKeymap),
55
67
highlightSelectionPlugin,
56
68
autolink({
···
69
81
handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
70
82
if (!direct) return;
71
83
if (node.nodeSize - 2 <= _pos) return;
72
-
let mark =
73
-
node
74
-
.nodeAt(_pos - 1)
75
-
?.marks.find((f) => f.type === schema.marks.link) ||
76
-
node
77
-
.nodeAt(Math.max(_pos - 2, 0))
78
-
?.marks.find((f) => f.type === schema.marks.link);
79
-
if (mark) {
80
-
window.open(mark.attrs.href, "_blank");
84
+
85
+
// Check for marks at the clicked position
86
+
const nodeAt1 = node.nodeAt(_pos - 1);
87
+
const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
88
+
89
+
// Check for link marks
90
+
let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
91
+
nodeAt2?.marks.find((f) => f.type === schema.marks.link);
92
+
if (linkMark) {
93
+
window.open(linkMark.attrs.href, "_blank");
94
+
return;
95
+
}
96
+
97
+
// Check for didMention inline nodes
98
+
if (nodeAt1?.type === schema.nodes.didMention) {
99
+
window.open(didToBlueskyUrl(nodeAt1.attrs.did), "_blank", "noopener,noreferrer");
100
+
return;
101
+
}
102
+
if (nodeAt2?.type === schema.nodes.didMention) {
103
+
window.open(didToBlueskyUrl(nodeAt2.attrs.did), "_blank", "noopener,noreferrer");
104
+
return;
105
+
}
106
+
107
+
// Check for atMention inline nodes
108
+
if (nodeAt1?.type === schema.nodes.atMention) {
109
+
const url = atUriToUrl(nodeAt1.attrs.atURI);
110
+
window.open(url, "_blank", "noopener,noreferrer");
111
+
return;
112
+
}
113
+
if (nodeAt2?.type === schema.nodes.atMention) {
114
+
const url = atUriToUrl(nodeAt2.attrs.atURI);
115
+
window.open(url, "_blank", "noopener,noreferrer");
116
+
return;
81
117
}
82
118
},
83
119
dispatchTransaction,
+100
-1
components/Blocks/TextBlock/schema.ts
+100
-1
components/Blocks/TextBlock/schema.ts
···
1
-
import { Schema, Node, MarkSpec } from "prosemirror-model";
1
+
import { AtUri } from "@atproto/api";
2
+
import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model";
2
3
import { marks } from "prosemirror-schema-basic";
3
4
import { theme } from "tailwind.config";
4
5
···
122
123
parseDOM: [{ tag: "br" }],
123
124
toDOM: () => ["br"] as const,
124
125
},
126
+
atMention: {
127
+
attrs: {
128
+
atURI: {},
129
+
text: { default: "" },
130
+
},
131
+
group: "inline",
132
+
inline: true,
133
+
atom: true,
134
+
selectable: true,
135
+
draggable: true,
136
+
parseDOM: [
137
+
{
138
+
tag: "span.atMention",
139
+
getAttrs(dom: HTMLElement) {
140
+
return {
141
+
atURI: dom.getAttribute("data-at-uri"),
142
+
text: dom.textContent || "",
143
+
};
144
+
},
145
+
},
146
+
],
147
+
toDOM(node) {
148
+
// NOTE: This rendering should match the AtMentionLink component in
149
+
// components/AtMentionLink.tsx. If you update one, update the other.
150
+
let className = "atMention text-accent-contrast";
151
+
let aturi = new AtUri(node.attrs.atURI);
152
+
if (aturi.collection === "pub.leaflet.publication")
153
+
className += " font-bold";
154
+
if (aturi.collection === "pub.leaflet.document") className += " italic";
155
+
156
+
// For publications and documents, show icon
157
+
if (
158
+
aturi.collection === "pub.leaflet.publication" ||
159
+
aturi.collection === "pub.leaflet.document"
160
+
) {
161
+
return [
162
+
"span",
163
+
{
164
+
class: className,
165
+
"data-at-uri": node.attrs.atURI,
166
+
},
167
+
[
168
+
"img",
169
+
{
170
+
src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`,
171
+
class: "inline-block w-5 h-5 rounded-full mr-1 align-text-top",
172
+
alt: "",
173
+
width: "16",
174
+
height: "16",
175
+
loading: "lazy",
176
+
},
177
+
],
178
+
node.attrs.text,
179
+
];
180
+
}
181
+
182
+
return [
183
+
"span",
184
+
{
185
+
class: className,
186
+
"data-at-uri": node.attrs.atURI,
187
+
},
188
+
node.attrs.text,
189
+
];
190
+
},
191
+
} as NodeSpec,
192
+
didMention: {
193
+
attrs: {
194
+
did: {},
195
+
text: { default: "" },
196
+
},
197
+
group: "inline",
198
+
inline: true,
199
+
atom: true,
200
+
selectable: true,
201
+
draggable: true,
202
+
parseDOM: [
203
+
{
204
+
tag: "span.didMention",
205
+
getAttrs(dom: HTMLElement) {
206
+
return {
207
+
did: dom.getAttribute("data-did"),
208
+
text: dom.textContent || "",
209
+
};
210
+
},
211
+
},
212
+
],
213
+
toDOM(node) {
214
+
return [
215
+
"span",
216
+
{
217
+
class: "didMention text-accent-contrast",
218
+
"data-did": node.attrs.did,
219
+
},
220
+
node.attrs.text,
221
+
];
222
+
},
223
+
} as NodeSpec,
125
224
},
126
225
};
127
226
export const schema = new Schema(baseSchema);
+1
-1
components/Blocks/useBlockKeyboardHandlers.ts
+1
-1
components/Blocks/useBlockKeyboardHandlers.ts
···
12
12
import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
13
13
import { useEntitySetContext } from "components/EntitySetProvider";
14
14
import { Replicache } from "replicache";
15
-
import { deleteBlock } from "./DeleteBlock";
15
+
import { deleteBlock } from "src/utils/deleteBlock";
16
16
import { entities } from "drizzle/schema";
17
17
import { scanIndex } from "src/replicache/utils";
18
18
+1
-1
components/Blocks/useBlockMouseHandlers.ts
+1
-1
components/Blocks/useBlockMouseHandlers.ts
···
1
-
import { useSelectingMouse } from "components/SelectionManager";
1
+
import { useSelectingMouse } from "components/SelectionManager/selectionState";
2
2
import { MouseEvent, useCallback, useRef } from "react";
3
3
import { useUIState } from "src/useUIState";
4
4
import { Block } from "./Block";
-17
components/EmptyState.tsx
-17
components/EmptyState.tsx
···
1
-
export const EmptyState = (props: {
2
-
children: React.ReactNode;
3
-
className?: string;
4
-
}) => {
5
-
return (
6
-
<div
7
-
className={`
8
-
flex flex-col gap-2 justify-between
9
-
container bg-[rgba(var(--bg-page),.7)]
10
-
sm:p-4 p-3 mt-2
11
-
text-center text-tertiary
12
-
${props.className}`}
13
-
>
14
-
{props.children}
15
-
</div>
16
-
);
17
-
};
+21
components/Icons/GoBackTiny.tsx
+21
components/Icons/GoBackTiny.tsx
···
1
+
import { Props } from "./Props";
2
+
3
+
export const GoBackTiny = (props: Props) => {
4
+
return (
5
+
<svg
6
+
width="16"
7
+
height="16"
8
+
viewBox="0 0 16 16"
9
+
fill="none"
10
+
xmlns="http://www.w3.org/2000/svg"
11
+
>
12
+
<path
13
+
d="M7.40426 3L2.19592 8M2.19592 8L7.40426 13M2.19592 8H13.8041"
14
+
stroke="currentColor"
15
+
strokeWidth="2"
16
+
strokeLinecap="round"
17
+
strokeLinejoin="round"
18
+
/>
19
+
</svg>
20
+
);
21
+
};
+19
components/Icons/TagTiny.tsx
+19
components/Icons/TagTiny.tsx
···
1
+
import { Props } from "./Props";
2
+
3
+
export const TagTiny = (props: Props) => {
4
+
return (
5
+
<svg
6
+
width="16"
7
+
height="16"
8
+
viewBox="0 0 16 16"
9
+
fill="none"
10
+
xmlns="http://www.w3.org/2000/svg"
11
+
{...props}
12
+
>
13
+
<path
14
+
d="M3.70775 9.003C3.96622 8.90595 4.25516 9.03656 4.35228 9.29499C4.37448 9.35423 4.38309 9.41497 4.38255 9.47468C4.38208 9.6765 4.25946 9.86621 4.05931 9.94148C3.36545 10.2021 2.74535 10.833 2.42747 11.5479C2.33495 11.7561 2.27242 11.9608 2.239 12.1573C2.15817 12.6374 2.25357 13.069 2.52513 13.3858C2.92043 13.8467 3.51379 14.0403 4.20189 14.0665C4.88917 14.0925 5.59892 13.9482 6.12571 13.8126C7.09158 13.5639 7.81893 13.6157 8.29954 13.9415C8.67856 14.1986 8.83462 14.578 8.8347 14.9298C8.83502 15.0506 8.81652 15.1682 8.78294 15.2764C8.7009 15.5398 8.42049 15.6873 8.15696 15.6055C7.89935 15.5253 7.75386 15.2555 7.82396 14.9971C7.82572 14.9905 7.8258 14.9833 7.82786 14.9766C7.83167 14.9643 7.834 14.9503 7.8347 14.9356C7.83623 14.8847 7.8147 14.823 7.739 14.7716C7.61179 14.6853 7.23586 14.5616 6.37474 14.7833C5.81779 14.9266 4.99695 15.1 4.1638 15.0684C3.33126 15.0368 2.41412 14.7967 1.76536 14.0401C1.30175 13.4992 1.16206 12.8427 1.22728 12.1993C1.23863 12.086 1.25554 11.9732 1.27903 11.8614C1.28235 11.8457 1.28624 11.8302 1.28978 11.8145C1.34221 11.5817 1.41832 11.3539 1.51439 11.1378C1.92539 10.2136 2.72927 9.37064 3.70775 9.003ZM13.8972 7.54695C14.124 7.38948 14.4359 7.44622 14.5935 7.67292C14.7508 7.89954 14.6948 8.21063 14.4685 8.36823L8.65892 12.4044C8.24041 12.695 7.74265 12.8515 7.23314 12.8516H3.9138C3.63794 12.8515 3.41315 12.6274 3.41282 12.3516C3.41282 12.0755 3.63769 11.8517 3.9138 11.8516H7.23216C7.538 11.8516 7.8374 11.7575 8.0886 11.5831L13.8972 7.54695ZM10.1609 0.550851C10.6142 0.235853 11.2372 0.347685 11.5525 0.800851L14.6091 5.19734C14.9239 5.65063 14.8121 6.27369 14.3591 6.58894L7.88841 11.087C7.63297 11.2645 7.32837 11.3586 7.01732 11.3555L4.1804 11.3262C3.76371 11.3218 3.38443 11.1921 3.072 10.9776C3.23822 10.7748 3.43062 10.5959 3.63646 10.4503C3.96958 10.5767 4.35782 10.5421 4.67259 10.3233C5.17899 9.97084 5.30487 9.27438 4.95286 8.76765C4.60048 8.26108 3.90304 8.13639 3.39622 8.48835C3.17656 8.64127 3.02799 8.85895 2.9597 9.09773C2.69658 9.26211 2.45194 9.45783 2.23118 9.67585C2.17892 9.38285 2.19133 9.07163 2.28294 8.76081L3.14818 5.8282C3.24483 5.50092 3.45101 5.21639 3.73118 5.02155L10.1609 0.550851ZM8.76732 3.73835L9.73607 4.91023L8.68626 5.41804L7.79466 6.24323L7.04857 4.91804L6.26634 5.45417L7.22923 6.63386L5.72337 7.40437L6.34739 8.31355L7.60814 7.18464L8.37767 8.53132L9.15989 7.99421L8.17454 6.79792L9.27708 6.25788L10.1179 5.46589L10.8786 6.81452L11.6609 6.27741L10.6745 5.07917L12.1882 4.30476L11.5642 3.39558L10.2976 4.52839L9.54954 3.20124L8.76732 3.73835Z"
15
+
fill="currentColor"
16
+
/>
17
+
</svg>
18
+
);
19
+
};
+1
-34
components/Input.tsx
+1
-34
components/Input.tsx
···
2
2
import { useEffect, useRef, useState, type JSX } from "react";
3
3
import { onMouseDown } from "src/utils/iosInputMouseDown";
4
4
import { isIOS } from "src/utils/isDevice";
5
+
import { focusElement } from "src/utils/focusElement";
5
6
6
7
export const Input = (
7
8
props: {
···
56
57
}}
57
58
/>
58
59
);
59
-
};
60
-
61
-
export const focusElement = (el?: HTMLInputElement | null) => {
62
-
if (!isIOS()) {
63
-
el?.focus();
64
-
return;
65
-
}
66
-
67
-
let fakeInput = document.createElement("input");
68
-
fakeInput.setAttribute("type", "text");
69
-
fakeInput.style.position = "fixed";
70
-
fakeInput.style.height = "0px";
71
-
fakeInput.style.width = "0px";
72
-
fakeInput.style.fontSize = "16px"; // disable auto zoom
73
-
document.body.appendChild(fakeInput);
74
-
fakeInput.focus();
75
-
setTimeout(() => {
76
-
if (!el) return;
77
-
el.style.transform = "translateY(-2000px)";
78
-
el?.focus();
79
-
fakeInput.remove();
80
-
el.value = " ";
81
-
el.setSelectionRange(1, 1);
82
-
requestAnimationFrame(() => {
83
-
if (el) {
84
-
el.style.transform = "";
85
-
}
86
-
});
87
-
setTimeout(() => {
88
-
if (!el) return;
89
-
el.value = "";
90
-
el.setSelectionRange(0, 0);
91
-
}, 50);
92
-
}, 20);
93
60
};
94
61
95
62
export const InputWithLabel = (
+114
components/InteractionsPreview.tsx
+114
components/InteractionsPreview.tsx
···
1
+
"use client";
2
+
import { Separator } from "./Layout";
3
+
import { CommentTiny } from "./Icons/CommentTiny";
4
+
import { QuoteTiny } from "./Icons/QuoteTiny";
5
+
import { useSmoker } from "./Toast";
6
+
import { Tag } from "./Tags";
7
+
import { Popover } from "./Popover";
8
+
import { TagTiny } from "./Icons/TagTiny";
9
+
import { SpeedyLink } from "./SpeedyLink";
10
+
11
+
export const InteractionPreview = (props: {
12
+
quotesCount: number;
13
+
commentsCount: number;
14
+
tags?: string[];
15
+
postUrl: string;
16
+
showComments: boolean | undefined;
17
+
share?: boolean;
18
+
}) => {
19
+
let smoker = useSmoker();
20
+
let interactionsAvailable =
21
+
props.quotesCount > 0 ||
22
+
(props.showComments !== false && props.commentsCount > 0);
23
+
24
+
const tagsCount = props.tags?.length || 0;
25
+
26
+
return (
27
+
<div
28
+
className={`flex gap-2 text-tertiary text-sm items-center self-start`}
29
+
>
30
+
{tagsCount === 0 ? null : (
31
+
<>
32
+
<TagPopover tags={props.tags!} />
33
+
{interactionsAvailable || props.share ? (
34
+
<Separator classname="h-4!" />
35
+
) : null}
36
+
</>
37
+
)}
38
+
39
+
{props.quotesCount === 0 ? null : (
40
+
<SpeedyLink
41
+
aria-label="Post quotes"
42
+
href={`${props.postUrl}?interactionDrawer=quotes`}
43
+
className="flex flex-row gap-1 text-sm items-center text-accent-contrast!"
44
+
>
45
+
<QuoteTiny /> {props.quotesCount}
46
+
</SpeedyLink>
47
+
)}
48
+
{props.showComments === false || props.commentsCount === 0 ? null : (
49
+
<SpeedyLink
50
+
aria-label="Post comments"
51
+
href={`${props.postUrl}?interactionDrawer=comments`}
52
+
className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary"
53
+
>
54
+
<CommentTiny /> {props.commentsCount}
55
+
</SpeedyLink>
56
+
)}
57
+
{interactionsAvailable && props.share ? (
58
+
<Separator classname="h-4! !min-h-0" />
59
+
) : null}
60
+
{props.share && (
61
+
<>
62
+
<button
63
+
id={`copy-post-link-${props.postUrl}`}
64
+
className="flex gap-1 items-center hover:text-accent-contrast relative"
65
+
onClick={(e) => {
66
+
e.stopPropagation();
67
+
e.preventDefault();
68
+
let mouseX = e.clientX;
69
+
let mouseY = e.clientY;
70
+
71
+
if (!props.postUrl) return;
72
+
navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`);
73
+
74
+
smoker({
75
+
text: <strong>Copied Link!</strong>,
76
+
position: {
77
+
y: mouseY,
78
+
x: mouseX,
79
+
},
80
+
});
81
+
}}
82
+
>
83
+
Share
84
+
</button>
85
+
</>
86
+
)}
87
+
</div>
88
+
);
89
+
};
90
+
91
+
const TagPopover = (props: { tags: string[] }) => {
92
+
return (
93
+
<Popover
94
+
className="p-2! max-w-xs"
95
+
trigger={
96
+
<div className="relative flex gap-1 items-center hover:text-accent-contrast ">
97
+
<TagTiny /> {props.tags.length}
98
+
</div>
99
+
}
100
+
>
101
+
<TagList tags={props.tags} className="text-secondary!" />
102
+
</Popover>
103
+
);
104
+
};
105
+
106
+
const TagList = (props: { tags: string[]; className?: string }) => {
107
+
return (
108
+
<div className="flex gap-1 flex-wrap">
109
+
{props.tags.map((tag, index) => (
110
+
<Tag name={tag} key={index} className={props.className} />
111
+
))}
112
+
</div>
113
+
);
114
+
};
+1
-1
components/Layout.tsx
+1
-1
components/Layout.tsx
···
3
3
import { theme } from "tailwind.config";
4
4
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
5
5
import { PopoverArrow } from "./Icons/PopoverArrow";
6
-
import { PopoverOpenContext } from "./Popover";
6
+
import { PopoverOpenContext } from "./Popover/PopoverContext";
7
7
import { useState } from "react";
8
8
9
9
export const Separator = (props: { classname?: string }) => {
+543
components/Mention.tsx
+543
components/Mention.tsx
···
1
+
"use client";
2
+
import { Agent } from "@atproto/api";
3
+
import { useState, useEffect, Fragment, useRef, useCallback } from "react";
4
+
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
5
+
import * as Popover from "@radix-ui/react-popover";
6
+
import { EditorView } from "prosemirror-view";
7
+
import { callRPC } from "app/api/rpc/client";
8
+
import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
9
+
import { GoBackSmall } from "components/Icons/GoBackSmall";
10
+
import { SearchTiny } from "components/Icons/SearchTiny";
11
+
import { CloseTiny } from "./Icons/CloseTiny";
12
+
import { GoToArrow } from "./Icons/GoToArrow";
13
+
import { GoBackTiny } from "./Icons/GoBackTiny";
14
+
15
+
export function MentionAutocomplete(props: {
16
+
open: boolean;
17
+
onOpenChange: (open: boolean) => void;
18
+
view: React.RefObject<EditorView | null>;
19
+
onSelect: (mention: Mention) => void;
20
+
coords: { top: number; left: number } | null;
21
+
placeholder?: string;
22
+
}) {
23
+
const [searchQuery, setSearchQuery] = useState("");
24
+
const [noResults, setNoResults] = useState(false);
25
+
const inputRef = useRef<HTMLInputElement>(null);
26
+
const contentRef = useRef<HTMLDivElement>(null);
27
+
28
+
const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } =
29
+
useMentionSuggestions(searchQuery);
30
+
31
+
// Clear search when scope changes
32
+
const handleScopeChange = useCallback(
33
+
(newScope: MentionScope) => {
34
+
setSearchQuery("");
35
+
setSuggestionIndex(0);
36
+
setScope(newScope);
37
+
},
38
+
[setScope, setSuggestionIndex],
39
+
);
40
+
41
+
// Focus input when opened
42
+
useEffect(() => {
43
+
if (props.open && inputRef.current) {
44
+
// Small delay to ensure the popover is mounted
45
+
setTimeout(() => inputRef.current?.focus(), 0);
46
+
}
47
+
}, [props.open]);
48
+
49
+
// Reset state when closed
50
+
useEffect(() => {
51
+
if (!props.open) {
52
+
setSearchQuery("");
53
+
setScope({ type: "default" });
54
+
setSuggestionIndex(0);
55
+
setNoResults(false);
56
+
}
57
+
}, [props.open, setScope, setSuggestionIndex]);
58
+
59
+
// Handle timeout for showing "No results found"
60
+
useEffect(() => {
61
+
if (searchQuery && suggestions.length === 0) {
62
+
setNoResults(false);
63
+
const timer = setTimeout(() => {
64
+
setNoResults(true);
65
+
}, 2000);
66
+
return () => clearTimeout(timer);
67
+
} else {
68
+
setNoResults(false);
69
+
}
70
+
}, [searchQuery, suggestions.length]);
71
+
72
+
// Handle keyboard navigation
73
+
const handleKeyDown = (e: React.KeyboardEvent) => {
74
+
if (e.key === "Escape") {
75
+
e.preventDefault();
76
+
props.onOpenChange(false);
77
+
props.view.current?.focus();
78
+
return;
79
+
}
80
+
81
+
if (e.key === "Backspace" && searchQuery === "") {
82
+
// Backspace at the start of input closes autocomplete and refocuses editor
83
+
e.preventDefault();
84
+
props.onOpenChange(false);
85
+
props.view.current?.focus();
86
+
return;
87
+
}
88
+
89
+
// Reverse arrow key direction when popover is rendered above
90
+
const isReversed = contentRef.current?.dataset.side === "top";
91
+
const upKey = isReversed ? "ArrowDown" : "ArrowUp";
92
+
const downKey = isReversed ? "ArrowUp" : "ArrowDown";
93
+
94
+
if (e.key === upKey) {
95
+
e.preventDefault();
96
+
if (suggestionIndex > 0) {
97
+
setSuggestionIndex((i) => i - 1);
98
+
}
99
+
} else if (e.key === downKey) {
100
+
e.preventDefault();
101
+
if (suggestionIndex < suggestions.length - 1) {
102
+
setSuggestionIndex((i) => i + 1);
103
+
}
104
+
} else if (e.key === "Tab") {
105
+
const selectedSuggestion = suggestions[suggestionIndex];
106
+
if (selectedSuggestion?.type === "publication") {
107
+
e.preventDefault();
108
+
handleScopeChange({
109
+
type: "publication",
110
+
uri: selectedSuggestion.uri,
111
+
name: selectedSuggestion.name,
112
+
});
113
+
}
114
+
} else if (e.key === "Enter") {
115
+
e.preventDefault();
116
+
const selectedSuggestion = suggestions[suggestionIndex];
117
+
if (selectedSuggestion) {
118
+
props.onSelect(selectedSuggestion);
119
+
props.onOpenChange(false);
120
+
}
121
+
} else if (
122
+
e.key === " " &&
123
+
searchQuery === "" &&
124
+
scope.type === "default"
125
+
) {
126
+
// Space immediately after opening closes the autocomplete
127
+
e.preventDefault();
128
+
props.onOpenChange(false);
129
+
// Insert a space after the @ in the editor
130
+
if (props.view.current) {
131
+
const view = props.view.current;
132
+
const tr = view.state.tr.insertText(" ");
133
+
view.dispatch(tr);
134
+
view.focus();
135
+
}
136
+
}
137
+
};
138
+
139
+
if (!props.open || !props.coords) return null;
140
+
141
+
const getHeader = (type: Mention["type"], scope?: MentionScope) => {
142
+
switch (type) {
143
+
case "did":
144
+
return "People";
145
+
case "publication":
146
+
return "Publications";
147
+
case "post":
148
+
if (scope) {
149
+
return (
150
+
<ScopeHeader
151
+
scope={scope}
152
+
handleScopeChange={() => {
153
+
handleScopeChange({ type: "default" });
154
+
}}
155
+
/>
156
+
);
157
+
} else return "Posts";
158
+
}
159
+
};
160
+
161
+
const sortedSuggestions = [...suggestions].sort((a, b) => {
162
+
const order: Mention["type"][] = ["did", "publication", "post"];
163
+
return order.indexOf(a.type) - order.indexOf(b.type);
164
+
});
165
+
166
+
return (
167
+
<Popover.Root open>
168
+
<Popover.Anchor
169
+
style={{
170
+
top: props.coords.top - 24,
171
+
left: props.coords.left,
172
+
height: 24,
173
+
position: "absolute",
174
+
}}
175
+
/>
176
+
<Popover.Portal>
177
+
<Popover.Content
178
+
ref={contentRef}
179
+
align="start"
180
+
sideOffset={4}
181
+
collisionPadding={32}
182
+
onOpenAutoFocus={(e) => e.preventDefault()}
183
+
className={`dropdownMenu group/mention-menu z-20 bg-bg-page
184
+
flex data-[side=top]:flex-col-reverse flex-col
185
+
p-1 gap-1 text-primary
186
+
border border-border rounded-md shadow-md
187
+
sm:max-w-xs w-[1000px] max-w-(--radix-popover-content-available-width)
188
+
max-h-(--radix-popover-content-available-height)
189
+
overflow-hidden`}
190
+
>
191
+
{/* Dropdown Header - sticky */}
192
+
<div className="flex flex-col items-center gap-2 px-2 py-1 border-b group-data-[side=top]/mention-menu:border-b-0 group-data-[side=top]/mention-menu:border-t border-border-light bg-bg-page sticky top-0 group-data-[side=top]/mention-menu:sticky group-data-[side=top]/mention-menu:bottom-0 group-data-[side=top]/mention-menu:top-auto z-10 shrink-0">
193
+
<div className="flex items-center gap-1 flex-1 min-w-0 text-primary">
194
+
<div className="text-tertiary">
195
+
<SearchTiny className="w-4 h-4 shrink-0" />
196
+
</div>
197
+
<input
198
+
ref={inputRef}
199
+
size={100}
200
+
type="text"
201
+
value={searchQuery}
202
+
onChange={(e) => {
203
+
setSearchQuery(e.target.value);
204
+
setSuggestionIndex(0);
205
+
}}
206
+
onKeyDown={handleKeyDown}
207
+
autoFocus
208
+
placeholder={
209
+
scope.type === "publication"
210
+
? "Search posts..."
211
+
: props.placeholder ?? "Search people & publications..."
212
+
}
213
+
className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary"
214
+
/>
215
+
</div>
216
+
</div>
217
+
<div className="overflow-y-auto flex-1 min-h-0">
218
+
{sortedSuggestions.length === 0 && noResults && (
219
+
<div className="text-sm text-tertiary italic px-3 py-1 text-center">
220
+
No results found
221
+
</div>
222
+
)}
223
+
<ul className="list-none p-0 text-sm flex flex-col group-data-[side=top]/mention-menu:flex-col-reverse">
224
+
{sortedSuggestions.map((result, index) => {
225
+
const prevResult = sortedSuggestions[index - 1];
226
+
const showHeader =
227
+
index === 0 ||
228
+
(prevResult && prevResult.type !== result.type);
229
+
230
+
return (
231
+
<Fragment
232
+
key={result.type === "did" ? result.did : result.uri}
233
+
>
234
+
{showHeader && (
235
+
<>
236
+
{index > 0 && (
237
+
<hr className="border-border-light mx-1 my-1" />
238
+
)}
239
+
<div className="text-xs text-tertiary font-bold pt-1 px-2">
240
+
{getHeader(result.type, scope)}
241
+
</div>
242
+
</>
243
+
)}
244
+
{result.type === "did" ? (
245
+
<DidResult
246
+
onClick={() => {
247
+
props.onSelect(result);
248
+
props.onOpenChange(false);
249
+
}}
250
+
onMouseDown={(e) => e.preventDefault()}
251
+
displayName={result.displayName}
252
+
handle={result.handle}
253
+
avatar={result.avatar}
254
+
selected={index === suggestionIndex}
255
+
/>
256
+
) : result.type === "publication" ? (
257
+
<PublicationResult
258
+
onClick={() => {
259
+
props.onSelect(result);
260
+
props.onOpenChange(false);
261
+
}}
262
+
onMouseDown={(e) => e.preventDefault()}
263
+
pubName={result.name}
264
+
uri={result.uri}
265
+
selected={index === suggestionIndex}
266
+
onPostsClick={() => {
267
+
handleScopeChange({
268
+
type: "publication",
269
+
uri: result.uri,
270
+
name: result.name,
271
+
});
272
+
}}
273
+
/>
274
+
) : (
275
+
<PostResult
276
+
onClick={() => {
277
+
props.onSelect(result);
278
+
props.onOpenChange(false);
279
+
}}
280
+
onMouseDown={(e) => e.preventDefault()}
281
+
title={result.title}
282
+
selected={index === suggestionIndex}
283
+
/>
284
+
)}
285
+
</Fragment>
286
+
);
287
+
})}
288
+
</ul>
289
+
</div>
290
+
</Popover.Content>
291
+
</Popover.Portal>
292
+
</Popover.Root>
293
+
);
294
+
}
295
+
296
+
const Result = (props: {
297
+
result: React.ReactNode;
298
+
subtext?: React.ReactNode;
299
+
icon?: React.ReactNode;
300
+
onClick: () => void;
301
+
onMouseDown: (e: React.MouseEvent) => void;
302
+
selected?: boolean;
303
+
}) => {
304
+
return (
305
+
<button
306
+
className={`
307
+
menuItem w-full flex-row! gap-2!
308
+
text-secondary leading-snug text-sm
309
+
${props.subtext ? "py-1!" : "py-2!"}
310
+
${props.selected ? "bg-[var(--accent-light)]!" : ""}`}
311
+
onClick={() => {
312
+
props.onClick();
313
+
}}
314
+
onMouseDown={(e) => props.onMouseDown(e)}
315
+
>
316
+
{props.icon}
317
+
<div className="flex flex-col min-w-0 flex-1">
318
+
<div
319
+
className={`flex gap-2 items-center w-full truncate justify-between`}
320
+
>
321
+
{props.result}
322
+
</div>
323
+
{props.subtext && (
324
+
<div className="text-tertiary italic text-xs font-normal min-w-0 truncate pb-[1px]">
325
+
{props.subtext}
326
+
</div>
327
+
)}
328
+
</div>
329
+
</button>
330
+
);
331
+
};
332
+
333
+
const ScopeButton = (props: {
334
+
onClick: () => void;
335
+
children: React.ReactNode;
336
+
}) => {
337
+
return (
338
+
<span
339
+
className="flex flex-row items-center h-full shrink-0 text-xs font-normal text-tertiary hover:text-accent-contrast cursor-pointer"
340
+
onClick={(e) => {
341
+
e.preventDefault();
342
+
e.stopPropagation();
343
+
props.onClick();
344
+
}}
345
+
onMouseDown={(e) => {
346
+
e.preventDefault();
347
+
e.stopPropagation();
348
+
}}
349
+
>
350
+
{props.children} <ArrowRightTiny className="scale-80" />
351
+
</span>
352
+
);
353
+
};
354
+
355
+
const DidResult = (props: {
356
+
displayName?: string;
357
+
handle: string;
358
+
avatar?: string;
359
+
onClick: () => void;
360
+
onMouseDown: (e: React.MouseEvent) => void;
361
+
selected?: boolean;
362
+
}) => {
363
+
return (
364
+
<Result
365
+
icon={
366
+
props.avatar ? (
367
+
<img
368
+
src={props.avatar}
369
+
alt=""
370
+
className="w-5 h-5 rounded-full shrink-0"
371
+
/>
372
+
) : (
373
+
<div className="w-5 h-5 rounded-full bg-border shrink-0" />
374
+
)
375
+
}
376
+
result={props.displayName ? props.displayName : props.handle}
377
+
subtext={props.displayName && `@${props.handle}`}
378
+
onClick={props.onClick}
379
+
onMouseDown={props.onMouseDown}
380
+
selected={props.selected}
381
+
/>
382
+
);
383
+
};
384
+
385
+
const PublicationResult = (props: {
386
+
pubName: string;
387
+
uri: string;
388
+
onClick: () => void;
389
+
onMouseDown: (e: React.MouseEvent) => void;
390
+
selected?: boolean;
391
+
onPostsClick: () => void;
392
+
}) => {
393
+
return (
394
+
<Result
395
+
icon={
396
+
<img
397
+
src={`/api/pub_icon?at_uri=${encodeURIComponent(props.uri)}`}
398
+
alt=""
399
+
className="w-5 h-5 rounded-full shrink-0"
400
+
/>
401
+
}
402
+
result={
403
+
<>
404
+
<div className="truncate w-full grow min-w-0">{props.pubName}</div>
405
+
<ScopeButton onClick={props.onPostsClick}>Posts</ScopeButton>
406
+
</>
407
+
}
408
+
onClick={props.onClick}
409
+
onMouseDown={props.onMouseDown}
410
+
selected={props.selected}
411
+
/>
412
+
);
413
+
};
414
+
415
+
const PostResult = (props: {
416
+
title: string;
417
+
onClick: () => void;
418
+
onMouseDown: (e: React.MouseEvent) => void;
419
+
selected?: boolean;
420
+
}) => {
421
+
return (
422
+
<Result
423
+
result={<div className="truncate w-full">{props.title}</div>}
424
+
onClick={props.onClick}
425
+
onMouseDown={props.onMouseDown}
426
+
selected={props.selected}
427
+
/>
428
+
);
429
+
};
430
+
431
+
const ScopeHeader = (props: {
432
+
scope: MentionScope;
433
+
handleScopeChange: () => void;
434
+
}) => {
435
+
if (props.scope.type === "default") return;
436
+
if (props.scope.type === "publication")
437
+
return (
438
+
<button
439
+
className="w-full flex flex-row gap-2 pt-1 rounded text-tertiary hover:text-accent-contrast shrink-0 text-xs"
440
+
onClick={() => props.handleScopeChange()}
441
+
onMouseDown={(e) => e.preventDefault()}
442
+
>
443
+
<GoBackTiny className="shrink-0 " />
444
+
445
+
<div className="grow w-full truncate text-left">
446
+
Posts from {props.scope.name}
447
+
</div>
448
+
</button>
449
+
);
450
+
};
451
+
452
+
export type Mention =
453
+
| {
454
+
type: "did";
455
+
handle: string;
456
+
did: string;
457
+
displayName?: string;
458
+
avatar?: string;
459
+
}
460
+
| { type: "publication"; uri: string; name: string; url: string }
461
+
| { type: "post"; uri: string; title: string; url: string };
462
+
463
+
export type MentionScope =
464
+
| { type: "default" }
465
+
| { type: "publication"; uri: string; name: string };
466
+
function useMentionSuggestions(query: string | null) {
467
+
const [suggestionIndex, setSuggestionIndex] = useState(0);
468
+
const [suggestions, setSuggestions] = useState<Array<Mention>>([]);
469
+
const [scope, setScope] = useState<MentionScope>({ type: "default" });
470
+
471
+
// Clear suggestions immediately when scope changes
472
+
const setScopeAndClear = useCallback((newScope: MentionScope) => {
473
+
setSuggestions([]);
474
+
setScope(newScope);
475
+
}, []);
476
+
477
+
useDebouncedEffect(
478
+
async () => {
479
+
if (!query && scope.type === "default") {
480
+
setSuggestions([]);
481
+
return;
482
+
}
483
+
484
+
if (scope.type === "publication") {
485
+
// Search within the publication's documents
486
+
const documents = await callRPC(`search_publication_documents`, {
487
+
publication_uri: scope.uri,
488
+
query: query || "",
489
+
limit: 10,
490
+
});
491
+
setSuggestions(
492
+
documents.result.documents.map((d) => ({
493
+
type: "post" as const,
494
+
uri: d.uri,
495
+
title: d.title,
496
+
url: d.url,
497
+
})),
498
+
);
499
+
} else {
500
+
// Default scope: search people and publications
501
+
const agent = new Agent("https://public.api.bsky.app");
502
+
const [result, publications] = await Promise.all([
503
+
agent.searchActorsTypeahead({
504
+
q: query || "",
505
+
limit: 8,
506
+
}),
507
+
callRPC(`search_publication_names`, { query: query || "", limit: 8 }),
508
+
]);
509
+
setSuggestions([
510
+
...result.data.actors.map((actor) => ({
511
+
type: "did" as const,
512
+
handle: actor.handle,
513
+
did: actor.did,
514
+
displayName: actor.displayName,
515
+
avatar: actor.avatar,
516
+
})),
517
+
...publications.result.publications.map((p) => ({
518
+
type: "publication" as const,
519
+
uri: p.uri,
520
+
name: p.name,
521
+
url: p.url,
522
+
})),
523
+
]);
524
+
}
525
+
},
526
+
300,
527
+
[query, scope],
528
+
);
529
+
530
+
useEffect(() => {
531
+
if (suggestionIndex > suggestions.length - 1) {
532
+
setSuggestionIndex(Math.max(0, suggestions.length - 1));
533
+
}
534
+
}, [suggestionIndex, suggestions.length]);
535
+
536
+
return {
537
+
suggestions,
538
+
suggestionIndex,
539
+
setSuggestionIndex,
540
+
scope,
541
+
setScope: setScopeAndClear,
542
+
};
543
+
}
+5
-2
components/PageLayouts/DashboardLayout.tsx
+5
-2
components/PageLayouts/DashboardLayout.tsx
···
372
372
);
373
373
}
374
374
375
-
const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => {
375
+
const FilterOptions = (props: {
376
+
hasPubs: boolean;
377
+
hasArchived: boolean;
378
+
}) => {
376
379
let { filter } = useDashboardState();
377
380
let setState = useSetDashboardState();
378
381
let filterCount = Object.values(filter).filter(Boolean).length;
···
469
472
type="text"
470
473
id="pubName"
471
474
size={1}
472
-
placeholder="searchโฆ"
475
+
placeholder="search..."
473
476
value={props.searchValue}
474
477
onChange={(e) => {
475
478
props.setSearchValue(e.currentTarget.value);
+4
-8
components/PageSWRDataProvider.tsx
+4
-8
components/PageSWRDataProvider.tsx
···
90
90
const publishedInPublication = data.leaflets_in_publications?.find(
91
91
(l) => l.doc,
92
92
);
93
-
const publishedStandalone =
94
-
data.leaflets_to_documents && data.leaflets_to_documents.documents
95
-
? data.leaflets_to_documents
96
-
: null;
93
+
const publishedStandalone = data.leaflets_to_documents?.find(
94
+
(l) => !!l.documents,
95
+
);
97
96
98
97
const documentUri =
99
98
publishedInPublication?.documents?.uri ?? publishedStandalone?.document;
100
99
101
100
// Compute the full post URL for sharing
102
101
let postShareLink: string | undefined;
103
-
if (
104
-
publishedInPublication?.publications &&
105
-
publishedInPublication.documents
106
-
) {
102
+
if (publishedInPublication?.publications && publishedInPublication.documents) {
107
103
// Published in a publication - use publication URL + document rkey
108
104
const docUri = new AtUri(publishedInPublication.documents.uri);
109
105
postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
+1
-1
components/Pages/Page.tsx
+1
-1
components/Pages/Page.tsx
···
12
12
import { Blocks } from "components/Blocks";
13
13
import { PublicationMetadata } from "./PublicationMetadata";
14
14
import { useCardBorderHidden } from "./useCardBorderHidden";
15
-
import { focusPage } from ".";
15
+
import { focusPage } from "src/utils/focusPage";
16
16
import { PageOptions } from "./PageOptions";
17
17
import { CardThemeProvider } from "components/ThemeManager/ThemeProvider";
18
18
import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
+156
-84
components/Pages/PublicationMetadata.tsx
+156
-84
components/Pages/PublicationMetadata.tsx
···
1
1
import Link from "next/link";
2
2
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
3
-
import { useRef } from "react";
3
+
import { useRef, useState } from "react";
4
4
import { useReplicache } from "src/replicache";
5
5
import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
6
6
import { Separator } from "components/Layout";
7
7
import { AtUri } from "@atproto/syntax";
8
-
import { PubLeafletDocument } from "lexicons/api";
8
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
9
9
import {
10
10
getBasePublicationURL,
11
11
getPublicationURL,
···
13
13
import { useSubscribe } from "src/replicache/useSubscribe";
14
14
import { useEntitySetContext } from "components/EntitySetProvider";
15
15
import { timeAgo } from "src/utils/timeAgo";
16
+
import { CommentTiny } from "components/Icons/CommentTiny";
17
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
18
+
import { TagTiny } from "components/Icons/TagTiny";
19
+
import { Popover } from "components/Popover";
20
+
import { TagSelector } from "components/Tags";
16
21
import { useIdentityData } from "components/IdentityProvider";
22
+
import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader";
17
23
export const PublicationMetadata = () => {
18
24
let { rep } = useReplicache();
19
25
let { data: pub } = useLeafletPublicationData();
···
23
29
tx.get<string>("publication_description"),
24
30
);
25
31
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
32
+
let pubRecord = pub?.publications?.record as
33
+
| PubLeafletPublication.Record
34
+
| undefined;
26
35
let publishedAt = record?.publishedAt;
27
36
28
37
if (!pub) return null;
···
33
42
if (typeof description !== "string") {
34
43
description = pub?.description || "";
35
44
}
45
+
let tags = true;
46
+
36
47
return (
37
-
<div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}>
38
-
<div className="flex gap-2">
39
-
{pub.publications && (
40
-
<Link
41
-
href={
42
-
identity?.atp_did === pub.publications?.identity_did
43
-
? `${getBasePublicationURL(pub.publications)}/dashboard`
44
-
: getPublicationURL(pub.publications)
45
-
}
46
-
className="leafletMetadata text-accent-contrast font-bold hover:no-underline"
47
-
>
48
-
{pub.publications?.name}
49
-
</Link>
50
-
)}
51
-
<div className="font-bold text-tertiary px-1 text-sm flex place-items-center bg-border-light rounded-md ">
52
-
Editor
53
-
</div>
54
-
</div>
55
-
<TextField
56
-
className="text-xl font-bold outline-hidden bg-transparent"
57
-
value={title}
58
-
onChange={async (newTitle) => {
59
-
await rep?.mutate.updatePublicationDraft({
60
-
title: newTitle,
61
-
description,
62
-
});
63
-
}}
64
-
placeholder="Untitled"
65
-
/>
66
-
<TextField
67
-
placeholder="add an optional description..."
68
-
className="italic text-secondary outline-hidden bg-transparent"
69
-
value={description}
70
-
onChange={async (newDescription) => {
71
-
await rep?.mutate.updatePublicationDraft({
72
-
title,
73
-
description: newDescription,
74
-
});
75
-
}}
76
-
/>
77
-
{pub.doc ? (
78
-
<div className="flex flex-row items-center gap-2 pt-3">
79
-
<p className="text-sm text-tertiary">
80
-
Published {publishedAt && timeAgo(publishedAt)}
81
-
</p>
82
-
<Separator classname="h-4" />
83
-
<Link
84
-
target="_blank"
85
-
className="text-sm"
86
-
href={
87
-
pub.publications
88
-
? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`
89
-
: `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}`
90
-
}
91
-
>
92
-
View Post
93
-
</Link>
48
+
<PostHeaderLayout
49
+
pubLink={
50
+
<div className="flex gap-2 items-center">
51
+
{pub.publications && (
52
+
<Link
53
+
href={
54
+
identity?.atp_did === pub.publications?.identity_did
55
+
? `${getBasePublicationURL(pub.publications)}/dashboard`
56
+
: getPublicationURL(pub.publications)
57
+
}
58
+
className="leafletMetadata text-accent-contrast font-bold hover:no-underline"
59
+
>
60
+
{pub.publications?.name}
61
+
</Link>
62
+
)}
63
+
<div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md ">
64
+
DRAFT
65
+
</div>
94
66
</div>
95
-
) : (
96
-
<p className="text-sm text-tertiary pt-2">Draft</p>
97
-
)}
98
-
</div>
67
+
}
68
+
postTitle={
69
+
<TextField
70
+
className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent"
71
+
value={title}
72
+
onChange={async (newTitle) => {
73
+
await rep?.mutate.updatePublicationDraft({
74
+
title: newTitle,
75
+
description,
76
+
});
77
+
}}
78
+
placeholder="Untitled"
79
+
/>
80
+
}
81
+
postDescription={
82
+
<TextField
83
+
placeholder="add an optional description..."
84
+
className="pt-1 italic text-secondary outline-hidden bg-transparent"
85
+
value={description}
86
+
onChange={async (newDescription) => {
87
+
await rep?.mutate.updatePublicationDraft({
88
+
title,
89
+
description: newDescription,
90
+
});
91
+
}}
92
+
/>
93
+
}
94
+
postInfo={
95
+
<>
96
+
{pub.doc ? (
97
+
<div className="flex gap-2 items-center">
98
+
<p className="text-sm text-tertiary">
99
+
Published {publishedAt && timeAgo(publishedAt)}
100
+
</p>
101
+
102
+
<Link
103
+
target="_blank"
104
+
className="text-sm"
105
+
href={
106
+
pub.publications
107
+
? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}`
108
+
: `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}`
109
+
}
110
+
>
111
+
View
112
+
</Link>
113
+
</div>
114
+
) : (
115
+
<p>Draft</p>
116
+
)}
117
+
<div className="flex gap-2 text-border items-center">
118
+
{tags && (
119
+
<>
120
+
<AddTags />
121
+
<Separator classname="h-4!" />
122
+
</>
123
+
)}
124
+
<div className="flex gap-1 items-center">
125
+
<QuoteTiny />โ
126
+
</div>
127
+
{pubRecord?.preferences?.showComments && (
128
+
<div className="flex gap-1 items-center">
129
+
<CommentTiny />โ
130
+
</div>
131
+
)}
132
+
</div>
133
+
</>
134
+
}
135
+
/>
99
136
);
100
137
};
101
138
···
178
215
if (!pub) return null;
179
216
180
217
return (
181
-
<div className={`flex flex-col px-3 sm:px-4 pb-5 sm:pt-3 pt-2`}>
182
-
<div className="text-accent-contrast font-bold hover:no-underline">
183
-
{pub.publications?.name}
184
-
</div>
218
+
<PostHeaderLayout
219
+
pubLink={
220
+
<div className="text-accent-contrast font-bold hover:no-underline">
221
+
{pub.publications?.name}
222
+
</div>
223
+
}
224
+
postTitle={pub.title}
225
+
postDescription={pub.description}
226
+
postInfo={
227
+
pub.doc ? (
228
+
<p>Published {publishedAt && timeAgo(publishedAt)}</p>
229
+
) : (
230
+
<p>Draft</p>
231
+
)
232
+
}
233
+
/>
234
+
);
235
+
};
185
236
186
-
<div
187
-
className={`text-xl font-bold outline-hidden bg-transparent ${!pub.title && "text-tertiary italic"}`}
188
-
>
189
-
{pub.title ? pub.title : "Untitled"}
190
-
</div>
191
-
<div className="italic text-secondary outline-hidden bg-transparent">
192
-
{pub.description}
193
-
</div>
237
+
const AddTags = () => {
238
+
let { data: pub } = useLeafletPublicationData();
239
+
let { rep } = useReplicache();
240
+
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
194
241
195
-
{pub.doc ? (
196
-
<div className="flex flex-row items-center gap-2 pt-3">
197
-
<p className="text-sm text-tertiary">
198
-
Published {publishedAt && timeAgo(publishedAt)}
199
-
</p>
242
+
// Get tags from Replicache local state or published document
243
+
let replicacheTags = useSubscribe(rep, (tx) =>
244
+
tx.get<string[]>("publication_tags"),
245
+
);
246
+
247
+
// Determine which tags to use - prioritize Replicache state
248
+
let tags: string[] = [];
249
+
if (Array.isArray(replicacheTags)) {
250
+
tags = replicacheTags;
251
+
} else if (record?.tags && Array.isArray(record.tags)) {
252
+
tags = record.tags as string[];
253
+
}
254
+
255
+
// Update tags in replicache local state
256
+
const handleTagsChange = async (newTags: string[]) => {
257
+
// Store tags in replicache for next publish/update
258
+
await rep?.mutate.updatePublicationDraft({
259
+
tags: newTags,
260
+
});
261
+
};
262
+
263
+
return (
264
+
<Popover
265
+
className="p-2! w-full min-w-xs"
266
+
trigger={
267
+
<div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary">
268
+
<TagTiny />{" "}
269
+
{tags.length > 0
270
+
? `${tags.length} Tag${tags.length === 1 ? "" : "s"}`
271
+
: "Add Tags"}
200
272
</div>
201
-
) : (
202
-
<p className="text-sm text-tertiary pt-2">Draft</p>
203
-
)}
204
-
</div>
273
+
}
274
+
>
275
+
<TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} />
276
+
</Popover>
205
277
);
206
278
};
+2
-75
components/Pages/index.tsx
+2
-75
components/Pages/index.tsx
···
4
4
import { useUIState } from "src/useUIState";
5
5
import { useSearchParams } from "next/navigation";
6
6
7
-
import { focusBlock } from "src/utils/focusBlock";
8
-
import { elementId } from "src/utils/elementId";
7
+
import { useEntity } from "src/replicache";
9
8
10
-
import { Replicache } from "replicache";
11
-
import { Fact, ReplicacheMutators, useEntity } from "src/replicache";
12
-
13
-
import { scanIndex } from "src/replicache/utils";
14
-
import { CardThemeProvider } from "../ThemeManager/ThemeProvider";
15
-
import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
16
9
import { useCardBorderHidden } from "./useCardBorderHidden";
17
10
import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout";
18
11
import { LeafletSidebar } from "app/[leaflet_id]/Sidebar";
···
62
55
);
63
56
}
64
57
65
-
export async function focusPage(
66
-
pageID: string,
67
-
rep: Replicache<ReplicacheMutators>,
68
-
focusFirstBlock?: "focusFirstBlock",
69
-
) {
70
-
// if this page is already focused,
71
-
let focusedBlock = useUIState.getState().focusedEntity;
72
-
// else set this page as focused
73
-
useUIState.setState(() => ({
74
-
focusedEntity: {
75
-
entityType: "page",
76
-
entityID: pageID,
77
-
},
78
-
}));
79
-
80
-
setTimeout(async () => {
81
-
//scroll to page
82
-
83
-
scrollIntoViewIfNeeded(
84
-
document.getElementById(elementId.page(pageID).container),
85
-
false,
86
-
"smooth",
87
-
);
88
-
89
-
// if we asked that the function focus the first block, focus the first block
90
-
if (focusFirstBlock === "focusFirstBlock") {
91
-
let firstBlock = await rep.query(async (tx) => {
92
-
let type = await scanIndex(tx).eav(pageID, "page/type");
93
-
let blocks = await scanIndex(tx).eav(
94
-
pageID,
95
-
type[0]?.data.value === "canvas" ? "canvas/block" : "card/block",
96
-
);
97
-
98
-
let firstBlock = blocks[0];
99
-
100
-
if (!firstBlock) {
101
-
return null;
102
-
}
103
-
104
-
let blockType = (
105
-
await tx
106
-
.scan<
107
-
Fact<"block/type">
108
-
>({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` })
109
-
.toArray()
110
-
)[0];
111
-
112
-
if (!blockType) return null;
113
-
114
-
return {
115
-
value: firstBlock.data.value,
116
-
type: blockType.data.value,
117
-
parent: firstBlock.entity,
118
-
position: firstBlock.data.position,
119
-
};
120
-
});
121
-
122
-
if (firstBlock) {
123
-
setTimeout(() => {
124
-
focusBlock(firstBlock, { type: "start" });
125
-
}, 500);
126
-
}
127
-
}
128
-
}, 50);
129
-
}
130
-
131
-
export const blurPage = () => {
58
+
const blurPage = () => {
132
59
useUIState.setState(() => ({
133
60
focusedEntity: null,
134
61
selectedBlocks: [],
+3
components/Popover/PopoverContext.ts
+3
components/Popover/PopoverContext.ts
+87
components/Popover/index.tsx
+87
components/Popover/index.tsx
···
1
+
"use client";
2
+
import * as RadixPopover from "@radix-ui/react-popover";
3
+
import { theme } from "tailwind.config";
4
+
import { NestedCardThemeProvider } from "../ThemeManager/ThemeProvider";
5
+
import { useEffect, useState } from "react";
6
+
import { PopoverArrow } from "../Icons/PopoverArrow";
7
+
import { PopoverOpenContext } from "./PopoverContext";
8
+
export const Popover = (props: {
9
+
trigger: React.ReactNode;
10
+
disabled?: boolean;
11
+
children: React.ReactNode;
12
+
align?: "start" | "end" | "center";
13
+
side?: "top" | "bottom" | "left" | "right";
14
+
sideOffset?: number;
15
+
background?: string;
16
+
border?: string;
17
+
className?: string;
18
+
open?: boolean;
19
+
onOpenChange?: (open: boolean) => void;
20
+
onOpenAutoFocus?: (e: Event) => void;
21
+
asChild?: boolean;
22
+
arrowFill?: string;
23
+
noArrow?: boolean;
24
+
}) => {
25
+
let [open, setOpen] = useState(props.open || false);
26
+
useEffect(() => {
27
+
if (props.open !== undefined) setOpen(props.open);
28
+
}, [props.open]);
29
+
return (
30
+
<RadixPopover.Root
31
+
open={props.open}
32
+
onOpenChange={(o) => {
33
+
setOpen(o);
34
+
props.onOpenChange?.(o);
35
+
}}
36
+
>
37
+
<PopoverOpenContext value={open}>
38
+
<RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}>
39
+
{props.trigger}
40
+
</RadixPopover.Trigger>
41
+
<RadixPopover.Portal>
42
+
<NestedCardThemeProvider>
43
+
<RadixPopover.Content
44
+
className={`
45
+
z-20 bg-bg-page
46
+
px-3 py-2
47
+
max-w-(--radix-popover-content-available-width)
48
+
max-h-(--radix-popover-content-available-height)
49
+
border border-border rounded-md shadow-md
50
+
overflow-y-scroll
51
+
${props.className}
52
+
`}
53
+
side={props.side}
54
+
align={props.align ? props.align : "center"}
55
+
sideOffset={props.sideOffset ? props.sideOffset : 4}
56
+
collisionPadding={16}
57
+
onOpenAutoFocus={props.onOpenAutoFocus}
58
+
>
59
+
{props.children}
60
+
{!props.noArrow && (
61
+
<RadixPopover.Arrow
62
+
asChild
63
+
width={16}
64
+
height={8}
65
+
viewBox="0 0 16 8"
66
+
>
67
+
<PopoverArrow
68
+
arrowFill={
69
+
props.arrowFill
70
+
? props.arrowFill
71
+
: props.background
72
+
? props.background
73
+
: theme.colors["bg-page"]
74
+
}
75
+
arrowStroke={
76
+
props.border ? props.border : theme.colors["border"]
77
+
}
78
+
/>
79
+
</RadixPopover.Arrow>
80
+
)}
81
+
</RadixPopover.Content>
82
+
</NestedCardThemeProvider>
83
+
</RadixPopover.Portal>
84
+
</PopoverOpenContext>
85
+
</RadixPopover.Root>
86
+
);
87
+
};
-84
components/Popover.tsx
-84
components/Popover.tsx
···
1
-
"use client";
2
-
import * as RadixPopover from "@radix-ui/react-popover";
3
-
import { theme } from "tailwind.config";
4
-
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
5
-
import { createContext, useEffect, useState } from "react";
6
-
import { PopoverArrow } from "./Icons/PopoverArrow";
7
-
8
-
export const PopoverOpenContext = createContext(false);
9
-
export const Popover = (props: {
10
-
trigger: React.ReactNode;
11
-
disabled?: boolean;
12
-
children: React.ReactNode;
13
-
align?: "start" | "end" | "center";
14
-
side?: "top" | "bottom" | "left" | "right";
15
-
background?: string;
16
-
border?: string;
17
-
className?: string;
18
-
open?: boolean;
19
-
onOpenChange?: (open: boolean) => void;
20
-
onOpenAutoFocus?: (e: Event) => void;
21
-
asChild?: boolean;
22
-
arrowFill?: string;
23
-
}) => {
24
-
let [open, setOpen] = useState(props.open || false);
25
-
useEffect(() => {
26
-
if (props.open !== undefined) setOpen(props.open);
27
-
}, [props.open]);
28
-
return (
29
-
<RadixPopover.Root
30
-
open={props.open}
31
-
onOpenChange={(o) => {
32
-
setOpen(o);
33
-
props.onOpenChange?.(o);
34
-
}}
35
-
>
36
-
<PopoverOpenContext value={open}>
37
-
<RadixPopover.Trigger disabled={props.disabled} asChild={props.asChild}>
38
-
{props.trigger}
39
-
</RadixPopover.Trigger>
40
-
<RadixPopover.Portal>
41
-
<NestedCardThemeProvider>
42
-
<RadixPopover.Content
43
-
className={`
44
-
z-20 bg-bg-page
45
-
px-3 py-2
46
-
max-w-(--radix-popover-content-available-width)
47
-
max-h-(--radix-popover-content-available-height)
48
-
border border-border rounded-md shadow-md
49
-
overflow-y-scroll
50
-
${props.className}
51
-
`}
52
-
side={props.side}
53
-
align={props.align ? props.align : "center"}
54
-
sideOffset={4}
55
-
collisionPadding={16}
56
-
onOpenAutoFocus={props.onOpenAutoFocus}
57
-
>
58
-
{props.children}
59
-
<RadixPopover.Arrow
60
-
asChild
61
-
width={16}
62
-
height={8}
63
-
viewBox="0 0 16 8"
64
-
>
65
-
<PopoverArrow
66
-
arrowFill={
67
-
props.arrowFill
68
-
? props.arrowFill
69
-
: props.background
70
-
? props.background
71
-
: theme.colors["bg-page"]
72
-
}
73
-
arrowStroke={
74
-
props.border ? props.border : theme.colors["border"]
75
-
}
76
-
/>
77
-
</RadixPopover.Arrow>
78
-
</RadixPopover.Content>
79
-
</NestedCardThemeProvider>
80
-
</RadixPopover.Portal>
81
-
</PopoverOpenContext>
82
-
</RadixPopover.Root>
83
-
);
84
-
};
+132
components/PostListing.tsx
+132
components/PostListing.tsx
···
1
+
"use client";
2
+
import { AtUri } from "@atproto/api";
3
+
import { PubIcon } from "components/ActionBar/Publications";
4
+
import { CommentTiny } from "components/Icons/CommentTiny";
5
+
import { QuoteTiny } from "components/Icons/QuoteTiny";
6
+
import { Separator } from "components/Layout";
7
+
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
8
+
import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
9
+
import { useSmoker } from "components/Toast";
10
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
11
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
12
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
13
+
14
+
import Link from "next/link";
15
+
import { InteractionPreview } from "./InteractionsPreview";
16
+
17
+
export const PostListing = (props: Post) => {
18
+
let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record;
19
+
20
+
let postRecord = props.documents.data as PubLeafletDocument.Record;
21
+
let postUri = new AtUri(props.documents.uri);
22
+
23
+
let theme = usePubTheme(pubRecord.theme);
24
+
let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref
25
+
? blobRefToSrc(
26
+
pubRecord?.theme?.backgroundImage?.image?.ref,
27
+
new AtUri(props.publication.uri).host,
28
+
)
29
+
: null;
30
+
31
+
let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
32
+
let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
33
+
34
+
let showPageBackground = pubRecord.theme?.showPageBackground;
35
+
36
+
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
37
+
let comments =
38
+
pubRecord.preferences?.showComments === false
39
+
? 0
40
+
: props.documents.comments_on_documents?.[0]?.count || 0;
41
+
let tags = (postRecord?.tags as string[] | undefined) || [];
42
+
43
+
return (
44
+
<BaseThemeProvider {...theme} local>
45
+
<div
46
+
style={{
47
+
backgroundImage: `url(${backgroundImage})`,
48
+
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
49
+
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
50
+
}}
51
+
className={`no-underline! flex flex-row gap-2 w-full relative
52
+
bg-bg-leaflet
53
+
border border-border-light rounded-lg
54
+
sm:p-2 p-2 selected-outline
55
+
hover:outline-accent-contrast hover:border-accent-contrast
56
+
`}
57
+
>
58
+
<Link
59
+
className="h-full w-full absolute top-0 left-0"
60
+
href={`${props.publication.href}/${postUri.rkey}`}
61
+
/>
62
+
<div
63
+
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
64
+
style={{
65
+
backgroundColor: showPageBackground
66
+
? "rgba(var(--bg-page), var(--bg-page-alpha))"
67
+
: "transparent",
68
+
}}
69
+
>
70
+
<h3 className="text-primary truncate">{postRecord.title}</h3>
71
+
72
+
<p className="text-secondary italic">{postRecord.description}</p>
73
+
<div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full">
74
+
<PubInfo
75
+
href={props.publication.href}
76
+
pubRecord={pubRecord}
77
+
uri={props.publication.uri}
78
+
/>
79
+
<div className="flex flex-row justify-between gap-2 items-center w-full">
80
+
<PostInfo publishedAt={postRecord.publishedAt} />
81
+
<InteractionPreview
82
+
postUrl={`${props.publication.href}/${postUri.rkey}`}
83
+
quotesCount={quotes}
84
+
commentsCount={comments}
85
+
tags={tags}
86
+
showComments={pubRecord.preferences?.showComments}
87
+
share
88
+
/>
89
+
</div>
90
+
</div>
91
+
</div>
92
+
</div>
93
+
</BaseThemeProvider>
94
+
);
95
+
};
96
+
97
+
const PubInfo = (props: {
98
+
href: string;
99
+
pubRecord: PubLeafletPublication.Record;
100
+
uri: string;
101
+
}) => {
102
+
return (
103
+
<div className="flex flex-col md:w-auto shrink-0 w-full">
104
+
<hr className="md:hidden block border-border-light mb-2" />
105
+
<Link
106
+
href={props.href}
107
+
className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit relative shrink-0"
108
+
>
109
+
<PubIcon small record={props.pubRecord} uri={props.uri} />
110
+
{props.pubRecord.name}
111
+
</Link>
112
+
</div>
113
+
);
114
+
};
115
+
116
+
const PostInfo = (props: { publishedAt: string | undefined }) => {
117
+
return (
118
+
<div className="flex gap-2 items-center shrink-0 self-start">
119
+
{props.publishedAt && (
120
+
<>
121
+
<div className="shrink-0">
122
+
{new Date(props.publishedAt).toLocaleDateString("en-US", {
123
+
year: "numeric",
124
+
month: "short",
125
+
day: "numeric",
126
+
})}
127
+
</div>
128
+
</>
129
+
)}
130
+
</div>
131
+
);
132
+
};
+717
components/SelectionManager/index.tsx
+717
components/SelectionManager/index.tsx
···
1
+
"use client";
2
+
import { useEffect, useRef, useState } from "react";
3
+
import { useReplicache } from "src/replicache";
4
+
import { useUIState } from "src/useUIState";
5
+
import { scanIndex } from "src/replicache/utils";
6
+
import { focusBlock } from "src/utils/focusBlock";
7
+
import { useEditorStates } from "src/state/useEditorState";
8
+
import { useEntitySetContext } from "../EntitySetProvider";
9
+
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
10
+
import { indent, outdent, outdentFull } from "src/utils/list-operations";
11
+
import { addShortcut, Shortcut } from "src/shortcuts";
12
+
import { elementId } from "src/utils/elementId";
13
+
import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
14
+
import { copySelection } from "src/utils/copySelection";
15
+
import { useIsMobile } from "src/hooks/isMobile";
16
+
import { deleteBlock } from "src/utils/deleteBlock";
17
+
import { schema } from "../Blocks/TextBlock/schema";
18
+
import { MarkType } from "prosemirror-model";
19
+
import { useSelectingMouse, getSortedSelection } from "./selectionState";
20
+
21
+
//How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges?
22
+
// How does this relate to *when dragging* ?
23
+
24
+
export function SelectionManager() {
25
+
let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1);
26
+
let entity_set = useEntitySetContext();
27
+
let { rep, undoManager } = useReplicache();
28
+
let isMobile = useIsMobile();
29
+
useEffect(() => {
30
+
if (!entity_set.permissions.write || !rep) return;
31
+
const getSortedSelectionBound = getSortedSelection.bind(null, rep);
32
+
let shortcuts: Shortcut[] = [
33
+
{
34
+
metaKey: true,
35
+
key: "ArrowUp",
36
+
handler: async () => {
37
+
let [firstBlock] =
38
+
(await rep?.query((tx) =>
39
+
getBlocksWithType(
40
+
tx,
41
+
useUIState.getState().selectedBlocks[0].parent,
42
+
),
43
+
)) || [];
44
+
if (firstBlock) focusBlock(firstBlock, { type: "start" });
45
+
},
46
+
},
47
+
{
48
+
metaKey: true,
49
+
key: "ArrowDown",
50
+
handler: async () => {
51
+
let blocks =
52
+
(await rep?.query((tx) =>
53
+
getBlocksWithType(
54
+
tx,
55
+
useUIState.getState().selectedBlocks[0].parent,
56
+
),
57
+
)) || [];
58
+
let folded = useUIState.getState().foldedBlocks;
59
+
blocks = blocks.filter(
60
+
(f) =>
61
+
!f.listData ||
62
+
!f.listData.path.find(
63
+
(path) =>
64
+
folded.includes(path.entity) && f.value !== path.entity,
65
+
),
66
+
);
67
+
let lastBlock = blocks[blocks.length - 1];
68
+
if (lastBlock) focusBlock(lastBlock, { type: "end" });
69
+
},
70
+
},
71
+
{
72
+
metaKey: true,
73
+
altKey: true,
74
+
key: ["l", "ยฌ"],
75
+
handler: async () => {
76
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
77
+
for (let block of sortedBlocks) {
78
+
if (!block.listData) {
79
+
await rep?.mutate.assertFact({
80
+
entity: block.value,
81
+
attribute: "block/is-list",
82
+
data: { type: "boolean", value: true },
83
+
});
84
+
} else {
85
+
outdentFull(block, rep);
86
+
}
87
+
}
88
+
},
89
+
},
90
+
{
91
+
metaKey: true,
92
+
shift: true,
93
+
key: ["ArrowDown", "J"],
94
+
handler: async () => {
95
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
96
+
let block = sortedBlocks[0];
97
+
let nextBlock = siblings
98
+
.slice(siblings.findIndex((s) => s.value === block.value) + 1)
99
+
.find(
100
+
(f) =>
101
+
f.listData &&
102
+
block.listData &&
103
+
!f.listData.path.find((f) => f.entity === block.value),
104
+
);
105
+
if (
106
+
nextBlock?.listData &&
107
+
block.listData &&
108
+
nextBlock.listData.depth === block.listData.depth - 1
109
+
) {
110
+
if (useUIState.getState().foldedBlocks.includes(nextBlock.value))
111
+
useUIState.getState().toggleFold(nextBlock.value);
112
+
await rep?.mutate.moveBlock({
113
+
block: block.value,
114
+
oldParent: block.listData?.parent,
115
+
newParent: nextBlock.value,
116
+
position: { type: "first" },
117
+
});
118
+
} else {
119
+
await rep?.mutate.moveBlockDown({
120
+
entityID: block.value,
121
+
parent: block.listData?.parent || block.parent,
122
+
});
123
+
}
124
+
},
125
+
},
126
+
{
127
+
metaKey: true,
128
+
shift: true,
129
+
key: ["ArrowUp", "K"],
130
+
handler: async () => {
131
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
132
+
let block = sortedBlocks[0];
133
+
let previousBlock =
134
+
siblings?.[siblings.findIndex((s) => s.value === block.value) - 1];
135
+
if (previousBlock.value === block.listData?.parent) {
136
+
previousBlock =
137
+
siblings?.[
138
+
siblings.findIndex((s) => s.value === block.value) - 2
139
+
];
140
+
}
141
+
142
+
if (
143
+
previousBlock?.listData &&
144
+
block.listData &&
145
+
block.listData.depth > 1 &&
146
+
!previousBlock.listData.path.find(
147
+
(f) => f.entity === block.listData?.parent,
148
+
)
149
+
) {
150
+
let depth = block.listData.depth;
151
+
let newParent = previousBlock.listData.path.find(
152
+
(f) => f.depth === depth - 1,
153
+
);
154
+
if (!newParent) return;
155
+
if (useUIState.getState().foldedBlocks.includes(newParent.entity))
156
+
useUIState.getState().toggleFold(newParent.entity);
157
+
rep?.mutate.moveBlock({
158
+
block: block.value,
159
+
oldParent: block.listData?.parent,
160
+
newParent: newParent.entity,
161
+
position: { type: "end" },
162
+
});
163
+
} else {
164
+
rep?.mutate.moveBlockUp({
165
+
entityID: block.value,
166
+
parent: block.listData?.parent || block.parent,
167
+
});
168
+
}
169
+
},
170
+
},
171
+
172
+
{
173
+
metaKey: true,
174
+
shift: true,
175
+
key: "Enter",
176
+
handler: async () => {
177
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
178
+
if (!sortedBlocks[0].listData) return;
179
+
useUIState.getState().toggleFold(sortedBlocks[0].value);
180
+
},
181
+
},
182
+
];
183
+
if (moreThanOneSelected)
184
+
shortcuts = shortcuts.concat([
185
+
{
186
+
metaKey: true,
187
+
key: "u",
188
+
handler: async () => {
189
+
let [sortedBlocks] = await getSortedSelectionBound();
190
+
toggleMarkInBlocks(
191
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
192
+
schema.marks.underline,
193
+
);
194
+
},
195
+
},
196
+
{
197
+
metaKey: true,
198
+
key: "i",
199
+
handler: async () => {
200
+
let [sortedBlocks] = await getSortedSelectionBound();
201
+
toggleMarkInBlocks(
202
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
203
+
schema.marks.em,
204
+
);
205
+
},
206
+
},
207
+
{
208
+
metaKey: true,
209
+
key: "b",
210
+
handler: async () => {
211
+
let [sortedBlocks] = await getSortedSelectionBound();
212
+
toggleMarkInBlocks(
213
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
214
+
schema.marks.strong,
215
+
);
216
+
},
217
+
},
218
+
{
219
+
metaAndCtrl: true,
220
+
key: "h",
221
+
handler: async () => {
222
+
let [sortedBlocks] = await getSortedSelectionBound();
223
+
toggleMarkInBlocks(
224
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
225
+
schema.marks.highlight,
226
+
{
227
+
color: useUIState.getState().lastUsedHighlight,
228
+
},
229
+
);
230
+
},
231
+
},
232
+
{
233
+
metaAndCtrl: true,
234
+
key: "x",
235
+
handler: async () => {
236
+
let [sortedBlocks] = await getSortedSelectionBound();
237
+
toggleMarkInBlocks(
238
+
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
239
+
schema.marks.strikethrough,
240
+
);
241
+
},
242
+
},
243
+
]);
244
+
let removeListener = addShortcut(
245
+
shortcuts.map((shortcut) => ({
246
+
...shortcut,
247
+
handler: () => undoManager.withUndoGroup(() => shortcut.handler()),
248
+
})),
249
+
);
250
+
let listener = async (e: KeyboardEvent) =>
251
+
undoManager.withUndoGroup(async () => {
252
+
//used here and in cut
253
+
const deleteBlocks = async () => {
254
+
if (!entity_set.permissions.write) return;
255
+
if (moreThanOneSelected) {
256
+
e.preventDefault();
257
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
258
+
let selectedBlocks = useUIState.getState().selectedBlocks;
259
+
let firstBlock = sortedBlocks[0];
260
+
261
+
await rep?.mutate.removeBlock(
262
+
selectedBlocks.map((block) => ({ blockEntity: block.value })),
263
+
);
264
+
useUIState.getState().closePage(selectedBlocks.map((b) => b.value));
265
+
266
+
let nextBlock =
267
+
siblings?.[
268
+
siblings.findIndex((s) => s.value === firstBlock.value) - 1
269
+
];
270
+
if (nextBlock) {
271
+
useUIState.getState().setSelectedBlock({
272
+
value: nextBlock.value,
273
+
parent: nextBlock.parent,
274
+
});
275
+
let type = await rep?.query((tx) =>
276
+
scanIndex(tx).eav(nextBlock.value, "block/type"),
277
+
);
278
+
if (!type?.[0]) return;
279
+
if (
280
+
type[0]?.data.value === "text" ||
281
+
type[0]?.data.value === "heading"
282
+
)
283
+
focusBlock(
284
+
{
285
+
value: nextBlock.value,
286
+
type: "text",
287
+
parent: nextBlock.parent,
288
+
},
289
+
{ type: "end" },
290
+
);
291
+
}
292
+
}
293
+
};
294
+
if (e.key === "Backspace" || e.key === "Delete") {
295
+
deleteBlocks();
296
+
}
297
+
if (e.key === "ArrowUp") {
298
+
let [sortedBlocks, siblings] = await getSortedSelectionBound();
299
+
let focusedBlock = useUIState.getState().focusedEntity;
300
+
if (!e.shiftKey && !e.ctrlKey) {
301
+
if (e.defaultPrevented) return;
302
+
if (sortedBlocks.length === 1) return;
303
+
let firstBlock = sortedBlocks[0];
304
+
if (!firstBlock) return;
305
+
let type = await rep?.query((tx) =>
306
+
scanIndex(tx).eav(firstBlock.value, "block/type"),
307
+
);
308
+
if (!type?.[0]) return;
309
+
useUIState.getState().setSelectedBlock(firstBlock);
310
+
focusBlock(
311
+
{ ...firstBlock, type: type[0].data.value },
312
+
{ type: "start" },
313
+
);
314
+
} else {
315
+
if (e.defaultPrevented) return;
316
+
if (
317
+
sortedBlocks.length <= 1 ||
318
+
!focusedBlock ||
319
+
focusedBlock.entityType === "page"
320
+
)
321
+
return;
322
+
let b = focusedBlock;
323
+
let focusedBlockIndex = sortedBlocks.findIndex(
324
+
(s) => s.value == b.entityID,
325
+
);
326
+
if (focusedBlockIndex === 0) {
327
+
let index = siblings.findIndex((s) => s.value === b.entityID);
328
+
let nextSelectedBlock = siblings[index - 1];
329
+
if (!nextSelectedBlock) return;
330
+
331
+
scrollIntoViewIfNeeded(
332
+
document.getElementById(
333
+
elementId.block(nextSelectedBlock.value).container,
334
+
),
335
+
false,
336
+
);
337
+
useUIState.getState().addBlockToSelection({
338
+
...nextSelectedBlock,
339
+
});
340
+
useUIState.getState().setFocusedBlock({
341
+
entityType: "block",
342
+
parent: nextSelectedBlock.parent,
343
+
entityID: nextSelectedBlock.value,
344
+
});
345
+
} else {
346
+
let nextBlock = sortedBlocks[sortedBlocks.length - 2];
347
+
useUIState.getState().setFocusedBlock({
348
+
entityType: "block",
349
+
parent: b.parent,
350
+
entityID: nextBlock.value,
351
+
});
352
+
scrollIntoViewIfNeeded(
353
+
document.getElementById(
354
+
elementId.block(nextBlock.value).container,
355
+
),
356
+
false,
357
+
);
358
+
if (sortedBlocks.length === 2) {
359
+
useEditorStates
360
+
.getState()
361
+
.editorStates[nextBlock.value]?.view?.focus();
362
+
}
363
+
useUIState
364
+
.getState()
365
+
.removeBlockFromSelection(sortedBlocks[focusedBlockIndex]);
366
+
}
367
+
}
368
+
}
369
+
if (e.key === "ArrowLeft") {
370
+
let [sortedSelection, siblings] = await getSortedSelectionBound();
371
+
if (sortedSelection.length === 1) return;
372
+
let firstBlock = sortedSelection[0];
373
+
if (!firstBlock) return;
374
+
let type = await rep?.query((tx) =>
375
+
scanIndex(tx).eav(firstBlock.value, "block/type"),
376
+
);
377
+
if (!type?.[0]) return;
378
+
useUIState.getState().setSelectedBlock(firstBlock);
379
+
focusBlock(
380
+
{ ...firstBlock, type: type[0].data.value },
381
+
{ type: "start" },
382
+
);
383
+
}
384
+
if (e.key === "ArrowRight") {
385
+
let [sortedSelection, siblings] = await getSortedSelectionBound();
386
+
if (sortedSelection.length === 1) return;
387
+
let lastBlock = sortedSelection[sortedSelection.length - 1];
388
+
if (!lastBlock) return;
389
+
let type = await rep?.query((tx) =>
390
+
scanIndex(tx).eav(lastBlock.value, "block/type"),
391
+
);
392
+
if (!type?.[0]) return;
393
+
useUIState.getState().setSelectedBlock(lastBlock);
394
+
focusBlock(
395
+
{ ...lastBlock, type: type[0].data.value },
396
+
{ type: "end" },
397
+
);
398
+
}
399
+
if (e.key === "Tab") {
400
+
let [sortedSelection, siblings] = await getSortedSelectionBound();
401
+
if (sortedSelection.length <= 1) return;
402
+
e.preventDefault();
403
+
if (e.shiftKey) {
404
+
for (let i = siblings.length - 1; i >= 0; i--) {
405
+
let block = siblings[i];
406
+
if (!sortedSelection.find((s) => s.value === block.value))
407
+
continue;
408
+
if (
409
+
sortedSelection.find((s) => s.value === block.listData?.parent)
410
+
)
411
+
continue;
412
+
let parentoffset = 1;
413
+
let previousBlock = siblings[i - parentoffset];
414
+
while (
415
+
previousBlock &&
416
+
sortedSelection.find((s) => previousBlock.value === s.value)
417
+
) {
418
+
parentoffset += 1;
419
+
previousBlock = siblings[i - parentoffset];
420
+
}
421
+
if (!block.listData || !previousBlock.listData) continue;
422
+
outdent(block, previousBlock, rep);
423
+
}
424
+
} else {
425
+
for (let i = 0; i < siblings.length; i++) {
426
+
let block = siblings[i];
427
+
if (!sortedSelection.find((s) => s.value === block.value))
428
+
continue;
429
+
if (
430
+
sortedSelection.find((s) => s.value === block.listData?.parent)
431
+
)
432
+
continue;
433
+
let parentoffset = 1;
434
+
let previousBlock = siblings[i - parentoffset];
435
+
while (
436
+
previousBlock &&
437
+
sortedSelection.find((s) => previousBlock.value === s.value)
438
+
) {
439
+
parentoffset += 1;
440
+
previousBlock = siblings[i - parentoffset];
441
+
}
442
+
if (!block.listData || !previousBlock.listData) continue;
443
+
indent(block, previousBlock, rep);
444
+
}
445
+
}
446
+
}
447
+
if (e.key === "ArrowDown") {
448
+
let [sortedSelection, siblings] = await getSortedSelectionBound();
449
+
let focusedBlock = useUIState.getState().focusedEntity;
450
+
if (!e.shiftKey) {
451
+
if (sortedSelection.length === 1) return;
452
+
let lastBlock = sortedSelection[sortedSelection.length - 1];
453
+
if (!lastBlock) return;
454
+
let type = await rep?.query((tx) =>
455
+
scanIndex(tx).eav(lastBlock.value, "block/type"),
456
+
);
457
+
if (!type?.[0]) return;
458
+
useUIState.getState().setSelectedBlock(lastBlock);
459
+
focusBlock(
460
+
{ ...lastBlock, type: type[0].data.value },
461
+
{ type: "end" },
462
+
);
463
+
}
464
+
if (e.shiftKey) {
465
+
if (e.defaultPrevented) return;
466
+
if (
467
+
sortedSelection.length <= 1 ||
468
+
!focusedBlock ||
469
+
focusedBlock.entityType === "page"
470
+
)
471
+
return;
472
+
let b = focusedBlock;
473
+
let focusedBlockIndex = sortedSelection.findIndex(
474
+
(s) => s.value == b.entityID,
475
+
);
476
+
if (focusedBlockIndex === sortedSelection.length - 1) {
477
+
let index = siblings.findIndex((s) => s.value === b.entityID);
478
+
let nextSelectedBlock = siblings[index + 1];
479
+
if (!nextSelectedBlock) return;
480
+
useUIState.getState().addBlockToSelection({
481
+
...nextSelectedBlock,
482
+
});
483
+
484
+
scrollIntoViewIfNeeded(
485
+
document.getElementById(
486
+
elementId.block(nextSelectedBlock.value).container,
487
+
),
488
+
false,
489
+
);
490
+
useUIState.getState().setFocusedBlock({
491
+
entityType: "block",
492
+
parent: nextSelectedBlock.parent,
493
+
entityID: nextSelectedBlock.value,
494
+
});
495
+
} else {
496
+
let nextBlock = sortedSelection[1];
497
+
useUIState
498
+
.getState()
499
+
.removeBlockFromSelection({ value: b.entityID });
500
+
scrollIntoViewIfNeeded(
501
+
document.getElementById(
502
+
elementId.block(nextBlock.value).container,
503
+
),
504
+
false,
505
+
);
506
+
useUIState.getState().setFocusedBlock({
507
+
entityType: "block",
508
+
parent: b.parent,
509
+
entityID: nextBlock.value,
510
+
});
511
+
if (sortedSelection.length === 2) {
512
+
useEditorStates
513
+
.getState()
514
+
.editorStates[nextBlock.value]?.view?.focus();
515
+
}
516
+
}
517
+
}
518
+
}
519
+
if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) {
520
+
if (!rep) return;
521
+
if (e.shiftKey || (e.metaKey && e.ctrlKey)) return;
522
+
let [, , selectionWithFoldedChildren] =
523
+
await getSortedSelectionBound();
524
+
if (!selectionWithFoldedChildren) return;
525
+
let el = document.activeElement as HTMLElement;
526
+
if (
527
+
el?.tagName === "LABEL" ||
528
+
el?.tagName === "INPUT" ||
529
+
el?.tagName === "TEXTAREA"
530
+
) {
531
+
return;
532
+
}
533
+
534
+
if (
535
+
el.contentEditable === "true" &&
536
+
selectionWithFoldedChildren.length <= 1
537
+
)
538
+
return;
539
+
e.preventDefault();
540
+
await copySelection(rep, selectionWithFoldedChildren);
541
+
if (e.key === "x") deleteBlocks();
542
+
}
543
+
});
544
+
window.addEventListener("keydown", listener);
545
+
return () => {
546
+
removeListener();
547
+
window.removeEventListener("keydown", listener);
548
+
};
549
+
}, [moreThanOneSelected, rep, entity_set.permissions.write]);
550
+
551
+
let [mouseDown, setMouseDown] = useState(false);
552
+
let initialContentEditableParent = useRef<null | Node>(null);
553
+
let savedSelection = useRef<SavedRange[] | null>(undefined);
554
+
useEffect(() => {
555
+
if (isMobile) return;
556
+
if (!entity_set.permissions.write) return;
557
+
let mouseDownListener = (e: MouseEvent) => {
558
+
if ((e.target as Element).getAttribute("data-draggable")) return;
559
+
let contentEditableParent = getContentEditableParent(e.target as Node);
560
+
if (contentEditableParent) {
561
+
setMouseDown(true);
562
+
let entityID = (contentEditableParent as Element).getAttribute(
563
+
"data-entityid",
564
+
);
565
+
useSelectingMouse.setState({ start: entityID });
566
+
}
567
+
initialContentEditableParent.current = contentEditableParent;
568
+
};
569
+
let mouseUpListener = (e: MouseEvent) => {
570
+
savedSelection.current = null;
571
+
if (
572
+
initialContentEditableParent.current &&
573
+
!(e.target as Element).getAttribute("data-draggable") &&
574
+
getContentEditableParent(e.target as Node) !==
575
+
initialContentEditableParent.current
576
+
) {
577
+
setTimeout(() => {
578
+
window.getSelection()?.removeAllRanges();
579
+
}, 5);
580
+
}
581
+
initialContentEditableParent.current = null;
582
+
useSelectingMouse.setState({ start: null });
583
+
setMouseDown(false);
584
+
};
585
+
window.addEventListener("mousedown", mouseDownListener);
586
+
window.addEventListener("mouseup", mouseUpListener);
587
+
return () => {
588
+
window.removeEventListener("mousedown", mouseDownListener);
589
+
window.removeEventListener("mouseup", mouseUpListener);
590
+
};
591
+
}, [entity_set.permissions.write, isMobile]);
592
+
useEffect(() => {
593
+
if (!mouseDown) return;
594
+
if (isMobile) return;
595
+
let mouseMoveListener = (e: MouseEvent) => {
596
+
if (e.buttons !== 1) return;
597
+
if (initialContentEditableParent.current) {
598
+
if (
599
+
initialContentEditableParent.current ===
600
+
getContentEditableParent(e.target as Node)
601
+
) {
602
+
if (savedSelection.current) {
603
+
restoreSelection(savedSelection.current);
604
+
}
605
+
savedSelection.current = null;
606
+
return;
607
+
}
608
+
if (!savedSelection.current) savedSelection.current = saveSelection();
609
+
window.getSelection()?.removeAllRanges();
610
+
}
611
+
};
612
+
window.addEventListener("mousemove", mouseMoveListener);
613
+
return () => {
614
+
window.removeEventListener("mousemove", mouseMoveListener);
615
+
};
616
+
}, [mouseDown, isMobile]);
617
+
return null;
618
+
}
619
+
620
+
type SavedRange = {
621
+
startContainer: Node;
622
+
startOffset: number;
623
+
endContainer: Node;
624
+
endOffset: number;
625
+
direction: "forward" | "backward";
626
+
};
627
+
function saveSelection() {
628
+
let selection = window.getSelection();
629
+
if (selection && selection.rangeCount > 0) {
630
+
let ranges: SavedRange[] = [];
631
+
for (let i = 0; i < selection.rangeCount; i++) {
632
+
let range = selection.getRangeAt(i);
633
+
ranges.push({
634
+
startContainer: range.startContainer,
635
+
startOffset: range.startOffset,
636
+
endContainer: range.endContainer,
637
+
endOffset: range.endOffset,
638
+
direction:
639
+
selection.anchorNode === range.startContainer &&
640
+
selection.anchorOffset === range.startOffset
641
+
? "forward"
642
+
: "backward",
643
+
});
644
+
}
645
+
return ranges;
646
+
}
647
+
return [];
648
+
}
649
+
650
+
function restoreSelection(savedRanges: SavedRange[]) {
651
+
if (savedRanges && savedRanges.length > 0) {
652
+
let selection = window.getSelection();
653
+
if (!selection) return;
654
+
selection.removeAllRanges();
655
+
for (let i = 0; i < savedRanges.length; i++) {
656
+
let range = document.createRange();
657
+
range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset);
658
+
range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset);
659
+
660
+
selection.addRange(range);
661
+
662
+
// If the direction is backward, collapse the selection to the end and then extend it backward
663
+
if (savedRanges[i].direction === "backward") {
664
+
selection.collapseToEnd();
665
+
selection.extend(
666
+
savedRanges[i].startContainer,
667
+
savedRanges[i].startOffset,
668
+
);
669
+
}
670
+
}
671
+
}
672
+
}
673
+
674
+
function getContentEditableParent(e: Node | null): Node | null {
675
+
let element: Node | null = e;
676
+
while (element && element !== document) {
677
+
if (
678
+
(element as HTMLElement).contentEditable === "true" ||
679
+
(element as HTMLElement).getAttribute("data-editable-block")
680
+
) {
681
+
return element;
682
+
}
683
+
element = element.parentNode;
684
+
}
685
+
return null;
686
+
}
687
+
688
+
689
+
function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) {
690
+
let everyBlockHasMark = blocks.reduce((acc, block) => {
691
+
let editor = useEditorStates.getState().editorStates[block];
692
+
if (!editor) return acc;
693
+
let { view } = editor;
694
+
let from = 0;
695
+
let to = view.state.doc.content.size;
696
+
let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark);
697
+
return acc && hasMarkInRange;
698
+
}, true);
699
+
for (let block of blocks) {
700
+
let editor = useEditorStates.getState().editorStates[block];
701
+
if (!editor) return;
702
+
let { view } = editor;
703
+
let tr = view.state.tr;
704
+
705
+
let from = 0;
706
+
let to = view.state.doc.content.size;
707
+
708
+
tr.setMeta("bulkOp", true);
709
+
if (everyBlockHasMark) {
710
+
tr.removeMark(from, to, mark);
711
+
} else {
712
+
tr.addMark(from, to, mark.create(attrs));
713
+
}
714
+
715
+
view.dispatch(tr);
716
+
}
717
+
}
+48
components/SelectionManager/selectionState.ts
+48
components/SelectionManager/selectionState.ts
···
1
+
import { create } from "zustand";
2
+
import { Replicache } from "replicache";
3
+
import { ReplicacheMutators } from "src/replicache";
4
+
import { useUIState } from "src/useUIState";
5
+
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6
+
7
+
export const useSelectingMouse = create(() => ({
8
+
start: null as null | string,
9
+
}));
10
+
11
+
export const getSortedSelection = async (
12
+
rep: Replicache<ReplicacheMutators>,
13
+
) => {
14
+
let selectedBlocks = useUIState.getState().selectedBlocks;
15
+
let foldedBlocks = useUIState.getState().foldedBlocks;
16
+
if (!selectedBlocks[0]) return [[], []];
17
+
let siblings =
18
+
(await rep?.query((tx) =>
19
+
getBlocksWithType(tx, selectedBlocks[0].parent),
20
+
)) || [];
21
+
let sortedBlocks = siblings.filter((s) => {
22
+
let selected = selectedBlocks.find((sb) => sb.value === s.value);
23
+
return selected;
24
+
});
25
+
let sortedBlocksWithChildren = siblings.filter((s) => {
26
+
let selected = selectedBlocks.find((sb) => sb.value === s.value);
27
+
if (s.listData && !selected) {
28
+
//Select the children of folded list blocks (in order to copy them)
29
+
return s.listData.path.find(
30
+
(p) =>
31
+
selectedBlocks.find((sb) => sb.value === p.entity) &&
32
+
foldedBlocks.includes(p.entity),
33
+
);
34
+
}
35
+
return selected;
36
+
});
37
+
return [
38
+
sortedBlocks,
39
+
siblings.filter(
40
+
(f) =>
41
+
!f.listData ||
42
+
!f.listData.path.find(
43
+
(p) => foldedBlocks.includes(p.entity) && p.entity !== f.value,
44
+
),
45
+
),
46
+
sortedBlocksWithChildren,
47
+
];
48
+
};
-763
components/SelectionManager.tsx
-763
components/SelectionManager.tsx
···
1
-
"use client";
2
-
import { useEffect, useRef, useState } from "react";
3
-
import { create } from "zustand";
4
-
import { ReplicacheMutators, useReplicache } from "src/replicache";
5
-
import { useUIState } from "src/useUIState";
6
-
import { scanIndex } from "src/replicache/utils";
7
-
import { focusBlock } from "src/utils/focusBlock";
8
-
import { useEditorStates } from "src/state/useEditorState";
9
-
import { useEntitySetContext } from "./EntitySetProvider";
10
-
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
11
-
import { v7 } from "uuid";
12
-
import { indent, outdent, outdentFull } from "src/utils/list-operations";
13
-
import { addShortcut, Shortcut } from "src/shortcuts";
14
-
import { htmlToMarkdown } from "src/htmlMarkdownParsers";
15
-
import { elementId } from "src/utils/elementId";
16
-
import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
17
-
import { copySelection } from "src/utils/copySelection";
18
-
import { isTextBlock } from "src/utils/isTextBlock";
19
-
import { useIsMobile } from "src/hooks/isMobile";
20
-
import { deleteBlock } from "./Blocks/DeleteBlock";
21
-
import { Replicache } from "replicache";
22
-
import { schema } from "./Blocks/TextBlock/schema";
23
-
import { TextSelection } from "prosemirror-state";
24
-
import { MarkType } from "prosemirror-model";
25
-
export const useSelectingMouse = create(() => ({
26
-
start: null as null | string,
27
-
}));
28
-
29
-
//How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges?
30
-
// How does this relate to *when dragging* ?
31
-
32
-
export function SelectionManager() {
33
-
let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1);
34
-
let entity_set = useEntitySetContext();
35
-
let { rep, undoManager } = useReplicache();
36
-
let isMobile = useIsMobile();
37
-
useEffect(() => {
38
-
if (!entity_set.permissions.write || !rep) return;
39
-
const getSortedSelectionBound = getSortedSelection.bind(null, rep);
40
-
let shortcuts: Shortcut[] = [
41
-
{
42
-
metaKey: true,
43
-
key: "ArrowUp",
44
-
handler: async () => {
45
-
let [firstBlock] =
46
-
(await rep?.query((tx) =>
47
-
getBlocksWithType(
48
-
tx,
49
-
useUIState.getState().selectedBlocks[0].parent,
50
-
),
51
-
)) || [];
52
-
if (firstBlock) focusBlock(firstBlock, { type: "start" });
53
-
},
54
-
},
55
-
{
56
-
metaKey: true,
57
-
key: "ArrowDown",
58
-
handler: async () => {
59
-
let blocks =
60
-
(await rep?.query((tx) =>
61
-
getBlocksWithType(
62
-
tx,
63
-
useUIState.getState().selectedBlocks[0].parent,
64
-
),
65
-
)) || [];
66
-
let folded = useUIState.getState().foldedBlocks;
67
-
blocks = blocks.filter(
68
-
(f) =>
69
-
!f.listData ||
70
-
!f.listData.path.find(
71
-
(path) =>
72
-
folded.includes(path.entity) && f.value !== path.entity,
73
-
),
74
-
);
75
-
let lastBlock = blocks[blocks.length - 1];
76
-
if (lastBlock) focusBlock(lastBlock, { type: "end" });
77
-
},
78
-
},
79
-
{
80
-
metaKey: true,
81
-
altKey: true,
82
-
key: ["l", "ยฌ"],
83
-
handler: async () => {
84
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
85
-
for (let block of sortedBlocks) {
86
-
if (!block.listData) {
87
-
await rep?.mutate.assertFact({
88
-
entity: block.value,
89
-
attribute: "block/is-list",
90
-
data: { type: "boolean", value: true },
91
-
});
92
-
} else {
93
-
outdentFull(block, rep);
94
-
}
95
-
}
96
-
},
97
-
},
98
-
{
99
-
metaKey: true,
100
-
shift: true,
101
-
key: ["ArrowDown", "J"],
102
-
handler: async () => {
103
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
104
-
let block = sortedBlocks[0];
105
-
let nextBlock = siblings
106
-
.slice(siblings.findIndex((s) => s.value === block.value) + 1)
107
-
.find(
108
-
(f) =>
109
-
f.listData &&
110
-
block.listData &&
111
-
!f.listData.path.find((f) => f.entity === block.value),
112
-
);
113
-
if (
114
-
nextBlock?.listData &&
115
-
block.listData &&
116
-
nextBlock.listData.depth === block.listData.depth - 1
117
-
) {
118
-
if (useUIState.getState().foldedBlocks.includes(nextBlock.value))
119
-
useUIState.getState().toggleFold(nextBlock.value);
120
-
await rep?.mutate.moveBlock({
121
-
block: block.value,
122
-
oldParent: block.listData?.parent,
123
-
newParent: nextBlock.value,
124
-
position: { type: "first" },
125
-
});
126
-
} else {
127
-
await rep?.mutate.moveBlockDown({
128
-
entityID: block.value,
129
-
parent: block.listData?.parent || block.parent,
130
-
});
131
-
}
132
-
},
133
-
},
134
-
{
135
-
metaKey: true,
136
-
shift: true,
137
-
key: ["ArrowUp", "K"],
138
-
handler: async () => {
139
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
140
-
let block = sortedBlocks[0];
141
-
let previousBlock =
142
-
siblings?.[siblings.findIndex((s) => s.value === block.value) - 1];
143
-
if (previousBlock.value === block.listData?.parent) {
144
-
previousBlock =
145
-
siblings?.[
146
-
siblings.findIndex((s) => s.value === block.value) - 2
147
-
];
148
-
}
149
-
150
-
if (
151
-
previousBlock?.listData &&
152
-
block.listData &&
153
-
block.listData.depth > 1 &&
154
-
!previousBlock.listData.path.find(
155
-
(f) => f.entity === block.listData?.parent,
156
-
)
157
-
) {
158
-
let depth = block.listData.depth;
159
-
let newParent = previousBlock.listData.path.find(
160
-
(f) => f.depth === depth - 1,
161
-
);
162
-
if (!newParent) return;
163
-
if (useUIState.getState().foldedBlocks.includes(newParent.entity))
164
-
useUIState.getState().toggleFold(newParent.entity);
165
-
rep?.mutate.moveBlock({
166
-
block: block.value,
167
-
oldParent: block.listData?.parent,
168
-
newParent: newParent.entity,
169
-
position: { type: "end" },
170
-
});
171
-
} else {
172
-
rep?.mutate.moveBlockUp({
173
-
entityID: block.value,
174
-
parent: block.listData?.parent || block.parent,
175
-
});
176
-
}
177
-
},
178
-
},
179
-
180
-
{
181
-
metaKey: true,
182
-
shift: true,
183
-
key: "Enter",
184
-
handler: async () => {
185
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
186
-
if (!sortedBlocks[0].listData) return;
187
-
useUIState.getState().toggleFold(sortedBlocks[0].value);
188
-
},
189
-
},
190
-
];
191
-
if (moreThanOneSelected)
192
-
shortcuts = shortcuts.concat([
193
-
{
194
-
metaKey: true,
195
-
key: "u",
196
-
handler: async () => {
197
-
let [sortedBlocks] = await getSortedSelectionBound();
198
-
toggleMarkInBlocks(
199
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
200
-
schema.marks.underline,
201
-
);
202
-
},
203
-
},
204
-
{
205
-
metaKey: true,
206
-
key: "i",
207
-
handler: async () => {
208
-
let [sortedBlocks] = await getSortedSelectionBound();
209
-
toggleMarkInBlocks(
210
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
211
-
schema.marks.em,
212
-
);
213
-
},
214
-
},
215
-
{
216
-
metaKey: true,
217
-
key: "b",
218
-
handler: async () => {
219
-
let [sortedBlocks] = await getSortedSelectionBound();
220
-
toggleMarkInBlocks(
221
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
222
-
schema.marks.strong,
223
-
);
224
-
},
225
-
},
226
-
{
227
-
metaAndCtrl: true,
228
-
key: "h",
229
-
handler: async () => {
230
-
let [sortedBlocks] = await getSortedSelectionBound();
231
-
toggleMarkInBlocks(
232
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
233
-
schema.marks.highlight,
234
-
{
235
-
color: useUIState.getState().lastUsedHighlight,
236
-
},
237
-
);
238
-
},
239
-
},
240
-
{
241
-
metaAndCtrl: true,
242
-
key: "x",
243
-
handler: async () => {
244
-
let [sortedBlocks] = await getSortedSelectionBound();
245
-
toggleMarkInBlocks(
246
-
sortedBlocks.filter((b) => b.type === "text").map((b) => b.value),
247
-
schema.marks.strikethrough,
248
-
);
249
-
},
250
-
},
251
-
]);
252
-
let removeListener = addShortcut(
253
-
shortcuts.map((shortcut) => ({
254
-
...shortcut,
255
-
handler: () => undoManager.withUndoGroup(() => shortcut.handler()),
256
-
})),
257
-
);
258
-
let listener = async (e: KeyboardEvent) =>
259
-
undoManager.withUndoGroup(async () => {
260
-
//used here and in cut
261
-
const deleteBlocks = async () => {
262
-
if (!entity_set.permissions.write) return;
263
-
if (moreThanOneSelected) {
264
-
e.preventDefault();
265
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
266
-
let selectedBlocks = useUIState.getState().selectedBlocks;
267
-
let firstBlock = sortedBlocks[0];
268
-
269
-
await rep?.mutate.removeBlock(
270
-
selectedBlocks.map((block) => ({ blockEntity: block.value })),
271
-
);
272
-
useUIState.getState().closePage(selectedBlocks.map((b) => b.value));
273
-
274
-
let nextBlock =
275
-
siblings?.[
276
-
siblings.findIndex((s) => s.value === firstBlock.value) - 1
277
-
];
278
-
if (nextBlock) {
279
-
useUIState.getState().setSelectedBlock({
280
-
value: nextBlock.value,
281
-
parent: nextBlock.parent,
282
-
});
283
-
let type = await rep?.query((tx) =>
284
-
scanIndex(tx).eav(nextBlock.value, "block/type"),
285
-
);
286
-
if (!type?.[0]) return;
287
-
if (
288
-
type[0]?.data.value === "text" ||
289
-
type[0]?.data.value === "heading"
290
-
)
291
-
focusBlock(
292
-
{
293
-
value: nextBlock.value,
294
-
type: "text",
295
-
parent: nextBlock.parent,
296
-
},
297
-
{ type: "end" },
298
-
);
299
-
}
300
-
}
301
-
};
302
-
if (e.key === "Backspace" || e.key === "Delete") {
303
-
deleteBlocks();
304
-
}
305
-
if (e.key === "ArrowUp") {
306
-
let [sortedBlocks, siblings] = await getSortedSelectionBound();
307
-
let focusedBlock = useUIState.getState().focusedEntity;
308
-
if (!e.shiftKey && !e.ctrlKey) {
309
-
if (e.defaultPrevented) return;
310
-
if (sortedBlocks.length === 1) return;
311
-
let firstBlock = sortedBlocks[0];
312
-
if (!firstBlock) return;
313
-
let type = await rep?.query((tx) =>
314
-
scanIndex(tx).eav(firstBlock.value, "block/type"),
315
-
);
316
-
if (!type?.[0]) return;
317
-
useUIState.getState().setSelectedBlock(firstBlock);
318
-
focusBlock(
319
-
{ ...firstBlock, type: type[0].data.value },
320
-
{ type: "start" },
321
-
);
322
-
} else {
323
-
if (e.defaultPrevented) return;
324
-
if (
325
-
sortedBlocks.length <= 1 ||
326
-
!focusedBlock ||
327
-
focusedBlock.entityType === "page"
328
-
)
329
-
return;
330
-
let b = focusedBlock;
331
-
let focusedBlockIndex = sortedBlocks.findIndex(
332
-
(s) => s.value == b.entityID,
333
-
);
334
-
if (focusedBlockIndex === 0) {
335
-
let index = siblings.findIndex((s) => s.value === b.entityID);
336
-
let nextSelectedBlock = siblings[index - 1];
337
-
if (!nextSelectedBlock) return;
338
-
339
-
scrollIntoViewIfNeeded(
340
-
document.getElementById(
341
-
elementId.block(nextSelectedBlock.value).container,
342
-
),
343
-
false,
344
-
);
345
-
useUIState.getState().addBlockToSelection({
346
-
...nextSelectedBlock,
347
-
});
348
-
useUIState.getState().setFocusedBlock({
349
-
entityType: "block",
350
-
parent: nextSelectedBlock.parent,
351
-
entityID: nextSelectedBlock.value,
352
-
});
353
-
} else {
354
-
let nextBlock = sortedBlocks[sortedBlocks.length - 2];
355
-
useUIState.getState().setFocusedBlock({
356
-
entityType: "block",
357
-
parent: b.parent,
358
-
entityID: nextBlock.value,
359
-
});
360
-
scrollIntoViewIfNeeded(
361
-
document.getElementById(
362
-
elementId.block(nextBlock.value).container,
363
-
),
364
-
false,
365
-
);
366
-
if (sortedBlocks.length === 2) {
367
-
useEditorStates
368
-
.getState()
369
-
.editorStates[nextBlock.value]?.view?.focus();
370
-
}
371
-
useUIState
372
-
.getState()
373
-
.removeBlockFromSelection(sortedBlocks[focusedBlockIndex]);
374
-
}
375
-
}
376
-
}
377
-
if (e.key === "ArrowLeft") {
378
-
let [sortedSelection, siblings] = await getSortedSelectionBound();
379
-
if (sortedSelection.length === 1) return;
380
-
let firstBlock = sortedSelection[0];
381
-
if (!firstBlock) return;
382
-
let type = await rep?.query((tx) =>
383
-
scanIndex(tx).eav(firstBlock.value, "block/type"),
384
-
);
385
-
if (!type?.[0]) return;
386
-
useUIState.getState().setSelectedBlock(firstBlock);
387
-
focusBlock(
388
-
{ ...firstBlock, type: type[0].data.value },
389
-
{ type: "start" },
390
-
);
391
-
}
392
-
if (e.key === "ArrowRight") {
393
-
let [sortedSelection, siblings] = await getSortedSelectionBound();
394
-
if (sortedSelection.length === 1) return;
395
-
let lastBlock = sortedSelection[sortedSelection.length - 1];
396
-
if (!lastBlock) return;
397
-
let type = await rep?.query((tx) =>
398
-
scanIndex(tx).eav(lastBlock.value, "block/type"),
399
-
);
400
-
if (!type?.[0]) return;
401
-
useUIState.getState().setSelectedBlock(lastBlock);
402
-
focusBlock(
403
-
{ ...lastBlock, type: type[0].data.value },
404
-
{ type: "end" },
405
-
);
406
-
}
407
-
if (e.key === "Tab") {
408
-
let [sortedSelection, siblings] = await getSortedSelectionBound();
409
-
if (sortedSelection.length <= 1) return;
410
-
e.preventDefault();
411
-
if (e.shiftKey) {
412
-
for (let i = siblings.length - 1; i >= 0; i--) {
413
-
let block = siblings[i];
414
-
if (!sortedSelection.find((s) => s.value === block.value))
415
-
continue;
416
-
if (
417
-
sortedSelection.find((s) => s.value === block.listData?.parent)
418
-
)
419
-
continue;
420
-
let parentoffset = 1;
421
-
let previousBlock = siblings[i - parentoffset];
422
-
while (
423
-
previousBlock &&
424
-
sortedSelection.find((s) => previousBlock.value === s.value)
425
-
) {
426
-
parentoffset += 1;
427
-
previousBlock = siblings[i - parentoffset];
428
-
}
429
-
if (!block.listData || !previousBlock.listData) continue;
430
-
outdent(block, previousBlock, rep);
431
-
}
432
-
} else {
433
-
for (let i = 0; i < siblings.length; i++) {
434
-
let block = siblings[i];
435
-
if (!sortedSelection.find((s) => s.value === block.value))
436
-
continue;
437
-
if (
438
-
sortedSelection.find((s) => s.value === block.listData?.parent)
439
-
)
440
-
continue;
441
-
let parentoffset = 1;
442
-
let previousBlock = siblings[i - parentoffset];
443
-
while (
444
-
previousBlock &&
445
-
sortedSelection.find((s) => previousBlock.value === s.value)
446
-
) {
447
-
parentoffset += 1;
448
-
previousBlock = siblings[i - parentoffset];
449
-
}
450
-
if (!block.listData || !previousBlock.listData) continue;
451
-
indent(block, previousBlock, rep);
452
-
}
453
-
}
454
-
}
455
-
if (e.key === "ArrowDown") {
456
-
let [sortedSelection, siblings] = await getSortedSelectionBound();
457
-
let focusedBlock = useUIState.getState().focusedEntity;
458
-
if (!e.shiftKey) {
459
-
if (sortedSelection.length === 1) return;
460
-
let lastBlock = sortedSelection[sortedSelection.length - 1];
461
-
if (!lastBlock) return;
462
-
let type = await rep?.query((tx) =>
463
-
scanIndex(tx).eav(lastBlock.value, "block/type"),
464
-
);
465
-
if (!type?.[0]) return;
466
-
useUIState.getState().setSelectedBlock(lastBlock);
467
-
focusBlock(
468
-
{ ...lastBlock, type: type[0].data.value },
469
-
{ type: "end" },
470
-
);
471
-
}
472
-
if (e.shiftKey) {
473
-
if (e.defaultPrevented) return;
474
-
if (
475
-
sortedSelection.length <= 1 ||
476
-
!focusedBlock ||
477
-
focusedBlock.entityType === "page"
478
-
)
479
-
return;
480
-
let b = focusedBlock;
481
-
let focusedBlockIndex = sortedSelection.findIndex(
482
-
(s) => s.value == b.entityID,
483
-
);
484
-
if (focusedBlockIndex === sortedSelection.length - 1) {
485
-
let index = siblings.findIndex((s) => s.value === b.entityID);
486
-
let nextSelectedBlock = siblings[index + 1];
487
-
if (!nextSelectedBlock) return;
488
-
useUIState.getState().addBlockToSelection({
489
-
...nextSelectedBlock,
490
-
});
491
-
492
-
scrollIntoViewIfNeeded(
493
-
document.getElementById(
494
-
elementId.block(nextSelectedBlock.value).container,
495
-
),
496
-
false,
497
-
);
498
-
useUIState.getState().setFocusedBlock({
499
-
entityType: "block",
500
-
parent: nextSelectedBlock.parent,
501
-
entityID: nextSelectedBlock.value,
502
-
});
503
-
} else {
504
-
let nextBlock = sortedSelection[1];
505
-
useUIState
506
-
.getState()
507
-
.removeBlockFromSelection({ value: b.entityID });
508
-
scrollIntoViewIfNeeded(
509
-
document.getElementById(
510
-
elementId.block(nextBlock.value).container,
511
-
),
512
-
false,
513
-
);
514
-
useUIState.getState().setFocusedBlock({
515
-
entityType: "block",
516
-
parent: b.parent,
517
-
entityID: nextBlock.value,
518
-
});
519
-
if (sortedSelection.length === 2) {
520
-
useEditorStates
521
-
.getState()
522
-
.editorStates[nextBlock.value]?.view?.focus();
523
-
}
524
-
}
525
-
}
526
-
}
527
-
if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) {
528
-
if (!rep) return;
529
-
if (e.shiftKey || (e.metaKey && e.ctrlKey)) return;
530
-
let [, , selectionWithFoldedChildren] =
531
-
await getSortedSelectionBound();
532
-
if (!selectionWithFoldedChildren) return;
533
-
let el = document.activeElement as HTMLElement;
534
-
if (
535
-
el?.tagName === "LABEL" ||
536
-
el?.tagName === "INPUT" ||
537
-
el?.tagName === "TEXTAREA"
538
-
) {
539
-
return;
540
-
}
541
-
542
-
if (
543
-
el.contentEditable === "true" &&
544
-
selectionWithFoldedChildren.length <= 1
545
-
)
546
-
return;
547
-
e.preventDefault();
548
-
await copySelection(rep, selectionWithFoldedChildren);
549
-
if (e.key === "x") deleteBlocks();
550
-
}
551
-
});
552
-
window.addEventListener("keydown", listener);
553
-
return () => {
554
-
removeListener();
555
-
window.removeEventListener("keydown", listener);
556
-
};
557
-
}, [moreThanOneSelected, rep, entity_set.permissions.write]);
558
-
559
-
let [mouseDown, setMouseDown] = useState(false);
560
-
let initialContentEditableParent = useRef<null | Node>(null);
561
-
let savedSelection = useRef<SavedRange[] | null>(undefined);
562
-
useEffect(() => {
563
-
if (isMobile) return;
564
-
if (!entity_set.permissions.write) return;
565
-
let mouseDownListener = (e: MouseEvent) => {
566
-
if ((e.target as Element).getAttribute("data-draggable")) return;
567
-
let contentEditableParent = getContentEditableParent(e.target as Node);
568
-
if (contentEditableParent) {
569
-
setMouseDown(true);
570
-
let entityID = (contentEditableParent as Element).getAttribute(
571
-
"data-entityid",
572
-
);
573
-
useSelectingMouse.setState({ start: entityID });
574
-
}
575
-
initialContentEditableParent.current = contentEditableParent;
576
-
};
577
-
let mouseUpListener = (e: MouseEvent) => {
578
-
savedSelection.current = null;
579
-
if (
580
-
initialContentEditableParent.current &&
581
-
!(e.target as Element).getAttribute("data-draggable") &&
582
-
getContentEditableParent(e.target as Node) !==
583
-
initialContentEditableParent.current
584
-
) {
585
-
setTimeout(() => {
586
-
window.getSelection()?.removeAllRanges();
587
-
}, 5);
588
-
}
589
-
initialContentEditableParent.current = null;
590
-
useSelectingMouse.setState({ start: null });
591
-
setMouseDown(false);
592
-
};
593
-
window.addEventListener("mousedown", mouseDownListener);
594
-
window.addEventListener("mouseup", mouseUpListener);
595
-
return () => {
596
-
window.removeEventListener("mousedown", mouseDownListener);
597
-
window.removeEventListener("mouseup", mouseUpListener);
598
-
};
599
-
}, [entity_set.permissions.write, isMobile]);
600
-
useEffect(() => {
601
-
if (!mouseDown) return;
602
-
if (isMobile) return;
603
-
let mouseMoveListener = (e: MouseEvent) => {
604
-
if (e.buttons !== 1) return;
605
-
if (initialContentEditableParent.current) {
606
-
if (
607
-
initialContentEditableParent.current ===
608
-
getContentEditableParent(e.target as Node)
609
-
) {
610
-
if (savedSelection.current) {
611
-
restoreSelection(savedSelection.current);
612
-
}
613
-
savedSelection.current = null;
614
-
return;
615
-
}
616
-
if (!savedSelection.current) savedSelection.current = saveSelection();
617
-
window.getSelection()?.removeAllRanges();
618
-
}
619
-
};
620
-
window.addEventListener("mousemove", mouseMoveListener);
621
-
return () => {
622
-
window.removeEventListener("mousemove", mouseMoveListener);
623
-
};
624
-
}, [mouseDown, isMobile]);
625
-
return null;
626
-
}
627
-
628
-
type SavedRange = {
629
-
startContainer: Node;
630
-
startOffset: number;
631
-
endContainer: Node;
632
-
endOffset: number;
633
-
direction: "forward" | "backward";
634
-
};
635
-
export function saveSelection() {
636
-
let selection = window.getSelection();
637
-
if (selection && selection.rangeCount > 0) {
638
-
let ranges: SavedRange[] = [];
639
-
for (let i = 0; i < selection.rangeCount; i++) {
640
-
let range = selection.getRangeAt(i);
641
-
ranges.push({
642
-
startContainer: range.startContainer,
643
-
startOffset: range.startOffset,
644
-
endContainer: range.endContainer,
645
-
endOffset: range.endOffset,
646
-
direction:
647
-
selection.anchorNode === range.startContainer &&
648
-
selection.anchorOffset === range.startOffset
649
-
? "forward"
650
-
: "backward",
651
-
});
652
-
}
653
-
return ranges;
654
-
}
655
-
return [];
656
-
}
657
-
658
-
export function restoreSelection(savedRanges: SavedRange[]) {
659
-
if (savedRanges && savedRanges.length > 0) {
660
-
let selection = window.getSelection();
661
-
if (!selection) return;
662
-
selection.removeAllRanges();
663
-
for (let i = 0; i < savedRanges.length; i++) {
664
-
let range = document.createRange();
665
-
range.setStart(savedRanges[i].startContainer, savedRanges[i].startOffset);
666
-
range.setEnd(savedRanges[i].endContainer, savedRanges[i].endOffset);
667
-
668
-
selection.addRange(range);
669
-
670
-
// If the direction is backward, collapse the selection to the end and then extend it backward
671
-
if (savedRanges[i].direction === "backward") {
672
-
selection.collapseToEnd();
673
-
selection.extend(
674
-
savedRanges[i].startContainer,
675
-
savedRanges[i].startOffset,
676
-
);
677
-
}
678
-
}
679
-
}
680
-
}
681
-
682
-
function getContentEditableParent(e: Node | null): Node | null {
683
-
let element: Node | null = e;
684
-
while (element && element !== document) {
685
-
if (
686
-
(element as HTMLElement).contentEditable === "true" ||
687
-
(element as HTMLElement).getAttribute("data-editable-block")
688
-
) {
689
-
return element;
690
-
}
691
-
element = element.parentNode;
692
-
}
693
-
return null;
694
-
}
695
-
696
-
export const getSortedSelection = async (
697
-
rep: Replicache<ReplicacheMutators>,
698
-
) => {
699
-
let selectedBlocks = useUIState.getState().selectedBlocks;
700
-
let foldedBlocks = useUIState.getState().foldedBlocks;
701
-
if (!selectedBlocks[0]) return [[], []];
702
-
let siblings =
703
-
(await rep?.query((tx) =>
704
-
getBlocksWithType(tx, selectedBlocks[0].parent),
705
-
)) || [];
706
-
let sortedBlocks = siblings.filter((s) => {
707
-
let selected = selectedBlocks.find((sb) => sb.value === s.value);
708
-
return selected;
709
-
});
710
-
let sortedBlocksWithChildren = siblings.filter((s) => {
711
-
let selected = selectedBlocks.find((sb) => sb.value === s.value);
712
-
if (s.listData && !selected) {
713
-
//Select the children of folded list blocks (in order to copy them)
714
-
return s.listData.path.find(
715
-
(p) =>
716
-
selectedBlocks.find((sb) => sb.value === p.entity) &&
717
-
foldedBlocks.includes(p.entity),
718
-
);
719
-
}
720
-
return selected;
721
-
});
722
-
return [
723
-
sortedBlocks,
724
-
siblings.filter(
725
-
(f) =>
726
-
!f.listData ||
727
-
!f.listData.path.find(
728
-
(p) => foldedBlocks.includes(p.entity) && p.entity !== f.value,
729
-
),
730
-
),
731
-
sortedBlocksWithChildren,
732
-
];
733
-
};
734
-
735
-
function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) {
736
-
let everyBlockHasMark = blocks.reduce((acc, block) => {
737
-
let editor = useEditorStates.getState().editorStates[block];
738
-
if (!editor) return acc;
739
-
let { view } = editor;
740
-
let from = 0;
741
-
let to = view.state.doc.content.size;
742
-
let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark);
743
-
return acc && hasMarkInRange;
744
-
}, true);
745
-
for (let block of blocks) {
746
-
let editor = useEditorStates.getState().editorStates[block];
747
-
if (!editor) return;
748
-
let { view } = editor;
749
-
let tr = view.state.tr;
750
-
751
-
let from = 0;
752
-
let to = view.state.doc.content.size;
753
-
754
-
tr.setMeta("bulkOp", true);
755
-
if (everyBlockHasMark) {
756
-
tr.removeMark(from, to, mark);
757
-
} else {
758
-
tr.addMark(from, to, mark.create(attrs));
759
-
}
760
-
761
-
view.dispatch(tr);
762
-
}
763
-
}
+296
components/Tags.tsx
+296
components/Tags.tsx
···
1
+
"use client";
2
+
import { CloseTiny } from "components/Icons/CloseTiny";
3
+
import { Input } from "components/Input";
4
+
import { useState, useRef } from "react";
5
+
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
6
+
import { Popover } from "components/Popover";
7
+
import Link from "next/link";
8
+
import { searchTags, type TagSearchResult } from "actions/searchTags";
9
+
10
+
export const Tag = (props: {
11
+
name: string;
12
+
selected?: boolean;
13
+
onDelete?: (tag: string) => void;
14
+
className?: string;
15
+
}) => {
16
+
return (
17
+
<div
18
+
className={`tag flex items-center text-xs rounded-md border ${props.selected ? "bg-accent-1 border-accent-1 font-bold" : "bg-bg-page border-border"} ${props.className}`}
19
+
>
20
+
<Link
21
+
href={`https://leaflet.pub/tag/${encodeURIComponent(props.name)}`}
22
+
className={`px-1 py-0.5 hover:no-underline! ${props.selected ? "text-accent-2" : "text-tertiary"}`}
23
+
>
24
+
{props.name}{" "}
25
+
</Link>
26
+
{props.selected ? (
27
+
<button
28
+
type="button"
29
+
onClick={() => (props.onDelete ? props.onDelete(props.name) : null)}
30
+
>
31
+
<CloseTiny className="scale-75 pr-1 text-accent-2" />
32
+
</button>
33
+
) : null}
34
+
</div>
35
+
);
36
+
};
37
+
38
+
export const TagSelector = (props: {
39
+
selectedTags: string[];
40
+
setSelectedTags: (tags: string[]) => void;
41
+
}) => {
42
+
return (
43
+
<div className="flex flex-col gap-2 text-primary">
44
+
<TagSearchInput
45
+
selectedTags={props.selectedTags}
46
+
setSelectedTags={props.setSelectedTags}
47
+
/>
48
+
{props.selectedTags.length > 0 ? (
49
+
<div className="flex flex-wrap gap-2 ">
50
+
{props.selectedTags.map((tag) => (
51
+
<Tag
52
+
key={tag}
53
+
name={tag}
54
+
selected
55
+
onDelete={() => {
56
+
props.setSelectedTags(
57
+
props.selectedTags.filter((t) => t !== tag),
58
+
);
59
+
}}
60
+
/>
61
+
))}
62
+
</div>
63
+
) : (
64
+
<div className="text-tertiary italic text-sm h-6">no tags selected</div>
65
+
)}
66
+
</div>
67
+
);
68
+
};
69
+
70
+
export const TagSearchInput = (props: {
71
+
selectedTags: string[];
72
+
setSelectedTags: (tags: string[]) => void;
73
+
}) => {
74
+
let [tagInputValue, setTagInputValue] = useState("");
75
+
let [isOpen, setIsOpen] = useState(false);
76
+
let [highlightedIndex, setHighlightedIndex] = useState(0);
77
+
let [searchResults, setSearchResults] = useState<TagSearchResult[]>([]);
78
+
let [isSearching, setIsSearching] = useState(false);
79
+
80
+
const placeholderInputRef = useRef<HTMLButtonElement | null>(null);
81
+
82
+
let inputWidth = placeholderInputRef.current?.clientWidth;
83
+
84
+
// Fetch tags whenever the input value changes
85
+
useDebouncedEffect(
86
+
async () => {
87
+
setIsSearching(true);
88
+
const results = await searchTags(tagInputValue);
89
+
if (results) {
90
+
setSearchResults(results);
91
+
}
92
+
setIsSearching(false);
93
+
},
94
+
300,
95
+
[tagInputValue],
96
+
);
97
+
98
+
const filteredTags = searchResults
99
+
.filter((tag) => !props.selectedTags.includes(tag.name))
100
+
.filter((tag) =>
101
+
tag.name.toLowerCase().includes(tagInputValue.toLowerCase()),
102
+
);
103
+
104
+
const showResults = tagInputValue.length >= 3;
105
+
106
+
function clearTagInput() {
107
+
setHighlightedIndex(0);
108
+
setTagInputValue("");
109
+
}
110
+
111
+
function selectTag(tag: string) {
112
+
console.log("selected " + tag);
113
+
props.setSelectedTags([...props.selectedTags, tag]);
114
+
clearTagInput();
115
+
}
116
+
117
+
const handleKeyDown = (
118
+
e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
119
+
) => {
120
+
if (!isOpen) return;
121
+
122
+
if (e.key === "ArrowDown") {
123
+
e.preventDefault();
124
+
setHighlightedIndex((prev) =>
125
+
prev < filteredTags.length ? prev + 1 : prev,
126
+
);
127
+
} else if (e.key === "ArrowUp") {
128
+
e.preventDefault();
129
+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
130
+
} else if (e.key === "Enter") {
131
+
e.preventDefault();
132
+
selectTag(
133
+
userInputResult
134
+
? highlightedIndex === 0
135
+
? tagInputValue
136
+
: filteredTags[highlightedIndex - 1].name
137
+
: filteredTags[highlightedIndex].name,
138
+
);
139
+
clearTagInput();
140
+
} else if (e.key === "Escape") {
141
+
setIsOpen(false);
142
+
}
143
+
};
144
+
145
+
const userInputResult =
146
+
showResults &&
147
+
tagInputValue !== "" &&
148
+
!filteredTags.some((tag) => tag.name === tagInputValue);
149
+
150
+
return (
151
+
<div className="relative">
152
+
<Input
153
+
className="input-with-border grow w-full outline-none!"
154
+
id="placeholder-tag-search-input"
155
+
value={tagInputValue}
156
+
placeholder="search tagsโฆ"
157
+
onChange={(e) => {
158
+
setTagInputValue(e.target.value);
159
+
setIsOpen(true);
160
+
setHighlightedIndex(0);
161
+
}}
162
+
onKeyDown={handleKeyDown}
163
+
onFocus={() => {
164
+
setIsOpen(true);
165
+
document.getElementById("tag-search-input")?.focus();
166
+
}}
167
+
/>
168
+
<Popover
169
+
open={isOpen}
170
+
onOpenChange={() => {
171
+
setIsOpen(!isOpen);
172
+
if (!isOpen)
173
+
setTimeout(() => {
174
+
document.getElementById("tag-search-input")?.focus();
175
+
}, 100);
176
+
}}
177
+
className="w-full p-2! min-w-xs text-primary"
178
+
sideOffset={-39}
179
+
onOpenAutoFocus={(e) => e.preventDefault()}
180
+
asChild
181
+
trigger={
182
+
<button
183
+
ref={placeholderInputRef}
184
+
className="absolute left-0 top-0 right-0 h-[30px]"
185
+
></button>
186
+
}
187
+
noArrow
188
+
>
189
+
<div className="" style={{ width: `${inputWidth}px` }}>
190
+
<Input
191
+
className="input-with-border grow w-full mb-2"
192
+
id="tag-search-input"
193
+
placeholder="search tagsโฆ"
194
+
value={tagInputValue}
195
+
onChange={(e) => {
196
+
setTagInputValue(e.target.value);
197
+
setIsOpen(true);
198
+
setHighlightedIndex(0);
199
+
}}
200
+
onKeyDown={handleKeyDown}
201
+
onFocus={() => {
202
+
setIsOpen(true);
203
+
}}
204
+
/>
205
+
{props.selectedTags.length > 0 ? (
206
+
<div className="flex flex-wrap gap-2 pb-[6px]">
207
+
{props.selectedTags.map((tag) => (
208
+
<Tag
209
+
key={tag}
210
+
name={tag}
211
+
selected
212
+
onDelete={() => {
213
+
props.setSelectedTags(
214
+
props.selectedTags.filter((t) => t !== tag),
215
+
);
216
+
}}
217
+
/>
218
+
))}
219
+
</div>
220
+
) : (
221
+
<div className="text-tertiary italic text-sm h-6">
222
+
no tags selected
223
+
</div>
224
+
)}
225
+
<hr className=" mb-[2px] border-border-light" />
226
+
227
+
{showResults ? (
228
+
<>
229
+
{userInputResult && (
230
+
<TagResult
231
+
key={"userInput"}
232
+
index={0}
233
+
name={tagInputValue}
234
+
tagged={0}
235
+
highlighted={0 === highlightedIndex}
236
+
setHighlightedIndex={setHighlightedIndex}
237
+
onSelect={() => {
238
+
selectTag(tagInputValue);
239
+
}}
240
+
/>
241
+
)}
242
+
{filteredTags.map((tag, i) => (
243
+
<TagResult
244
+
key={tag.name}
245
+
index={userInputResult ? i + 1 : i}
246
+
name={tag.name}
247
+
tagged={tag.document_count}
248
+
highlighted={
249
+
(userInputResult ? i + 1 : i) === highlightedIndex
250
+
}
251
+
setHighlightedIndex={setHighlightedIndex}
252
+
onSelect={() => {
253
+
selectTag(tag.name);
254
+
}}
255
+
/>
256
+
))}
257
+
</>
258
+
) : (
259
+
<div className="text-tertiary italic text-sm py-1">
260
+
type at least 3 characters to search
261
+
</div>
262
+
)}
263
+
</div>
264
+
</Popover>
265
+
</div>
266
+
);
267
+
};
268
+
269
+
const TagResult = (props: {
270
+
name: string;
271
+
tagged: number;
272
+
onSelect: () => void;
273
+
index: number;
274
+
highlighted: boolean;
275
+
setHighlightedIndex: (i: number) => void;
276
+
}) => {
277
+
return (
278
+
<div className="-mx-1">
279
+
<button
280
+
className={`w-full flex justify-between items-center text-left pr-1 pl-[6px] py-0.5 rounded-md ${props.highlighted ? "bg-border-light" : ""}`}
281
+
onSelect={(e) => {
282
+
e.preventDefault();
283
+
props.onSelect();
284
+
}}
285
+
onClick={(e) => {
286
+
e.preventDefault();
287
+
props.onSelect();
288
+
}}
289
+
onMouseEnter={(e) => props.setHighlightedIndex(props.index)}
290
+
>
291
+
{props.name}
292
+
<div className="text-tertiary text-sm"> {props.tagged}</div>
293
+
</button>
294
+
</div>
295
+
);
296
+
};
+4
-2
components/ThemeManager/PublicationThemeProvider.tsx
+4
-2
components/ThemeManager/PublicationThemeProvider.tsx
···
2
2
import { useMemo, useState } from "react";
3
3
import { parseColor } from "react-aria-components";
4
4
import { useEntity } from "src/replicache";
5
-
import { getColorContrast } from "./ThemeProvider";
5
+
import { getColorContrast } from "./themeUtils";
6
6
import { useColorAttribute, colorToString } from "./useColorAttribute";
7
7
import { BaseThemeProvider } from "./ThemeProvider";
8
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
···
84
84
<div
85
85
className="PubBackgroundWrapper w-full bg-bg-leaflet text-primary h-full flex flex-col bg-cover bg-center bg-no-repeat items-stretch"
86
86
style={{
87
-
backgroundImage: `url(${backgroundImage})`,
87
+
backgroundImage: backgroundImage
88
+
? `url(${backgroundImage})`
89
+
: undefined,
88
90
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
89
91
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
90
92
}}
+1
-40
components/ThemeManager/ThemeProvider.tsx
+1
-40
components/ThemeManager/ThemeProvider.tsx
···
5
5
CSSProperties,
6
6
useContext,
7
7
useEffect,
8
-
useMemo,
9
-
useState,
10
8
} from "react";
11
9
import {
12
10
colorToString,
···
14
12
useColorAttributeNullable,
15
13
} from "./useColorAttribute";
16
14
import { Color as AriaColor, parseColor } from "react-aria-components";
17
-
import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
18
15
19
16
import { useEntity } from "src/replicache";
20
17
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
···
23
20
PublicationThemeProvider,
24
21
} from "./PublicationThemeProvider";
25
22
import { PubLeafletPublication } from "lexicons/api";
26
-
27
-
type CSSVariables = {
28
-
"--bg-leaflet": string;
29
-
"--bg-page": string;
30
-
"--primary": string;
31
-
"--accent-1": string;
32
-
"--accent-2": string;
33
-
"--accent-contrast": string;
34
-
"--highlight-1": string;
35
-
"--highlight-2": string;
36
-
"--highlight-3": string;
37
-
};
38
-
39
-
// define the color defaults for everything
40
-
export const ThemeDefaults = {
41
-
"theme/page-background": "#FDFCFA",
42
-
"theme/card-background": "#FFFFFF",
43
-
"theme/primary": "#272727",
44
-
"theme/highlight-1": "#FFFFFF",
45
-
"theme/highlight-2": "#EDD280",
46
-
"theme/highlight-3": "#FFCDC3",
47
-
48
-
//everywhere else, accent-background = accent-1 and accent-text = accent-2.
49
-
// we just need to create a migration pipeline before we can change this
50
-
"theme/accent-text": "#FFFFFF",
51
-
"theme/accent-background": "#0000FF",
52
-
"theme/accent-contrast": "#0000FF",
53
-
};
23
+
import { getColorContrast } from "./themeUtils";
54
24
55
25
// define a function to set an Aria Color to a CSS Variable in RGB
56
26
function setCSSVariableToColor(
···
368
338
);
369
339
};
370
340
371
-
// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
372
-
export function getColorContrast(color1: string, color2: string) {
373
-
ColorSpace.register(sRGB);
374
-
375
-
let parsedColor1 = parse(`rgb(${color1})`);
376
-
let parsedColor2 = parse(`rgb(${color2})`);
377
-
378
-
return contrastLstar(parsedColor1, parsedColor2);
379
-
}
+27
components/ThemeManager/themeUtils.ts
+27
components/ThemeManager/themeUtils.ts
···
1
+
import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
2
+
3
+
// define the color defaults for everything
4
+
export const ThemeDefaults = {
5
+
"theme/page-background": "#FDFCFA",
6
+
"theme/card-background": "#FFFFFF",
7
+
"theme/primary": "#272727",
8
+
"theme/highlight-1": "#FFFFFF",
9
+
"theme/highlight-2": "#EDD280",
10
+
"theme/highlight-3": "#FFCDC3",
11
+
12
+
//everywhere else, accent-background = accent-1 and accent-text = accent-2.
13
+
// we just need to create a migration pipeline before we can change this
14
+
"theme/accent-text": "#FFFFFF",
15
+
"theme/accent-background": "#0000FF",
16
+
"theme/accent-contrast": "#0000FF",
17
+
};
18
+
19
+
// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
20
+
export function getColorContrast(color1: string, color2: string) {
21
+
ColorSpace.register(sRGB);
22
+
23
+
let parsedColor1 = parse(`rgb(${color1})`);
24
+
let parsedColor2 = parse(`rgb(${color2})`);
25
+
26
+
return contrastLstar(parsedColor1, parsedColor2);
27
+
}
+1
-1
components/ThemeManager/useColorAttribute.ts
+1
-1
components/ThemeManager/useColorAttribute.ts
···
2
2
import { Color, parseColor } from "react-aria-components";
3
3
import { useEntity, useReplicache } from "src/replicache";
4
4
import { FilterAttributes } from "src/replicache/attributes";
5
-
import { ThemeDefaults } from "./ThemeProvider";
5
+
import { ThemeDefaults } from "./themeUtils";
6
6
7
7
export function useColorAttribute(
8
8
entity: string | null,
+5
-14
components/Toolbar/BlockToolbar.tsx
+5
-14
components/Toolbar/BlockToolbar.tsx
···
2
2
import { ToolbarButton } from ".";
3
3
import { Separator, ShortcutKey } from "components/Layout";
4
4
import { metaKey } from "src/utils/metaKey";
5
-
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6
5
import { useUIState } from "src/useUIState";
7
6
import { LockBlockButton } from "./LockBlockButton";
8
7
import { TextAlignmentButton } from "./TextAlignmentToolbar";
9
8
import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
10
9
import { DeleteSmall } from "components/Icons/DeleteSmall";
10
+
import { getSortedSelection } from "components/SelectionManager/selectionState";
11
11
12
12
export const BlockToolbar = (props: {
13
13
setToolbarState: (
···
66
66
67
67
const MoveBlockButtons = () => {
68
68
let { rep } = useReplicache();
69
-
const getSortedSelection = async () => {
70
-
let selectedBlocks = useUIState.getState().selectedBlocks;
71
-
let siblings =
72
-
(await rep?.query((tx) =>
73
-
getBlocksWithType(tx, selectedBlocks[0].parent),
74
-
)) || [];
75
-
let sortedBlocks = siblings.filter((s) =>
76
-
selectedBlocks.find((sb) => sb.value === s.value),
77
-
);
78
-
return [sortedBlocks, siblings];
79
-
};
80
69
return (
81
70
<>
82
71
<ToolbarButton
83
72
hiddenOnCanvas
84
73
onClick={async () => {
85
-
let [sortedBlocks, siblings] = await getSortedSelection();
74
+
if (!rep) return;
75
+
let [sortedBlocks, siblings] = await getSortedSelection(rep);
86
76
if (sortedBlocks.length > 1) return;
87
77
let block = sortedBlocks[0];
88
78
let previousBlock =
···
139
129
<ToolbarButton
140
130
hiddenOnCanvas
141
131
onClick={async () => {
142
-
let [sortedBlocks, siblings] = await getSortedSelection();
132
+
if (!rep) return;
133
+
let [sortedBlocks, siblings] = await getSortedSelection(rep);
143
134
if (sortedBlocks.length > 1) return;
144
135
let block = sortedBlocks[0];
145
136
let nextBlock = siblings
+1
-1
components/Toolbar/MultiSelectToolbar.tsx
+1
-1
components/Toolbar/MultiSelectToolbar.tsx
···
8
8
import { LockBlockButton } from "./LockBlockButton";
9
9
import { Props } from "components/Icons/Props";
10
10
import { TextAlignmentButton } from "./TextAlignmentToolbar";
11
-
import { getSortedSelection } from "components/SelectionManager";
11
+
import { getSortedSelection } from "components/SelectionManager/selectionState";
12
12
13
13
export const MultiselectToolbar = (props: {
14
14
setToolbarState: (
+2
-1
components/Toolbar/index.tsx
+2
-1
components/Toolbar/index.tsx
···
13
13
import { TextToolbar } from "./TextToolbar";
14
14
import { BlockToolbar } from "./BlockToolbar";
15
15
import { MultiselectToolbar } from "./MultiSelectToolbar";
16
-
import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock";
16
+
import { AreYouSure } from "components/Blocks/DeleteBlock";
17
+
import { deleteBlock } from "src/utils/deleteBlock";
17
18
import { TooltipButton } from "components/Buttons";
18
19
import { TextAlignmentToolbar } from "./TextAlignmentToolbar";
19
20
import { useIsMobile } from "src/hooks/isMobile";
+1
-1
components/utils/UpdateLeafletTitle.tsx
+1
-1
components/utils/UpdateLeafletTitle.tsx
···
8
8
import { useEntity, useReplicache } from "src/replicache";
9
9
import * as Y from "yjs";
10
10
import * as base64 from "base64-js";
11
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
11
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
12
12
import { useParams, useRouter, useSearchParams } from "next/navigation";
13
13
import { focusBlock } from "src/utils/focusBlock";
14
14
import { useIsMobile } from "src/hooks/isMobile";
+31
lexicons/api/lexicons.ts
+31
lexicons/api/lexicons.ts
···
1440
1440
type: 'ref',
1441
1441
ref: 'lex:pub.leaflet.publication#theme',
1442
1442
},
1443
+
tags: {
1444
+
type: 'array',
1445
+
items: {
1446
+
type: 'string',
1447
+
maxLength: 50,
1448
+
},
1449
+
},
1443
1450
pages: {
1444
1451
type: 'array',
1445
1452
items: {
···
1865
1872
type: 'union',
1866
1873
refs: [
1867
1874
'lex:pub.leaflet.richtext.facet#link',
1875
+
'lex:pub.leaflet.richtext.facet#didMention',
1876
+
'lex:pub.leaflet.richtext.facet#atMention',
1868
1877
'lex:pub.leaflet.richtext.facet#code',
1869
1878
'lex:pub.leaflet.richtext.facet#highlight',
1870
1879
'lex:pub.leaflet.richtext.facet#underline',
···
1901
1910
properties: {
1902
1911
uri: {
1903
1912
type: 'string',
1913
+
},
1914
+
},
1915
+
},
1916
+
didMention: {
1917
+
type: 'object',
1918
+
description: 'Facet feature for mentioning a did.',
1919
+
required: ['did'],
1920
+
properties: {
1921
+
did: {
1922
+
type: 'string',
1923
+
format: 'did',
1924
+
},
1925
+
},
1926
+
},
1927
+
atMention: {
1928
+
type: 'object',
1929
+
description: 'Facet feature for mentioning an AT URI.',
1930
+
required: ['atURI'],
1931
+
properties: {
1932
+
atURI: {
1933
+
type: 'string',
1934
+
format: 'uri',
1904
1935
},
1905
1936
},
1906
1937
},
+1
lexicons/api/types/pub/leaflet/document.ts
+1
lexicons/api/types/pub/leaflet/document.ts
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
+34
lexicons/api/types/pub/leaflet/richtext/facet.ts
···
20
20
index: ByteSlice
21
21
features: (
22
22
| $Typed<Link>
23
+
| $Typed<DidMention>
24
+
| $Typed<AtMention>
23
25
| $Typed<Code>
24
26
| $Typed<Highlight>
25
27
| $Typed<Underline>
···
72
74
73
75
export function validateLink<V>(v: V) {
74
76
return validate<Link & V>(v, id, hashLink)
77
+
}
78
+
79
+
/** Facet feature for mentioning a did. */
80
+
export interface DidMention {
81
+
$type?: 'pub.leaflet.richtext.facet#didMention'
82
+
did: string
83
+
}
84
+
85
+
const hashDidMention = 'didMention'
86
+
87
+
export function isDidMention<V>(v: V) {
88
+
return is$typed(v, id, hashDidMention)
89
+
}
90
+
91
+
export function validateDidMention<V>(v: V) {
92
+
return validate<DidMention & V>(v, id, hashDidMention)
93
+
}
94
+
95
+
/** Facet feature for mentioning an AT URI. */
96
+
export interface AtMention {
97
+
$type?: 'pub.leaflet.richtext.facet#atMention'
98
+
atURI: string
99
+
}
100
+
101
+
const hashAtMention = 'atMention'
102
+
103
+
export function isAtMention<V>(v: V) {
104
+
return is$typed(v, id, hashAtMention)
105
+
}
106
+
107
+
export function validateAtMention<V>(v: V) {
108
+
return validate<AtMention & V>(v, id, hashAtMention)
75
109
}
76
110
77
111
/** Facet feature for inline code. */
+7
lexicons/pub/leaflet/document.json
+7
lexicons/pub/leaflet/document.json
+28
lexicons/pub/leaflet/richtext/facet.json
+28
lexicons/pub/leaflet/richtext/facet.json
···
20
20
"type": "union",
21
21
"refs": [
22
22
"#link",
23
+
"#didMention",
24
+
"#atMention",
23
25
"#code",
24
26
"#highlight",
25
27
"#underline",
···
59
61
"properties": {
60
62
"uri": {
61
63
"type": "string"
64
+
}
65
+
}
66
+
},
67
+
"didMention": {
68
+
"type": "object",
69
+
"description": "Facet feature for mentioning a did.",
70
+
"required": [
71
+
"did"
72
+
],
73
+
"properties": {
74
+
"did": {
75
+
"type": "string",
76
+
"format": "did"
77
+
}
78
+
}
79
+
},
80
+
"atMention": {
81
+
"type": "object",
82
+
"description": "Facet feature for mentioning an AT URI.",
83
+
"required": [
84
+
"atURI"
85
+
],
86
+
"properties": {
87
+
"atURI": {
88
+
"type": "string",
89
+
"format": "uri"
62
90
}
63
91
}
64
92
},
+1
lexicons/src/document.ts
+1
lexicons/src/document.ts
···
23
23
publication: { type: "string", format: "at-uri" },
24
24
author: { type: "string", format: "at-identifier" },
25
25
theme: { type: "ref", ref: "pub.leaflet.publication#theme" },
26
+
tags: { type: "array", items: { type: "string", maxLength: 50 } },
26
27
pages: {
27
28
type: "array",
28
29
items: {
+12
lexicons/src/facet.ts
+12
lexicons/src/facet.ts
···
9
9
uri: { type: "string" },
10
10
},
11
11
},
12
+
didMention: {
13
+
type: "object",
14
+
description: "Facet feature for mentioning a did.",
15
+
required: ["did"],
16
+
properties: { did: { type: "string", format: "did" } },
17
+
},
18
+
atMention: {
19
+
type: "object",
20
+
description: "Facet feature for mentioning an AT URI.",
21
+
required: ["atURI"],
22
+
properties: { atURI: { type: "string", format: "uri" } },
23
+
},
12
24
code: {
13
25
type: "object",
14
26
description: "Facet feature for inline code.",
+68
-49
package-lock.json
+68
-49
package-lock.json
···
48
48
"inngest": "^3.40.1",
49
49
"ioredis": "^5.6.1",
50
50
"katex": "^0.16.22",
51
+
"l": "^0.6.0",
51
52
"linkifyjs": "^4.2.0",
52
53
"luxon": "^3.7.2",
53
54
"multiformats": "^13.3.2",
54
-
"next": "16.0.3",
55
+
"next": "^16.0.7",
55
56
"pg": "^8.16.3",
56
57
"prosemirror-commands": "^1.5.2",
57
58
"prosemirror-inputrules": "^1.4.0",
···
59
60
"prosemirror-model": "^1.21.0",
60
61
"prosemirror-schema-basic": "^1.2.2",
61
62
"prosemirror-state": "^1.4.3",
62
-
"react": "19.2.0",
63
+
"react": "19.2.1",
63
64
"react-aria-components": "^1.8.0",
64
65
"react-day-picker": "^9.3.0",
65
-
"react-dom": "19.2.0",
66
+
"react-dom": "19.2.1",
66
67
"react-use-measure": "^2.1.1",
67
68
"redlock": "^5.0.0-beta.2",
68
69
"rehype-parse": "^9.0.0",
···
2734
2735
}
2735
2736
},
2736
2737
"node_modules/@next/env": {
2737
-
"version": "16.0.3",
2738
-
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz",
2739
-
"integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ=="
2738
+
"version": "16.0.7",
2739
+
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
2740
+
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
2741
+
"license": "MIT"
2740
2742
},
2741
2743
"node_modules/@next/eslint-plugin-next": {
2742
2744
"version": "16.0.3",
···
2804
2806
}
2805
2807
},
2806
2808
"node_modules/@next/swc-darwin-arm64": {
2807
-
"version": "16.0.3",
2808
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz",
2809
-
"integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==",
2809
+
"version": "16.0.7",
2810
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
2811
+
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
2810
2812
"cpu": [
2811
2813
"arm64"
2812
2814
],
2815
+
"license": "MIT",
2813
2816
"optional": true,
2814
2817
"os": [
2815
2818
"darwin"
···
2819
2822
}
2820
2823
},
2821
2824
"node_modules/@next/swc-darwin-x64": {
2822
-
"version": "16.0.3",
2823
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz",
2824
-
"integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==",
2825
+
"version": "16.0.7",
2826
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
2827
+
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
2825
2828
"cpu": [
2826
2829
"x64"
2827
2830
],
2831
+
"license": "MIT",
2828
2832
"optional": true,
2829
2833
"os": [
2830
2834
"darwin"
···
2834
2838
}
2835
2839
},
2836
2840
"node_modules/@next/swc-linux-arm64-gnu": {
2837
-
"version": "16.0.3",
2838
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz",
2839
-
"integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==",
2841
+
"version": "16.0.7",
2842
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
2843
+
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
2840
2844
"cpu": [
2841
2845
"arm64"
2842
2846
],
2847
+
"license": "MIT",
2843
2848
"optional": true,
2844
2849
"os": [
2845
2850
"linux"
···
2849
2854
}
2850
2855
},
2851
2856
"node_modules/@next/swc-linux-arm64-musl": {
2852
-
"version": "16.0.3",
2853
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz",
2854
-
"integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==",
2857
+
"version": "16.0.7",
2858
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
2859
+
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
2855
2860
"cpu": [
2856
2861
"arm64"
2857
2862
],
2863
+
"license": "MIT",
2858
2864
"optional": true,
2859
2865
"os": [
2860
2866
"linux"
···
2864
2870
}
2865
2871
},
2866
2872
"node_modules/@next/swc-linux-x64-gnu": {
2867
-
"version": "16.0.3",
2868
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz",
2869
-
"integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==",
2873
+
"version": "16.0.7",
2874
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
2875
+
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
2870
2876
"cpu": [
2871
2877
"x64"
2872
2878
],
2879
+
"license": "MIT",
2873
2880
"optional": true,
2874
2881
"os": [
2875
2882
"linux"
···
2879
2886
}
2880
2887
},
2881
2888
"node_modules/@next/swc-linux-x64-musl": {
2882
-
"version": "16.0.3",
2883
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz",
2884
-
"integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==",
2889
+
"version": "16.0.7",
2890
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
2891
+
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
2885
2892
"cpu": [
2886
2893
"x64"
2887
2894
],
2895
+
"license": "MIT",
2888
2896
"optional": true,
2889
2897
"os": [
2890
2898
"linux"
···
2894
2902
}
2895
2903
},
2896
2904
"node_modules/@next/swc-win32-arm64-msvc": {
2897
-
"version": "16.0.3",
2898
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz",
2899
-
"integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==",
2905
+
"version": "16.0.7",
2906
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
2907
+
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
2900
2908
"cpu": [
2901
2909
"arm64"
2902
2910
],
2911
+
"license": "MIT",
2903
2912
"optional": true,
2904
2913
"os": [
2905
2914
"win32"
···
2909
2918
}
2910
2919
},
2911
2920
"node_modules/@next/swc-win32-x64-msvc": {
2912
-
"version": "16.0.3",
2913
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz",
2914
-
"integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==",
2921
+
"version": "16.0.7",
2922
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
2923
+
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
2915
2924
"cpu": [
2916
2925
"x64"
2917
2926
],
2927
+
"license": "MIT",
2918
2928
"optional": true,
2919
2929
"os": [
2920
2930
"win32"
···
13360
13370
"json-buffer": "3.0.1"
13361
13371
}
13362
13372
},
13373
+
"node_modules/l": {
13374
+
"version": "0.6.0",
13375
+
"resolved": "https://registry.npmjs.org/l/-/l-0.6.0.tgz",
13376
+
"integrity": "sha512-rB5disIyfKRBQ1xcedByHCcAmPWy2NPnjWo5u4mVVIPtathROHyfHjkloqSBT49mLnSRnupkpoIUOFCL7irCVQ==",
13377
+
"license": "MIT"
13378
+
},
13363
13379
"node_modules/language-subtag-registry": {
13364
13380
"version": "0.3.23",
13365
13381
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
···
15108
15124
}
15109
15125
},
15110
15126
"node_modules/next": {
15111
-
"version": "16.0.3",
15112
-
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
15113
-
"integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==",
15127
+
"version": "16.0.7",
15128
+
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
15129
+
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
15130
+
"license": "MIT",
15114
15131
"dependencies": {
15115
-
"@next/env": "16.0.3",
15132
+
"@next/env": "16.0.7",
15116
15133
"@swc/helpers": "0.5.15",
15117
15134
"caniuse-lite": "^1.0.30001579",
15118
15135
"postcss": "8.4.31",
···
15125
15142
"node": ">=20.9.0"
15126
15143
},
15127
15144
"optionalDependencies": {
15128
-
"@next/swc-darwin-arm64": "16.0.3",
15129
-
"@next/swc-darwin-x64": "16.0.3",
15130
-
"@next/swc-linux-arm64-gnu": "16.0.3",
15131
-
"@next/swc-linux-arm64-musl": "16.0.3",
15132
-
"@next/swc-linux-x64-gnu": "16.0.3",
15133
-
"@next/swc-linux-x64-musl": "16.0.3",
15134
-
"@next/swc-win32-arm64-msvc": "16.0.3",
15135
-
"@next/swc-win32-x64-msvc": "16.0.3",
15145
+
"@next/swc-darwin-arm64": "16.0.7",
15146
+
"@next/swc-darwin-x64": "16.0.7",
15147
+
"@next/swc-linux-arm64-gnu": "16.0.7",
15148
+
"@next/swc-linux-arm64-musl": "16.0.7",
15149
+
"@next/swc-linux-x64-gnu": "16.0.7",
15150
+
"@next/swc-linux-x64-musl": "16.0.7",
15151
+
"@next/swc-win32-arm64-msvc": "16.0.7",
15152
+
"@next/swc-win32-x64-msvc": "16.0.7",
15136
15153
"sharp": "^0.34.4"
15137
15154
},
15138
15155
"peerDependencies": {
···
16321
16338
}
16322
16339
},
16323
16340
"node_modules/react": {
16324
-
"version": "19.2.0",
16325
-
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
16326
-
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
16341
+
"version": "19.2.1",
16342
+
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
16343
+
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
16344
+
"license": "MIT",
16327
16345
"engines": {
16328
16346
"node": ">=0.10.0"
16329
16347
}
···
16442
16460
}
16443
16461
},
16444
16462
"node_modules/react-dom": {
16445
-
"version": "19.2.0",
16446
-
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
16447
-
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
16463
+
"version": "19.2.1",
16464
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
16465
+
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
16466
+
"license": "MIT",
16448
16467
"dependencies": {
16449
16468
"scheduler": "^0.27.0"
16450
16469
},
16451
16470
"peerDependencies": {
16452
-
"react": "^19.2.0"
16471
+
"react": "^19.2.1"
16453
16472
}
16454
16473
},
16455
16474
"node_modules/react-is": {
+4
-3
package.json
+4
-3
package.json
···
58
58
"inngest": "^3.40.1",
59
59
"ioredis": "^5.6.1",
60
60
"katex": "^0.16.22",
61
+
"l": "^0.6.0",
61
62
"linkifyjs": "^4.2.0",
62
63
"luxon": "^3.7.2",
63
64
"multiformats": "^13.3.2",
64
-
"next": "16.0.3",
65
+
"next": "^16.0.7",
65
66
"pg": "^8.16.3",
66
67
"prosemirror-commands": "^1.5.2",
67
68
"prosemirror-inputrules": "^1.4.0",
···
69
70
"prosemirror-model": "^1.21.0",
70
71
"prosemirror-schema-basic": "^1.2.2",
71
72
"prosemirror-state": "^1.4.3",
72
-
"react": "19.2.0",
73
+
"react": "19.2.1",
73
74
"react-aria-components": "^1.8.0",
74
75
"react-day-picker": "^9.3.0",
75
-
"react-dom": "19.2.0",
76
+
"react-dom": "19.2.1",
76
77
"react-use-measure": "^2.1.1",
77
78
"redlock": "^5.0.0-beta.2",
78
79
"rehype-parse": "^9.0.0",
+3
-1
src/hooks/useLocalizedDate.ts
+3
-1
src/hooks/useLocalizedDate.ts
···
28
28
29
29
// On initial page load, use header timezone. After hydration, use system timezone
30
30
const effectiveTimezone = !hasPageLoaded
31
-
? timezone
31
+
? timezone || "UTC"
32
32
: Intl.DateTimeFormat().resolvedOptions().timeZone;
33
+
34
+
console.log("tz", effectiveTimezone);
33
35
34
36
// Apply timezone if available
35
37
if (effectiveTimezone) {
+4
-3
src/hooks/usePreserveScroll.ts
+4
-3
src/hooks/usePreserveScroll.ts
···
6
6
useEffect(() => {
7
7
if (!ref.current || !key) return;
8
8
9
-
window.requestAnimationFrame(() => {
10
-
ref.current?.scrollTo({ top: scrollPositions[key] || 0 });
11
-
});
9
+
if (scrollPositions[key] !== undefined)
10
+
window.requestAnimationFrame(() => {
11
+
ref.current?.scrollTo({ top: scrollPositions[key] || 0 });
12
+
});
12
13
13
14
const listener = () => {
14
15
if (!ref.current?.scrollTop) return;
+254
-37
src/notifications.ts
+254
-37
src/notifications.ts
···
2
2
3
3
import { supabaseServerClient } from "supabase/serverClient";
4
4
import { Tables, TablesInsert } from "supabase/database.types";
5
+
import { AtUri } from "@atproto/syntax";
6
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
5
7
6
8
type NotificationRow = Tables<"notifications">;
7
9
···
12
14
export type NotificationData =
13
15
| { type: "comment"; comment_uri: string; parent_uri?: string }
14
16
| { type: "subscribe"; subscription_uri: string }
15
-
| { type: "quote"; bsky_post_uri: string; document_uri: string };
17
+
| { type: "quote"; bsky_post_uri: string; document_uri: string }
18
+
| { type: "mention"; document_uri: string; mention_type: "did" }
19
+
| { type: "mention"; document_uri: string; mention_type: "publication"; mentioned_uri: string }
20
+
| { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string }
21
+
| { type: "comment_mention"; comment_uri: string; mention_type: "did" }
22
+
| { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string }
23
+
| { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string };
16
24
17
25
export type HydratedNotification =
18
26
| HydratedCommentNotification
19
27
| HydratedSubscribeNotification
20
-
| HydratedQuoteNotification;
28
+
| HydratedQuoteNotification
29
+
| HydratedMentionNotification
30
+
| HydratedCommentMentionNotification;
21
31
export async function hydrateNotifications(
22
32
notifications: NotificationRow[],
23
33
): Promise<Array<HydratedNotification>> {
24
34
// Call all hydrators in parallel
25
-
const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([
35
+
const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([
26
36
hydrateCommentNotifications(notifications),
27
37
hydrateSubscribeNotifications(notifications),
28
38
hydrateQuoteNotifications(notifications),
39
+
hydrateMentionNotifications(notifications),
40
+
hydrateCommentMentionNotifications(notifications),
29
41
]);
30
42
31
43
// Combine all hydrated notifications
32
-
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications];
44
+
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications];
33
45
34
46
// Sort by created_at to maintain order
35
47
allHydrated.sort(
···
73
85
)
74
86
.in("uri", commentUris);
75
87
76
-
return commentNotifications.map((notification) => ({
77
-
id: notification.id,
78
-
recipient: notification.recipient,
79
-
created_at: notification.created_at,
80
-
type: "comment" as const,
81
-
comment_uri: notification.data.comment_uri,
82
-
parentData: notification.data.parent_uri
83
-
? comments?.find((c) => c.uri === notification.data.parent_uri)!
84
-
: undefined,
85
-
commentData: comments?.find(
86
-
(c) => c.uri === notification.data.comment_uri,
87
-
)!,
88
-
}));
88
+
return commentNotifications
89
+
.map((notification) => {
90
+
const commentData = comments?.find((c) => c.uri === notification.data.comment_uri);
91
+
if (!commentData) return null;
92
+
return {
93
+
id: notification.id,
94
+
recipient: notification.recipient,
95
+
created_at: notification.created_at,
96
+
type: "comment" as const,
97
+
comment_uri: notification.data.comment_uri,
98
+
parentData: notification.data.parent_uri
99
+
? comments?.find((c) => c.uri === notification.data.parent_uri)
100
+
: undefined,
101
+
commentData,
102
+
};
103
+
})
104
+
.filter((n) => n !== null);
89
105
}
90
106
91
107
export type HydratedSubscribeNotification = Awaited<
···
113
129
.select("*, identities(bsky_profiles(*)), publications(*)")
114
130
.in("uri", subscriptionUris);
115
131
116
-
return subscribeNotifications.map((notification) => ({
117
-
id: notification.id,
118
-
recipient: notification.recipient,
119
-
created_at: notification.created_at,
120
-
type: "subscribe" as const,
121
-
subscription_uri: notification.data.subscription_uri,
122
-
subscriptionData: subscriptions?.find(
123
-
(s) => s.uri === notification.data.subscription_uri,
124
-
)!,
125
-
}));
132
+
return subscribeNotifications
133
+
.map((notification) => {
134
+
const subscriptionData = subscriptions?.find((s) => s.uri === notification.data.subscription_uri);
135
+
if (!subscriptionData) return null;
136
+
return {
137
+
id: notification.id,
138
+
recipient: notification.recipient,
139
+
created_at: notification.created_at,
140
+
type: "subscribe" as const,
141
+
subscription_uri: notification.data.subscription_uri,
142
+
subscriptionData,
143
+
};
144
+
})
145
+
.filter((n) => n !== null);
126
146
}
127
147
128
148
export type HydratedQuoteNotification = Awaited<
···
153
173
.select("*, documents_in_publications(publications(*))")
154
174
.in("uri", documentUris);
155
175
156
-
return quoteNotifications.map((notification) => ({
157
-
id: notification.id,
158
-
recipient: notification.recipient,
159
-
created_at: notification.created_at,
160
-
type: "quote" as const,
161
-
bsky_post_uri: notification.data.bsky_post_uri,
162
-
document_uri: notification.data.document_uri,
163
-
bskyPost: bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri)!,
164
-
document: documents?.find((d) => d.uri === notification.data.document_uri)!,
165
-
}));
176
+
return quoteNotifications
177
+
.map((notification) => {
178
+
const bskyPost = bskyPosts?.find((p) => p.uri === notification.data.bsky_post_uri);
179
+
const document = documents?.find((d) => d.uri === notification.data.document_uri);
180
+
if (!bskyPost || !document) return null;
181
+
return {
182
+
id: notification.id,
183
+
recipient: notification.recipient,
184
+
created_at: notification.created_at,
185
+
type: "quote" as const,
186
+
bsky_post_uri: notification.data.bsky_post_uri,
187
+
document_uri: notification.data.document_uri,
188
+
bskyPost,
189
+
document,
190
+
};
191
+
})
192
+
.filter((n) => n !== null);
193
+
}
194
+
195
+
export type HydratedMentionNotification = Awaited<
196
+
ReturnType<typeof hydrateMentionNotifications>
197
+
>[0];
198
+
199
+
async function hydrateMentionNotifications(notifications: NotificationRow[]) {
200
+
const mentionNotifications = notifications.filter(
201
+
(n): n is NotificationRow & { data: ExtractNotificationType<"mention"> } =>
202
+
(n.data as NotificationData)?.type === "mention",
203
+
);
204
+
205
+
if (mentionNotifications.length === 0) {
206
+
return [];
207
+
}
208
+
209
+
// Fetch document data from the database
210
+
const documentUris = mentionNotifications.map((n) => n.data.document_uri);
211
+
const { data: documents } = await supabaseServerClient
212
+
.from("documents")
213
+
.select("*, documents_in_publications(publications(*))")
214
+
.in("uri", documentUris);
215
+
216
+
// Extract unique DIDs from document URIs to resolve handles
217
+
const documentCreatorDids = [...new Set(documentUris.map((uri) => new AtUri(uri).host))];
218
+
219
+
// Resolve DIDs to handles in parallel
220
+
const didToHandleMap = new Map<string, string | null>();
221
+
await Promise.all(
222
+
documentCreatorDids.map(async (did) => {
223
+
try {
224
+
const resolved = await idResolver.did.resolve(did);
225
+
const handle = resolved?.alsoKnownAs?.[0]
226
+
? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix
227
+
: null;
228
+
didToHandleMap.set(did, handle);
229
+
} catch (error) {
230
+
console.error(`Failed to resolve DID ${did}:`, error);
231
+
didToHandleMap.set(did, null);
232
+
}
233
+
}),
234
+
);
235
+
236
+
// Fetch mentioned publications and documents
237
+
const mentionedPublicationUris = mentionNotifications
238
+
.filter((n) => n.data.mention_type === "publication")
239
+
.map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "publication" }>).mentioned_uri);
240
+
241
+
const mentionedDocumentUris = mentionNotifications
242
+
.filter((n) => n.data.mention_type === "document")
243
+
.map((n) => (n.data as Extract<ExtractNotificationType<"mention">, { mention_type: "document" }>).mentioned_uri);
244
+
245
+
const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([
246
+
mentionedPublicationUris.length > 0
247
+
? supabaseServerClient
248
+
.from("publications")
249
+
.select("*")
250
+
.in("uri", mentionedPublicationUris)
251
+
: Promise.resolve({ data: [] }),
252
+
mentionedDocumentUris.length > 0
253
+
? supabaseServerClient
254
+
.from("documents")
255
+
.select("*, documents_in_publications(publications(*))")
256
+
.in("uri", mentionedDocumentUris)
257
+
: Promise.resolve({ data: [] }),
258
+
]);
259
+
260
+
return mentionNotifications
261
+
.map((notification) => {
262
+
const document = documents?.find((d) => d.uri === notification.data.document_uri);
263
+
if (!document) return null;
264
+
265
+
const mentionedUri = notification.data.mention_type !== "did"
266
+
? (notification.data as Extract<ExtractNotificationType<"mention">, { mentioned_uri: string }>).mentioned_uri
267
+
: undefined;
268
+
269
+
const documentCreatorDid = new AtUri(notification.data.document_uri).host;
270
+
const documentCreatorHandle = didToHandleMap.get(documentCreatorDid) ?? null;
271
+
272
+
return {
273
+
id: notification.id,
274
+
recipient: notification.recipient,
275
+
created_at: notification.created_at,
276
+
type: "mention" as const,
277
+
document_uri: notification.data.document_uri,
278
+
mention_type: notification.data.mention_type,
279
+
mentioned_uri: mentionedUri,
280
+
document,
281
+
documentCreatorHandle,
282
+
mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined,
283
+
mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined,
284
+
};
285
+
})
286
+
.filter((n) => n !== null);
287
+
}
288
+
289
+
export type HydratedCommentMentionNotification = Awaited<
290
+
ReturnType<typeof hydrateCommentMentionNotifications>
291
+
>[0];
292
+
293
+
async function hydrateCommentMentionNotifications(notifications: NotificationRow[]) {
294
+
const commentMentionNotifications = notifications.filter(
295
+
(n): n is NotificationRow & { data: ExtractNotificationType<"comment_mention"> } =>
296
+
(n.data as NotificationData)?.type === "comment_mention",
297
+
);
298
+
299
+
if (commentMentionNotifications.length === 0) {
300
+
return [];
301
+
}
302
+
303
+
// Fetch comment data from the database
304
+
const commentUris = commentMentionNotifications.map((n) => n.data.comment_uri);
305
+
const { data: comments } = await supabaseServerClient
306
+
.from("comments_on_documents")
307
+
.select(
308
+
"*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))",
309
+
)
310
+
.in("uri", commentUris);
311
+
312
+
// Extract unique DIDs from comment URIs to resolve handles
313
+
const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))];
314
+
315
+
// Resolve DIDs to handles in parallel
316
+
const didToHandleMap = new Map<string, string | null>();
317
+
await Promise.all(
318
+
commenterDids.map(async (did) => {
319
+
try {
320
+
const resolved = await idResolver.did.resolve(did);
321
+
const handle = resolved?.alsoKnownAs?.[0]
322
+
? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix
323
+
: null;
324
+
didToHandleMap.set(did, handle);
325
+
} catch (error) {
326
+
console.error(`Failed to resolve DID ${did}:`, error);
327
+
didToHandleMap.set(did, null);
328
+
}
329
+
}),
330
+
);
331
+
332
+
// Fetch mentioned publications and documents
333
+
const mentionedPublicationUris = commentMentionNotifications
334
+
.filter((n) => n.data.mention_type === "publication")
335
+
.map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "publication" }>).mentioned_uri);
336
+
337
+
const mentionedDocumentUris = commentMentionNotifications
338
+
.filter((n) => n.data.mention_type === "document")
339
+
.map((n) => (n.data as Extract<ExtractNotificationType<"comment_mention">, { mention_type: "document" }>).mentioned_uri);
340
+
341
+
const [{ data: mentionedPublications }, { data: mentionedDocuments }] = await Promise.all([
342
+
mentionedPublicationUris.length > 0
343
+
? supabaseServerClient
344
+
.from("publications")
345
+
.select("*")
346
+
.in("uri", mentionedPublicationUris)
347
+
: Promise.resolve({ data: [] }),
348
+
mentionedDocumentUris.length > 0
349
+
? supabaseServerClient
350
+
.from("documents")
351
+
.select("*, documents_in_publications(publications(*))")
352
+
.in("uri", mentionedDocumentUris)
353
+
: Promise.resolve({ data: [] }),
354
+
]);
355
+
356
+
return commentMentionNotifications
357
+
.map((notification) => {
358
+
const commentData = comments?.find((c) => c.uri === notification.data.comment_uri);
359
+
if (!commentData) return null;
360
+
361
+
const mentionedUri = notification.data.mention_type !== "did"
362
+
? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri
363
+
: undefined;
364
+
365
+
const commenterDid = new AtUri(notification.data.comment_uri).host;
366
+
const commenterHandle = didToHandleMap.get(commenterDid) ?? null;
367
+
368
+
return {
369
+
id: notification.id,
370
+
recipient: notification.recipient,
371
+
created_at: notification.created_at,
372
+
type: "comment_mention" as const,
373
+
comment_uri: notification.data.comment_uri,
374
+
mention_type: notification.data.mention_type,
375
+
mentioned_uri: mentionedUri,
376
+
commentData,
377
+
commenterHandle,
378
+
mentionedPublication: mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined,
379
+
mentionedDocument: mentionedUri ? mentionedDocuments?.find((d) => d.uri === mentionedUri) : undefined,
380
+
};
381
+
})
382
+
.filter((n) => n !== null);
166
383
}
167
384
168
385
export async function pingIdentityToUpdateNotification(did: string) {
+34
-8
src/replicache/mutations.ts
+34
-8
src/replicache/mutations.ts
···
609
609
};
610
610
611
611
const updatePublicationDraft: Mutation<{
612
-
title: string;
613
-
description: string;
612
+
title?: string;
613
+
description?: string;
614
+
tags?: string[];
614
615
}> = async (args, ctx) => {
615
616
await ctx.runOnServer(async (serverCtx) => {
616
617
console.log("updating");
617
-
await serverCtx.supabase
618
-
.from("leaflets_in_publications")
619
-
.update({ description: args.description, title: args.title })
620
-
.eq("leaflet", ctx.permission_token_id);
618
+
const updates: {
619
+
description?: string;
620
+
title?: string;
621
+
tags?: string[];
622
+
} = {};
623
+
if (args.description !== undefined) updates.description = args.description;
624
+
if (args.title !== undefined) updates.title = args.title;
625
+
if (args.tags !== undefined) updates.tags = args.tags;
626
+
627
+
if (Object.keys(updates).length > 0) {
628
+
// First try to update leaflets_in_publications (for publications)
629
+
const { data: pubResult } = await serverCtx.supabase
630
+
.from("leaflets_in_publications")
631
+
.update(updates)
632
+
.eq("leaflet", ctx.permission_token_id)
633
+
.select("leaflet");
634
+
635
+
// If no rows were updated in leaflets_in_publications,
636
+
// try leaflets_to_documents (for standalone documents)
637
+
if (!pubResult || pubResult.length === 0) {
638
+
await serverCtx.supabase
639
+
.from("leaflets_to_documents")
640
+
.update(updates)
641
+
.eq("leaflet", ctx.permission_token_id);
642
+
}
643
+
}
621
644
});
622
645
await ctx.runOnClient(async ({ tx }) => {
623
-
await tx.set("publication_title", args.title);
624
-
await tx.set("publication_description", args.description);
646
+
if (args.title !== undefined)
647
+
await tx.set("publication_title", args.title);
648
+
if (args.description !== undefined)
649
+
await tx.set("publication_description", args.description);
650
+
if (args.tags !== undefined) await tx.set("publication_tags", args.tags);
625
651
});
626
652
};
627
653
+116
src/utils/deleteBlock.ts
+116
src/utils/deleteBlock.ts
···
1
+
import { Replicache } from "replicache";
2
+
import { ReplicacheMutators } from "src/replicache";
3
+
import { useUIState } from "src/useUIState";
4
+
import { scanIndex } from "src/replicache/utils";
5
+
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6
+
import { focusBlock } from "src/utils/focusBlock";
7
+
8
+
export async function deleteBlock(
9
+
entities: string[],
10
+
rep: Replicache<ReplicacheMutators>,
11
+
) {
12
+
// get what pagess we need to close as a result of deleting this block
13
+
let pagesToClose = [] as string[];
14
+
for (let entity of entities) {
15
+
let [type] = await rep.query((tx) =>
16
+
scanIndex(tx).eav(entity, "block/type"),
17
+
);
18
+
if (type.data.value === "card") {
19
+
let [childPages] = await rep?.query(
20
+
(tx) => scanIndex(tx).eav(entity, "block/card") || [],
21
+
);
22
+
pagesToClose = [childPages?.data.value];
23
+
}
24
+
if (type.data.value === "mailbox") {
25
+
let [archive] = await rep?.query(
26
+
(tx) => scanIndex(tx).eav(entity, "mailbox/archive") || [],
27
+
);
28
+
let [draft] = await rep?.query(
29
+
(tx) => scanIndex(tx).eav(entity, "mailbox/draft") || [],
30
+
);
31
+
pagesToClose = [archive?.data.value, draft?.data.value];
32
+
}
33
+
}
34
+
35
+
// the next and previous blocks in the block list
36
+
// if the focused thing is a page and not a block, return
37
+
let focusedBlock = useUIState.getState().focusedEntity;
38
+
let parent =
39
+
focusedBlock?.entityType === "page"
40
+
? focusedBlock.entityID
41
+
: focusedBlock?.parent;
42
+
43
+
if (parent) {
44
+
let parentType = await rep?.query((tx) =>
45
+
scanIndex(tx).eav(parent, "page/type"),
46
+
);
47
+
if (parentType[0]?.data.value === "canvas") {
48
+
useUIState
49
+
.getState()
50
+
.setFocusedBlock({ entityType: "page", entityID: parent });
51
+
useUIState.getState().setSelectedBlocks([]);
52
+
} else {
53
+
let siblings =
54
+
(await rep?.query((tx) => getBlocksWithType(tx, parent))) || [];
55
+
56
+
let selectedBlocks = useUIState.getState().selectedBlocks;
57
+
let firstSelected = selectedBlocks[0];
58
+
let lastSelected = selectedBlocks[entities.length - 1];
59
+
60
+
let prevBlock =
61
+
siblings?.[
62
+
siblings.findIndex((s) => s.value === firstSelected?.value) - 1
63
+
];
64
+
let prevBlockType = await rep?.query((tx) =>
65
+
scanIndex(tx).eav(prevBlock?.value, "block/type"),
66
+
);
67
+
68
+
let nextBlock =
69
+
siblings?.[
70
+
siblings.findIndex((s) => s.value === lastSelected.value) + 1
71
+
];
72
+
let nextBlockType = await rep?.query((tx) =>
73
+
scanIndex(tx).eav(nextBlock?.value, "block/type"),
74
+
);
75
+
76
+
if (prevBlock) {
77
+
useUIState.getState().setSelectedBlock({
78
+
value: prevBlock.value,
79
+
parent: prevBlock.parent,
80
+
});
81
+
82
+
focusBlock(
83
+
{
84
+
value: prevBlock.value,
85
+
type: prevBlockType?.[0].data.value,
86
+
parent: prevBlock.parent,
87
+
},
88
+
{ type: "end" },
89
+
);
90
+
} else {
91
+
useUIState.getState().setSelectedBlock({
92
+
value: nextBlock.value,
93
+
parent: nextBlock.parent,
94
+
});
95
+
96
+
focusBlock(
97
+
{
98
+
value: nextBlock.value,
99
+
type: nextBlockType?.[0]?.data.value,
100
+
parent: nextBlock.parent,
101
+
},
102
+
{ type: "start" },
103
+
);
104
+
}
105
+
}
106
+
}
107
+
108
+
pagesToClose.forEach((page) => page && useUIState.getState().closePage(page));
109
+
await Promise.all(
110
+
entities.map((entity) =>
111
+
rep?.mutate.removeBlock({
112
+
blockEntity: entity,
113
+
}),
114
+
),
115
+
);
116
+
}
+37
src/utils/focusElement.ts
+37
src/utils/focusElement.ts
···
1
+
import { isIOS } from "src/utils/isDevice";
2
+
3
+
export const focusElement = (
4
+
el?: HTMLInputElement | HTMLTextAreaElement | null,
5
+
) => {
6
+
if (!isIOS()) {
7
+
el?.focus();
8
+
return;
9
+
}
10
+
11
+
let fakeInput = document.createElement("input");
12
+
fakeInput.setAttribute("type", "text");
13
+
fakeInput.style.position = "fixed";
14
+
fakeInput.style.height = "0px";
15
+
fakeInput.style.width = "0px";
16
+
fakeInput.style.fontSize = "16px"; // disable auto zoom
17
+
document.body.appendChild(fakeInput);
18
+
fakeInput.focus();
19
+
setTimeout(() => {
20
+
if (!el) return;
21
+
el.style.transform = "translateY(-2000px)";
22
+
el?.focus();
23
+
fakeInput.remove();
24
+
el.value = " ";
25
+
el.setSelectionRange(1, 1);
26
+
requestAnimationFrame(() => {
27
+
if (el) {
28
+
el.style.transform = "";
29
+
}
30
+
});
31
+
setTimeout(() => {
32
+
if (!el) return;
33
+
el.value = "";
34
+
el.setSelectionRange(0, 0);
35
+
}, 50);
36
+
}, 20);
37
+
};
+73
src/utils/focusPage.ts
+73
src/utils/focusPage.ts
···
1
+
import { Replicache } from "replicache";
2
+
import { Fact, ReplicacheMutators } from "src/replicache";
3
+
import { useUIState } from "src/useUIState";
4
+
import { scanIndex } from "src/replicache/utils";
5
+
import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded";
6
+
import { elementId } from "src/utils/elementId";
7
+
import { focusBlock } from "src/utils/focusBlock";
8
+
9
+
export async function focusPage(
10
+
pageID: string,
11
+
rep: Replicache<ReplicacheMutators>,
12
+
focusFirstBlock?: "focusFirstBlock",
13
+
) {
14
+
// if this page is already focused,
15
+
let focusedBlock = useUIState.getState().focusedEntity;
16
+
// else set this page as focused
17
+
useUIState.setState(() => ({
18
+
focusedEntity: {
19
+
entityType: "page",
20
+
entityID: pageID,
21
+
},
22
+
}));
23
+
24
+
setTimeout(async () => {
25
+
//scroll to page
26
+
27
+
scrollIntoViewIfNeeded(
28
+
document.getElementById(elementId.page(pageID).container),
29
+
false,
30
+
"smooth",
31
+
);
32
+
33
+
// if we asked that the function focus the first block, focus the first block
34
+
if (focusFirstBlock === "focusFirstBlock") {
35
+
let firstBlock = await rep.query(async (tx) => {
36
+
let type = await scanIndex(tx).eav(pageID, "page/type");
37
+
let blocks = await scanIndex(tx).eav(
38
+
pageID,
39
+
type[0]?.data.value === "canvas" ? "canvas/block" : "card/block",
40
+
);
41
+
42
+
let firstBlock = blocks[0];
43
+
44
+
if (!firstBlock) {
45
+
return null;
46
+
}
47
+
48
+
let blockType = (
49
+
await tx
50
+
.scan<
51
+
Fact<"block/type">
52
+
>({ indexName: "eav", prefix: `${firstBlock.data.value}-block/type` })
53
+
.toArray()
54
+
)[0];
55
+
56
+
if (!blockType) return null;
57
+
58
+
return {
59
+
value: firstBlock.data.value,
60
+
type: blockType.data.value,
61
+
parent: firstBlock.entity,
62
+
position: firstBlock.data.position,
63
+
};
64
+
});
65
+
66
+
if (firstBlock) {
67
+
setTimeout(() => {
68
+
focusBlock(firstBlock, { type: "start" });
69
+
}, 500);
70
+
}
71
+
}
72
+
}, 50);
73
+
}
+6
-1
src/utils/getMicroLinkOgImage.ts
+6
-1
src/utils/getMicroLinkOgImage.ts
···
17
17
hostname = "leaflet.pub";
18
18
}
19
19
let full_path = `${protocol}://${hostname}${path}`;
20
-
return getWebpageImage(full_path, options);
20
+
return getWebpageImage(full_path, {
21
+
...options,
22
+
setJavaScriptEnabled: false,
23
+
});
21
24
}
22
25
23
26
export async function getWebpageImage(
24
27
url: string,
25
28
options?: {
29
+
setJavaScriptEnabled?: boolean;
26
30
width?: number;
27
31
height?: number;
28
32
deviceScaleFactor?: number;
···
39
43
},
40
44
body: JSON.stringify({
41
45
url,
46
+
setJavaScriptEnabled: options?.setJavaScriptEnabled,
42
47
scrollPage: true,
43
48
addStyleTag: [
44
49
{
+7
-31
src/utils/getPublicationMetadataFromLeafletData.ts
+7
-31
src/utils/getPublicationMetadataFromLeafletData.ts
···
32
32
(p) => p.leaflets_in_publications?.length,
33
33
)?.leaflets_in_publications?.[0];
34
34
35
-
// If not found, check for standalone documents (looseleafs)
36
-
let standaloneDoc = data?.leaflets_to_documents;
37
-
38
-
// Only use standaloneDoc if it exists and has meaningful data
39
-
// (either published with a document, or saved as draft with a title)
40
-
if (
41
-
!pubData &&
42
-
standaloneDoc &&
43
-
(standaloneDoc.document || standaloneDoc.title)
44
-
) {
35
+
// If not found, check for standalone documents
36
+
let standaloneDoc =
37
+
data?.leaflets_to_documents?.[0] ||
38
+
data?.permission_token_rights[0].entity_sets?.permission_tokens.find(
39
+
(p) => p.leaflets_to_documents?.length,
40
+
)?.leaflets_to_documents?.[0];
41
+
if (!pubData && standaloneDoc) {
45
42
// Transform standalone document data to match the expected format
46
43
pubData = {
47
44
...standaloneDoc,
48
45
publications: null, // No publication for standalone docs
49
46
doc: standaloneDoc.document,
50
-
leaflet: data.id,
51
47
};
52
48
}
53
-
54
-
// Also check nested permission tokens for looseleafs
55
-
if (!pubData) {
56
-
let nestedStandaloneDoc =
57
-
data?.permission_token_rights[0].entity_sets?.permission_tokens?.find(
58
-
(p) =>
59
-
p.leaflets_to_documents &&
60
-
(p.leaflets_to_documents.document || p.leaflets_to_documents.title),
61
-
)?.leaflets_to_documents;
62
-
63
-
if (nestedStandaloneDoc) {
64
-
pubData = {
65
-
...nestedStandaloneDoc,
66
-
publications: null,
67
-
doc: nestedStandaloneDoc.document,
68
-
leaflet: data.id,
69
-
};
70
-
}
71
-
}
72
-
73
49
return pubData;
74
50
}
+59
src/utils/mentionUtils.ts
+59
src/utils/mentionUtils.ts
···
1
+
import { AtUri } from "@atproto/api";
2
+
3
+
/**
4
+
* Converts a DID to a Bluesky profile URL
5
+
*/
6
+
export function didToBlueskyUrl(did: string): string {
7
+
return `https://bsky.app/profile/${did}`;
8
+
}
9
+
10
+
/**
11
+
* Converts an AT URI (publication or document) to the appropriate URL
12
+
*/
13
+
export function atUriToUrl(atUri: string): string {
14
+
try {
15
+
const uri = new AtUri(atUri);
16
+
17
+
if (uri.collection === "pub.leaflet.publication") {
18
+
// Publication URL: /lish/{did}/{rkey}
19
+
return `/lish/${uri.host}/${uri.rkey}`;
20
+
} else if (uri.collection === "pub.leaflet.document") {
21
+
// Document URL - we need to resolve this via the API
22
+
// For now, create a redirect route that will handle it
23
+
return `/lish/uri/${encodeURIComponent(atUri)}`;
24
+
}
25
+
26
+
return "#";
27
+
} catch (e) {
28
+
console.error("Failed to parse AT URI:", atUri, e);
29
+
return "#";
30
+
}
31
+
}
32
+
33
+
/**
34
+
* Opens a mention link in the appropriate way
35
+
* - DID mentions open in a new tab (external Bluesky)
36
+
* - Publication/document mentions navigate in the same tab
37
+
*/
38
+
export function handleMentionClick(
39
+
e: MouseEvent | React.MouseEvent,
40
+
type: "did" | "at-uri",
41
+
value: string
42
+
) {
43
+
e.preventDefault();
44
+
e.stopPropagation();
45
+
46
+
if (type === "did") {
47
+
// Open Bluesky profile in new tab
48
+
window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer");
49
+
} else {
50
+
// Navigate to publication/document in same tab
51
+
const url = atUriToUrl(value);
52
+
if (url.startsWith("/lish/uri/")) {
53
+
// Redirect route - navigate to it
54
+
window.location.href = url;
55
+
} else {
56
+
window.location.href = url;
57
+
}
58
+
}
59
+
}
+41
src/utils/yjsFragmentToString.ts
+41
src/utils/yjsFragmentToString.ts
···
1
+
import { XmlElement, XmlText, XmlHook } from "yjs";
2
+
3
+
export type Delta = {
4
+
insert: string;
5
+
attributes?: {
6
+
strong?: {};
7
+
code?: {};
8
+
em?: {};
9
+
underline?: {};
10
+
strikethrough?: {};
11
+
highlight?: { color: string };
12
+
link?: { href: string };
13
+
};
14
+
};
15
+
16
+
export function YJSFragmentToString(
17
+
node: XmlElement | XmlText | XmlHook,
18
+
): string {
19
+
if (node.constructor === XmlElement) {
20
+
// Handle hard_break nodes specially
21
+
if (node.nodeName === "hard_break") {
22
+
return "\n";
23
+
}
24
+
// Handle inline mention nodes
25
+
if (node.nodeName === "didMention" || node.nodeName === "atMention") {
26
+
return node.getAttribute("text") || "";
27
+
}
28
+
return node
29
+
.toArray()
30
+
.map((f) => YJSFragmentToString(f))
31
+
.join("");
32
+
}
33
+
if (node.constructor === XmlText) {
34
+
return (node.toDelta() as Delta[])
35
+
.map((d) => {
36
+
return d.insert;
37
+
})
38
+
.join("");
39
+
}
40
+
return "";
41
+
}
+13
-4
supabase/database.types.ts
+13
-4
supabase/database.types.ts
···
631
631
Row: {
632
632
created_at: string
633
633
description: string
634
-
document: string | null
634
+
document: string
635
635
leaflet: string
636
636
title: string
637
637
}
638
638
Insert: {
639
639
created_at?: string
640
640
description?: string
641
-
document?: string | null
641
+
document: string
642
642
leaflet: string
643
643
title?: string
644
644
}
645
645
Update: {
646
646
created_at?: string
647
647
description?: string
648
-
document?: string | null
648
+
document?: string
649
649
leaflet?: string
650
650
title?: string
651
651
}
···
660
660
{
661
661
foreignKeyName: "leaflets_to_documents_leaflet_fkey"
662
662
columns: ["leaflet"]
663
-
isOneToOne: true
663
+
isOneToOne: false
664
664
referencedRelation: "permission_tokens"
665
665
referencedColumns: ["id"]
666
666
},
···
1157
1157
client_group_id: string
1158
1158
}
1159
1159
Returns: Database["public"]["CompositeTypes"]["pull_result"]
1160
+
}
1161
+
search_tags: {
1162
+
Args: {
1163
+
search_query: string
1164
+
}
1165
+
Returns: {
1166
+
name: string
1167
+
document_count: number
1168
+
}[]
1160
1169
}
1161
1170
}
1162
1171
Enums: {