+14
-12
actions/deleteLeaflet.ts
+14
-12
actions/deleteLeaflet.ts
···
53
}
54
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) {
59
throw new Error(
60
"Unauthorized: You must be logged in to delete a published leaflet",
61
);
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
-
);
69
}
70
}
71
}
···
81
.where(eq(permission_tokens.id, permission_token.id));
82
83
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));
87
await tx
88
.delete(permission_tokens)
89
.where(eq(permission_tokens.id, permission_token.id));
···
53
}
54
55
// Check if there's a standalone published document
56
+
const leafletDocs = tokenData.leaflets_to_documents || [];
57
+
if (leafletDocs.length > 0) {
58
+
if (!identity) {
59
throw new Error(
60
"Unauthorized: You must be logged in to delete a published leaflet",
61
);
62
}
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
+
}
71
}
72
}
73
}
···
83
.where(eq(permission_tokens.id, permission_token.id));
84
85
if (!token?.permission_token_rights?.write) return;
86
+
await tx
87
+
.delete(entities)
88
+
.where(eq(entities.set, token.permission_token_rights.entity_set));
89
await tx
90
.delete(permission_tokens)
91
.where(eq(permission_tokens.id, permission_token.id));
-3
actions/publications/moveLeafletToPublication.ts
-3
actions/publications/moveLeafletToPublication.ts
···
11
) {
12
let identity = await getIdentityData();
13
if (!identity || !identity.atp_did) return null;
14
-
15
-
// Verify publication ownership
16
let { data: publication } = await supabaseServerClient
17
.from("publications")
18
.select("*")
···
20
.single();
21
if (publication?.identity_did !== identity.atp_did) return;
22
23
-
// Save as a publication draft
24
await supabaseServerClient.from("leaflets_in_publications").insert({
25
publication: publication_uri,
26
leaflet: leaflet_id,
···
11
) {
12
let identity = await getIdentityData();
13
if (!identity || !identity.atp_did) return null;
14
let { data: publication } = await supabaseServerClient
15
.from("publications")
16
.select("*")
···
18
.single();
19
if (publication?.identity_did !== identity.atp_did) return;
20
21
await supabaseServerClient.from("leaflets_in_publications").insert({
22
publication: publication_uri,
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
import { scanIndexLocal } from "src/replicache/utils";
33
import type { Fact } from "src/replicache";
34
import type { Attribute } from "src/replicache/attributes";
35
-
import {
36
-
Delta,
37
-
YJSFragmentToString,
38
-
} from "components/Blocks/TextBlock/RenderYJSFragment";
39
import { ids } from "lexicons/api/lexicons";
40
import { BlobRef } from "@atproto/lexicon";
41
import { AtUri } from "@atproto/syntax";
···
50
ColorToRGBA,
51
} from "components/ThemeManager/colorToLexicons";
52
import { parseColor } from "@react-stately/color";
53
54
export async function publishToPublication({
55
root_entity,
···
57
leaflet_id,
58
title,
59
description,
60
entitiesToDelete,
61
}: {
62
root_entity: string;
···
64
leaflet_id: string;
65
title?: string;
66
description?: string;
67
entitiesToDelete?: string[];
68
}) {
69
const oauthClient = await createOauthClient();
···
143
...(theme && { theme }),
144
title: title || "Untitled",
145
description: description || "",
146
pages: pages.map((p) => {
147
if (p.type === "canvas") {
148
return {
···
210
}
211
}
212
213
return { rkey, record: JSON.parse(JSON.stringify(record)) };
214
}
215
···
298
if (!b) return [];
299
let block: PubLeafletPagesLinearDocument.Block = {
300
$type: "pub.leaflet.pages.linearDocument#block",
301
-
alignment,
302
block: b,
303
};
304
return [block];
305
} else {
306
let block: PubLeafletPagesLinearDocument.Block = {
···
342
Y.applyUpdate(doc, update);
343
let nodes = doc.getXmlElement("prosemirror").toArray();
344
let stringValue = YJSFragmentToString(nodes[0]);
345
-
let facets = YJSFragmentToFacets(nodes[0]);
346
return [stringValue, facets] as const;
347
};
348
if (b.type === "card") {
···
398
let [stringValue, facets] = getBlockContent(b.value);
399
let block: $Typed<PubLeafletBlocksHeader.Main> = {
400
$type: "pub.leaflet.blocks.header",
401
-
level: headingLevel?.data.value || 1,
402
plaintext: stringValue,
403
facets,
404
};
···
431
let block: $Typed<PubLeafletBlocksIframe.Main> = {
432
$type: "pub.leaflet.blocks.iframe",
433
url: url.data.value,
434
-
height: height?.data.value || 600,
435
};
436
return block;
437
}
···
445
$type: "pub.leaflet.blocks.image",
446
image: blobref,
447
aspectRatio: {
448
-
height: image.data.height,
449
-
width: image.data.width,
450
},
451
alt: altText ? altText.data.value : undefined,
452
};
···
603
604
function YJSFragmentToFacets(
605
node: Y.XmlElement | Y.XmlText | Y.XmlHook,
606
-
): PubLeafletRichtextFacet.Main[] {
607
if (node.constructor === Y.XmlElement) {
608
-
return node
609
-
.toArray()
610
-
.map((f) => YJSFragmentToFacets(f))
611
-
.flat();
612
}
613
if (node.constructor === Y.XmlText) {
614
let facets: PubLeafletRichtextFacet.Main[] = [];
615
let delta = node.toDelta() as Delta[];
616
-
let byteStart = 0;
617
for (let d of delta) {
618
let unicodestring = new UnicodeString(d.insert);
619
let facet: PubLeafletRichtextFacet.Main = {
···
646
});
647
if (facet.features.length > 0) facets.push(facet);
648
byteStart += unicodestring.length;
649
}
650
-
return facets;
651
}
652
-
return [];
653
}
654
655
type ExcludeString<T> = T extends string
···
712
image: blob.data.blob,
713
repeat: backgroundImageRepeat?.data.value ? true : false,
714
...(backgroundImageRepeat?.data.value && {
715
-
width: backgroundImageRepeat.data.value,
716
}),
717
};
718
}
···
725
726
return undefined;
727
}
···
32
import { scanIndexLocal } from "src/replicache/utils";
33
import type { Fact } from "src/replicache";
34
import type { Attribute } from "src/replicache/attributes";
35
+
import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString";
36
import { ids } from "lexicons/api/lexicons";
37
import { BlobRef } from "@atproto/lexicon";
38
import { AtUri } from "@atproto/syntax";
···
47
ColorToRGBA,
48
} from "components/ThemeManager/colorToLexicons";
49
import { parseColor } from "@react-stately/color";
50
+
import { Notification, pingIdentityToUpdateNotification } from "src/notifications";
51
+
import { v7 } from "uuid";
52
53
export async function publishToPublication({
54
root_entity,
···
56
leaflet_id,
57
title,
58
description,
59
+
tags,
60
entitiesToDelete,
61
}: {
62
root_entity: string;
···
64
leaflet_id: string;
65
title?: string;
66
description?: string;
67
+
tags?: string[];
68
entitiesToDelete?: string[];
69
}) {
70
const oauthClient = await createOauthClient();
···
144
...(theme && { theme }),
145
title: title || "Untitled",
146
description: description || "",
147
+
...(tags !== undefined && { tags }), // Include tags if provided (even if empty array to clear tags)
148
pages: pages.map((p) => {
149
if (p.type === "canvas") {
150
return {
···
212
}
213
}
214
215
+
// Create notifications for mentions (only on first publish)
216
+
if (!existingDocUri) {
217
+
await createMentionNotifications(result.uri, record, credentialSession.did!);
218
+
}
219
+
220
return { rkey, record: JSON.parse(JSON.stringify(record)) };
221
}
222
···
305
if (!b) return [];
306
let block: PubLeafletPagesLinearDocument.Block = {
307
$type: "pub.leaflet.pages.linearDocument#block",
308
block: b,
309
};
310
+
if (alignment) block.alignment = alignment;
311
return [block];
312
} else {
313
let block: PubLeafletPagesLinearDocument.Block = {
···
349
Y.applyUpdate(doc, update);
350
let nodes = doc.getXmlElement("prosemirror").toArray();
351
let stringValue = YJSFragmentToString(nodes[0]);
352
+
let { facets } = YJSFragmentToFacets(nodes[0]);
353
return [stringValue, facets] as const;
354
};
355
if (b.type === "card") {
···
405
let [stringValue, facets] = getBlockContent(b.value);
406
let block: $Typed<PubLeafletBlocksHeader.Main> = {
407
$type: "pub.leaflet.blocks.header",
408
+
level: Math.floor(headingLevel?.data.value || 1),
409
plaintext: stringValue,
410
facets,
411
};
···
438
let block: $Typed<PubLeafletBlocksIframe.Main> = {
439
$type: "pub.leaflet.blocks.iframe",
440
url: url.data.value,
441
+
height: Math.floor(height?.data.value || 600),
442
};
443
return block;
444
}
···
452
$type: "pub.leaflet.blocks.image",
453
image: blobref,
454
aspectRatio: {
455
+
height: Math.floor(image.data.height),
456
+
width: Math.floor(image.data.width),
457
},
458
alt: altText ? altText.data.value : undefined,
459
};
···
610
611
function YJSFragmentToFacets(
612
node: Y.XmlElement | Y.XmlText | Y.XmlHook,
613
+
byteOffset: number = 0,
614
+
): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } {
615
if (node.constructor === Y.XmlElement) {
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 };
667
}
668
+
669
if (node.constructor === Y.XmlText) {
670
let facets: PubLeafletRichtextFacet.Main[] = [];
671
let delta = node.toDelta() as Delta[];
672
+
let byteStart = byteOffset;
673
+
let totalLength = 0;
674
for (let d of delta) {
675
let unicodestring = new UnicodeString(d.insert);
676
let facet: PubLeafletRichtextFacet.Main = {
···
703
});
704
if (facet.features.length > 0) facets.push(facet);
705
byteStart += unicodestring.length;
706
+
totalLength += unicodestring.length;
707
}
708
+
return { facets, byteLength: totalLength };
709
}
710
+
return { facets: [], byteLength: 0 };
711
}
712
713
type ExcludeString<T> = T extends string
···
770
image: blob.data.blob,
771
repeat: backgroundImageRepeat?.data.value ? true : false,
772
...(backgroundImageRepeat?.data.value && {
773
+
width: Math.floor(backgroundImageRepeat.data.value),
774
}),
775
};
776
}
···
783
784
return undefined;
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
import type { Attribute } from "src/replicache/attributes";
12
import { Database } from "supabase/database.types";
13
import * as Y from "yjs";
14
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
15
import { pool } from "supabase/pool";
16
17
let supabase = createServerClient<Database>(
···
11
import type { Attribute } from "src/replicache/attributes";
12
import { Database } from "supabase/database.types";
13
import * as Y from "yjs";
14
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
15
import { pool } from "supabase/pool";
16
17
let supabase = createServerClient<Database>(
+1
app/(home-pages)/discover/PubListing.tsx
+1
app/(home-pages)/discover/PubListing.tsx
···
1
"use client";
2
import { AtUri } from "@atproto/syntax";
3
import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions";
4
import { PubIcon } from "components/ActionBar/Publications";
5
import { Separator } from "components/Layout";
6
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
···
1
"use client";
2
import { AtUri } from "@atproto/syntax";
3
import { PublicationSubscription } from "app/(home-pages)/reader/getSubscriptions";
4
+
import { SubscribeWithBluesky } from "app/lish/Subscribe";
5
import { PubIcon } from "components/ActionBar/Publications";
6
import { Separator } from "components/Layout";
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
"use client";
2
3
-
import { Action } from "@vercel/sdk/esm/models/userevent";
4
import { createNewLeaflet } from "actions/createNewLeaflet";
5
import { ActionButton } from "components/ActionBar/ActionButton";
6
import { AddTiny } from "components/Icons/AddTiny";
7
-
import { ArrowDownTiny } from "components/Icons/ArrowDownTiny";
8
import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";
9
import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
10
-
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
11
-
import { Menu, MenuItem, Separator } from "components/Layout";
12
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
20
export const CreateNewLeafletButton = (props: {}) => {
21
let isMobile = useIsMobile();
···
27
}
28
};
29
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
<Menu
65
asChild
66
side={isMobile ? "top" : "right"}
67
align={isMobile ? "center" : "start"}
68
-
className="py-2"
69
trigger={
70
<ActionButton
71
-
id="new-leaflet-more-options"
72
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!"
75
/>
76
}
77
>
78
<MenuItem
79
-
className="leading-snug"
80
onSelect={async () => {
81
let id = await createNewLeaflet({
82
pageType: "doc",
···
85
openNewLeaflet(id);
86
}}
87
>
88
-
<BlockDocPageSmall />
89
<div className="flex flex-col">
90
-
<div>Doc</div>
91
<div className="text-tertiary text-sm font-normal">
92
A good ol' text document
93
</div>
94
</div>
95
</MenuItem>
96
<MenuItem
97
-
className="leading-snug"
98
onSelect={async () => {
99
let id = await createNewLeaflet({
100
pageType: "canvas",
···
105
>
106
<BlockCanvasPageSmall />
107
<div className="flex flex-col">
108
-
Canvas
109
<div className="text-tertiary text-sm font-normal">
110
A digital whiteboard
111
</div>
112
</div>
113
</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
</Menu>
154
);
155
};
···
1
"use client";
2
3
import { createNewLeaflet } from "actions/createNewLeaflet";
4
import { ActionButton } from "components/ActionBar/ActionButton";
5
import { AddTiny } from "components/Icons/AddTiny";
6
import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";
7
import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
8
+
import { Menu, MenuItem } from "components/Layout";
9
import { useIsMobile } from "src/hooks/isMobile";
10
11
export const CreateNewLeafletButton = (props: {}) => {
12
let isMobile = useIsMobile();
···
18
}
19
};
20
return (
21
<Menu
22
asChild
23
side={isMobile ? "top" : "right"}
24
align={isMobile ? "center" : "start"}
25
trigger={
26
<ActionButton
27
+
id="new-leaflet-button"
28
primary
29
+
icon=<AddTiny className="m-1 shrink-0" />
30
+
label="New"
31
/>
32
}
33
>
34
<MenuItem
35
onSelect={async () => {
36
let id = await createNewLeaflet({
37
pageType: "doc",
···
40
openNewLeaflet(id);
41
}}
42
>
43
+
<BlockDocPageSmall />{" "}
44
<div className="flex flex-col">
45
+
<div>New Doc</div>
46
<div className="text-tertiary text-sm font-normal">
47
A good ol' text document
48
</div>
49
</div>
50
</MenuItem>
51
<MenuItem
52
onSelect={async () => {
53
let id = await createNewLeaflet({
54
pageType: "canvas",
···
59
>
60
<BlockCanvasPageSmall />
61
<div className="flex flex-col">
62
+
New Canvas
63
<div className="text-tertiary text-sm font-normal">
64
A digital whiteboard
65
</div>
66
</div>
67
</MenuItem>
68
</Menu>
69
);
70
};
+10
-14
app/(home-pages)/home/HomeLayout.tsx
+10
-14
app/(home-pages)/home/HomeLayout.tsx
···
29
HomeEmptyState,
30
PublicationBanner,
31
} from "./HomeEmpty/HomeEmpty";
32
-
import { EmptyState } from "components/EmptyState";
33
34
export type Leaflet = {
35
added_at: string;
···
136
(acc, tok) => {
137
let title =
138
tok.permission_tokens.leaflets_in_publications[0]?.title ||
139
-
tok.permission_tokens.leaflets_to_documents?.title;
140
if (title) acc[tok.permission_tokens.root_entity] = title;
141
return acc;
142
},
···
212
className={`
213
leafletList
214
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 "} `}
216
>
217
-
{searchedLeaflets.length === 0 && (
218
-
<EmptyState>
219
-
<div className="italic">Oh no! No results!</div>
220
-
</EmptyState>
221
-
)}
222
{props.leaflets.map(({ token: leaflet, added_at, archived }, index) => (
223
<ReplicacheProvider
224
disablePull
···
233
value={{
234
...leaflet,
235
leaflets_in_publications: leaflet.leaflets_in_publications || [],
236
-
leaflets_to_documents: leaflet.leaflets_to_documents || null,
237
blocked_by_admin: null,
238
custom_domain_routes: [],
239
}}
···
292
({ token: leaflet, archived: archived }) => {
293
let published =
294
!!leaflet.leaflets_in_publications?.find((l) => l.doc) ||
295
-
!!leaflet.leaflets_to_documents?.document;
296
let drafts = !!leaflet.leaflets_in_publications?.length && !published;
297
let docs = !leaflet.leaflets_in_publications?.length && !archived;
298
-
// If no filters are active, show all
299
if (
300
!filter.drafts &&
301
!filter.published &&
···
304
)
305
return archived === false || archived === null || archived == undefined;
306
307
return (
308
-
(filter.drafts && drafts) ||
309
-
(filter.published && published) ||
310
-
(filter.docs && docs) ||
311
(filter.archived && archived)
312
);
313
},
···
29
HomeEmptyState,
30
PublicationBanner,
31
} from "./HomeEmpty/HomeEmpty";
32
33
export type Leaflet = {
34
added_at: string;
···
135
(acc, tok) => {
136
let title =
137
tok.permission_tokens.leaflets_in_publications[0]?.title ||
138
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
139
if (title) acc[tok.permission_tokens.root_entity] = title;
140
return acc;
141
},
···
211
className={`
212
leafletList
213
w-full
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"} `}
215
>
216
{props.leaflets.map(({ token: leaflet, added_at, archived }, index) => (
217
<ReplicacheProvider
218
disablePull
···
227
value={{
228
...leaflet,
229
leaflets_in_publications: leaflet.leaflets_in_publications || [],
230
+
leaflets_to_documents: leaflet.leaflets_to_documents || [],
231
blocked_by_admin: null,
232
custom_domain_routes: [],
233
}}
···
286
({ token: leaflet, archived: archived }) => {
287
let published =
288
!!leaflet.leaflets_in_publications?.find((l) => l.doc) ||
289
+
!!leaflet.leaflets_to_documents?.find((l) => l.document);
290
let drafts = !!leaflet.leaflets_in_publications?.length && !published;
291
let docs = !leaflet.leaflets_in_publications?.length && !archived;
292
+
293
+
// If no filters are active, show everything that is not archived
294
if (
295
!filter.drafts &&
296
!filter.published &&
···
299
)
300
return archived === false || archived === null || archived == undefined;
301
302
+
//if a filter is on, return itemsd of that filter that are also NOT archived
303
return (
304
+
(filter.drafts && drafts && !archived) ||
305
+
(filter.published && published && !archived) ||
306
+
(filter.docs && docs && !archived) ||
307
(filter.archived && archived)
308
);
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
+5
-50
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
+5
-50
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
···
1
"use client";
2
-
import {
3
-
DashboardLayout,
4
-
PublicationDashboardControls,
5
-
} from "components/PageLayouts/DashboardLayout";
6
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
import { useState } from "react";
8
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
···
14
import useSWR from "swr";
15
import { getHomeDocs } from "../home/storage";
16
import { Leaflet, LeafletList } from "../home/HomeLayout";
17
-
import { EmptyState } from "components/EmptyState";
18
-
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
19
20
export const LooseleafsLayout = (props: {
21
entityID: string | null;
···
41
id="looseleafs"
42
cardBorderHidden={cardBorderHidden}
43
currentPage="looseleafs"
44
-
defaultTab="Drafts"
45
actions={<Actions />}
46
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: {
59
controls: null,
60
content: (
61
<LooseleafList
···
71
);
72
};
73
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
export const LooseleafList = (props: {
91
titles: { [root_entity: string]: string };
92
initialFacts: {
···
111
(acc, tok) => {
112
let title =
113
tok.permission_tokens.leaflets_in_publications[0]?.title ||
114
-
tok.permission_tokens.leaflets_to_documents?.title;
115
if (title) acc[tok.permission_tokens.root_entity] = title;
116
return acc;
117
},
···
127
let leaflets: Leaflet[] = identity
128
? identity.permission_token_on_homepage
129
.filter(
130
-
(ptoh) =>
131
-
ptoh.permission_tokens.leaflets_to_documents &&
132
-
ptoh.permission_tokens.leaflets_to_documents.document,
133
)
134
.map((ptoh) => ({
135
added_at: ptoh.created_at,
136
token: ptoh.permission_tokens as PermissionToken,
137
}))
138
: [];
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
return (
151
<LeafletList
152
defaultDisplay="list"
···
1
"use client";
2
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
3
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
4
import { useState } from "react";
5
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
···
11
import useSWR from "swr";
12
import { getHomeDocs } from "../home/storage";
13
import { Leaflet, LeafletList } from "../home/HomeLayout";
14
15
export const LooseleafsLayout = (props: {
16
entityID: string | null;
···
36
id="looseleafs"
37
cardBorderHidden={cardBorderHidden}
38
currentPage="looseleafs"
39
+
defaultTab="home"
40
actions={<Actions />}
41
tabs={{
42
+
home: {
43
controls: null,
44
content: (
45
<LooseleafList
···
55
);
56
};
57
58
export const LooseleafList = (props: {
59
titles: { [root_entity: string]: string };
60
initialFacts: {
···
79
(acc, tok) => {
80
let title =
81
tok.permission_tokens.leaflets_in_publications[0]?.title ||
82
+
tok.permission_tokens.leaflets_to_documents[0]?.title;
83
if (title) acc[tok.permission_tokens.root_entity] = title;
84
return acc;
85
},
···
95
let leaflets: Leaflet[] = identity
96
? identity.permission_token_on_homepage
97
.filter(
98
+
(ptoh) => ptoh.permission_tokens.leaflets_to_documents.length > 0,
99
)
100
.map((ptoh) => ({
101
added_at: ptoh.created_at,
102
token: ptoh.permission_tokens as PermissionToken,
103
}))
104
: [];
105
return (
106
<LeafletList
107
defaultDisplay="list"
+1
-1
app/(home-pages)/looseleafs/page.tsx
+1
-1
app/(home-pages)/looseleafs/page.tsx
+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";
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
/>
···
1
+
import { MentionTiny } from "components/Icons/MentionTiny";
2
import { ContentLayout, Notification } from "./Notification";
3
+
import { HydratedMentionNotification } from "src/notifications";
4
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
5
+
import { Agent, AtUri } from "@atproto/api";
6
7
+
export const MentionNotification = (props: HydratedMentionNotification) => {
8
const docRecord = props.document.data as PubLeafletDocument.Record;
9
+
const pubRecord = props.document.documents_in_publications?.[0]?.publications
10
?.record as PubLeafletPublication.Record | undefined;
11
const docUri = new AtUri(props.document.uri);
12
const rkey = docUri.rkey;
13
const did = docUri.host;
14
15
const href = pubRecord
16
? `https://${pubRecord.base_path}/${rkey}`
17
: `/p/${did}/${rkey}`;
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
+
55
return (
56
<Notification
57
timestamp={props.created_at}
58
href={href}
59
+
icon={<MentionTiny />}
60
+
actionText={actionText}
61
content={
62
<ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}>
63
+
{docRecord.description && docRecord.description}
64
</ContentLayout>
65
}
66
/>
+3
-3
app/(home-pages)/notifications/Notification.tsx
+3
-3
app/(home-pages)/notifications/Notification.tsx
···
69
<div
70
className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`}
71
>
72
-
<div className="text-tertiary text-sm italic font-bold pb-1">
73
{props.postTitle}
74
</div>
75
-
{props.children}
76
{props.pubRecord && (
77
<>
78
-
<hr className="mt-3 mb-1 border-border-light" />
79
<a
80
href={`https://${props.pubRecord.base_path}`}
81
className="relative text-xs text-tertiary flex gap-[6px] items-center font-bold hover:no-underline!"
···
69
<div
70
className={`border border-border-light rounded-md px-2 py-[6px] w-full ${cardBorderHidden ? "transparent" : "bg-bg-page"}`}
71
>
72
+
<div className="text-tertiary text-sm italic font-bold ">
73
{props.postTitle}
74
</div>
75
+
{props.children && <div className="mb-2 text-sm">{props.children}</div>}
76
{props.pubRecord && (
77
<>
78
+
<hr className="mt-1 mb-1 border-border-light" />
79
<a
80
href={`https://${props.pubRecord.base_path}`}
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
import { ReplyNotification } from "./ReplyNotification";
8
import { useIdentityData } from "components/IdentityProvider";
9
import { FollowNotification } from "./FollowNotification";
10
-
import { QuoteNotification } from "./MentionNotification";
11
12
export function NotificationList({
13
notifications,
···
45
}
46
if (n.type === "quote") {
47
return <QuoteNotification key={n.id} {...n} />;
48
}
49
})}
50
</div>
···
7
import { ReplyNotification } from "./ReplyNotification";
8
import { useIdentityData } from "components/IdentityProvider";
9
import { FollowNotification } from "./FollowNotification";
10
+
import { QuoteNotification } from "./QuoteNotification";
11
+
import { MentionNotification } from "./MentionNotification";
12
+
import { CommentMentionNotification } from "./CommentMentionNotification";
13
14
export function NotificationList({
15
notifications,
···
47
}
48
if (n.type === "quote") {
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} />;
56
}
57
})}
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
"use client";
2
-
import { AtUri } from "@atproto/api";
3
-
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4
-
import { PubIcon } from "components/ActionBar/Publications";
5
import { ButtonPrimary } from "components/Buttons";
6
-
import { CommentTiny } from "components/Icons/CommentTiny";
7
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
import type { Cursor, Post } from "./getReaderFeed";
18
import useSWRInfinite from "swr/infinite";
19
import { getReaderFeed } from "./getReaderFeed";
20
import { useEffect, useRef } from "react";
21
-
import { useRouter } from "next/navigation";
22
import Link from "next/link";
23
-
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
24
-
import { EmptyState } from "components/EmptyState";
25
26
export const ReaderContent = (props: {
27
posts: Post[];
···
29
}) => {
30
const getKey = (
31
pageIndex: number,
32
-
previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null,
33
) => {
34
// Reached the end
35
if (previousPageData && !previousPageData.nextCursor) return null;
···
41
return ["reader-feed", previousPageData?.nextCursor] as const;
42
};
43
44
-
const { data, error, size, setSize, isValidating } = useSWRInfinite(
45
getKey,
46
([_, cursor]) => getReaderFeed(cursor),
47
{
···
80
return (
81
<div className="flex flex-col gap-3 relative">
82
{allPosts.map((p) => (
83
-
<Post {...p} key={p.documents.uri} />
84
))}
85
{/* Trigger element for loading more posts */}
86
<div
···
97
);
98
};
99
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
export const ReaderEmpty = () => {
275
return (
276
-
<EmptyState>
277
Nothing to read yetโฆ <br />
278
Subscribe to publications and find their posts here!
279
<Link href={"/discover"}>
···
281
<DiscoverSmall /> Discover Publications
282
</ButtonPrimary>
283
</Link>
284
-
</EmptyState>
285
);
286
};
···
1
"use client";
2
import { ButtonPrimary } from "components/Buttons";
3
import { DiscoverSmall } from "components/Icons/DiscoverSmall";
4
import type { Cursor, Post } from "./getReaderFeed";
5
import useSWRInfinite from "swr/infinite";
6
import { getReaderFeed } from "./getReaderFeed";
7
import { useEffect, useRef } from "react";
8
import Link from "next/link";
9
+
import { PostListing } from "components/PostListing";
10
11
export const ReaderContent = (props: {
12
posts: Post[];
···
14
}) => {
15
const getKey = (
16
pageIndex: number,
17
+
previousPageData: {
18
+
posts: Post[];
19
+
nextCursor: Cursor | null;
20
+
} | null,
21
) => {
22
// Reached the end
23
if (previousPageData && !previousPageData.nextCursor) return null;
···
29
return ["reader-feed", previousPageData?.nextCursor] as const;
30
};
31
32
+
const { data, size, setSize, isValidating } = useSWRInfinite(
33
getKey,
34
([_, cursor]) => getReaderFeed(cursor),
35
{
···
68
return (
69
<div className="flex flex-col gap-3 relative">
70
{allPosts.map((p) => (
71
+
<PostListing {...p} key={p.documents.uri} />
72
))}
73
{/* Trigger element for loading more posts */}
74
<div
···
85
);
86
};
87
88
export const ReaderEmpty = () => {
89
return (
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">
91
Nothing to read yetโฆ <br />
92
Subscribe to publications and find their posts here!
93
<Link href={"/discover"}>
···
95
<DiscoverSmall /> Discover Publications
96
</ButtonPrimary>
97
</Link>
98
+
</div>
99
);
100
};
+2
-3
app/(home-pages)/reader/SubscriptionsContent.tsx
+2
-3
app/(home-pages)/reader/SubscriptionsContent.tsx
···
8
import { useEffect, useRef } from "react";
9
import { Cursor } from "./getReaderFeed";
10
import Link from "next/link";
11
-
import { EmptyState } from "components/EmptyState";
12
13
export const SubscriptionsContent = (props: {
14
publications: PublicationSubscription[];
···
94
95
export const SubscriptionsEmpty = () => {
96
return (
97
-
<EmptyState>
98
You haven't subscribed to any publications yet!
99
<Link href={"/discover"}>
100
<ButtonPrimary className="mx-auto place-self-center">
101
<DiscoverSmall /> Discover Publications
102
</ButtonPrimary>
103
</Link>
104
-
</EmptyState>
105
);
106
};
···
8
import { useEffect, useRef } from "react";
9
import { Cursor } from "./getReaderFeed";
10
import Link from "next/link";
11
12
export const SubscriptionsContent = (props: {
13
publications: PublicationSubscription[];
···
93
94
export const SubscriptionsEmpty = () => {
95
return (
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">
97
You haven't subscribed to any publications yet!
98
<Link href={"/discover"}>
99
<ButtonPrimary className="mx-auto place-self-center">
100
<DiscoverSmall /> Discover Publications
101
</ButtonPrimary>
102
</Link>
103
+
</div>
104
);
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
import { useState, useMemo } from "react";
28
import { useIsMobile } from "src/hooks/isMobile";
29
import { useReplicache, useEntity } from "src/replicache";
30
import { Json } from "supabase/database.types";
31
import {
32
useBlocks,
···
34
} from "src/hooks/queries/useBlocks";
35
import * as Y from "yjs";
36
import * as base64 from "base64-js";
37
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
38
import { BlueskyLogin } from "app/login/LoginForm";
39
import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
40
-
import { saveLeafletDraft } from "actions/publications/saveLeafletDraft";
41
import { AddTiny } from "components/Icons/AddTiny";
42
43
export const PublishButton = (props: { entityID: string }) => {
···
64
const UpdateButton = () => {
65
let [isLoading, setIsLoading] = useState(false);
66
let { data: pub, mutate } = useLeafletPublicationData();
67
-
let { permission_token, rootEntity } = useReplicache();
68
let { identity } = useIdentityData();
69
let toaster = useToaster();
70
71
return (
72
<ActionButton
···
82
leaflet_id: permission_token.id,
83
title: pub.title,
84
description: pub.description,
85
});
86
setIsLoading(false);
87
mutate();
···
109
let { identity } = useIdentityData();
110
let { permission_token } = useReplicache();
111
let query = useSearchParams();
112
-
console.log(query.get("publish"));
113
let [open, setOpen] = useState(query.get("publish") !== null);
114
115
let isMobile = useIsMobile();
···
177
<hr className="border-border-light mt-3 mb-2" />
178
179
<div className="flex gap-2 items-center place-self-end">
180
-
{selectedPub && selectedPub !== "create" && (
181
<SaveAsDraftButton
182
selectedPub={selectedPub}
183
leafletId={permission_token.id}
···
230
if (props.selectedPub === "create") return;
231
e.preventDefault();
232
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
-
250
await Promise.all([rep?.pull(), mutate()]);
251
setIsLoading(false);
252
}}
···
27
import { useState, useMemo } from "react";
28
import { useIsMobile } from "src/hooks/isMobile";
29
import { useReplicache, useEntity } from "src/replicache";
30
+
import { useSubscribe } from "src/replicache/useSubscribe";
31
import { Json } from "supabase/database.types";
32
import {
33
useBlocks,
···
35
} from "src/hooks/queries/useBlocks";
36
import * as Y from "yjs";
37
import * as base64 from "base64-js";
38
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
39
import { BlueskyLogin } from "app/login/LoginForm";
40
import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication";
41
import { AddTiny } from "components/Icons/AddTiny";
42
43
export const PublishButton = (props: { entityID: string }) => {
···
64
const UpdateButton = () => {
65
let [isLoading, setIsLoading] = useState(false);
66
let { data: pub, mutate } = useLeafletPublicationData();
67
+
let { permission_token, rootEntity, rep } = useReplicache();
68
let { identity } = useIdentityData();
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 : [];
74
75
return (
76
<ActionButton
···
86
leaflet_id: permission_token.id,
87
title: pub.title,
88
description: pub.description,
89
+
tags: currentTags,
90
});
91
setIsLoading(false);
92
mutate();
···
114
let { identity } = useIdentityData();
115
let { permission_token } = useReplicache();
116
let query = useSearchParams();
117
let [open, setOpen] = useState(query.get("publish") !== null);
118
119
let isMobile = useIsMobile();
···
181
<hr className="border-border-light mt-3 mb-2" />
182
183
<div className="flex gap-2 items-center place-self-end">
184
+
{selectedPub !== "looseleaf" && selectedPub && (
185
<SaveAsDraftButton
186
selectedPub={selectedPub}
187
leafletId={permission_token.id}
···
234
if (props.selectedPub === "create") return;
235
e.preventDefault();
236
setIsLoading(true);
237
+
await moveLeafletToPublication(
238
+
props.leafletId,
239
+
props.selectedPub,
240
+
props.metadata,
241
+
props.entitiesToDelete,
242
+
);
243
await Promise.all([rep?.pull(), mutate()]);
244
setIsLoading(false);
245
}}
+1
-1
app/[leaflet_id]/page.tsx
+1
-1
app/[leaflet_id]/page.tsx
···
4
5
import type { Fact } from "src/replicache";
6
import type { Attribute } from "src/replicache/attributes";
7
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
8
import { Leaflet } from "./Leaflet";
9
import { scanIndexLocal } from "src/replicache/utils";
10
import { getRSVPData } from "actions/getRSVPData";
···
4
5
import type { Fact } from "src/replicache";
6
import type { Attribute } from "src/replicache/attributes";
7
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
8
import { Leaflet } from "./Leaflet";
9
import { scanIndexLocal } from "src/replicache/utils";
10
import { getRSVPData } from "actions/getRSVPData";
+144
-294
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
+144
-294
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
···
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";
14
import { EditorView } from "prosemirror-view";
15
import { Schema, MarkSpec, Mark } from "prosemirror-model";
16
import { baseKeymap } from "prosemirror-commands";
···
19
import { inputRules, InputRule } from "prosemirror-inputrules";
20
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
21
import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox";
22
23
// Schema with only links, mentions, and hashtags marks
24
const bskyPostSchema = new Schema({
···
134
return tr;
135
});
136
}
137
-
138
export function BlueskyPostEditorProsemirror(props: {
139
-
editorStateRef: React.MutableRefObject<EditorState | null>;
140
initialContent?: string;
141
onCharCountChange?: (count: number) => void;
142
}) {
143
const mountRef = useRef<HTMLDivElement | null>(null);
144
const viewRef = useRef<EditorView | null>(null);
145
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 });
151
152
const handleMentionSelect = useCallback(
153
-
(
154
-
mention: { handle: string; did: string },
155
-
range: { from: number; to: number },
156
-
) => {
157
-
if (!viewRef.current) return;
158
const view = viewRef.current;
159
-
const { from, to } = range;
160
const tr = view.state.tr;
161
162
-
// Delete the query text (keep the @)
163
-
tr.delete(from + 1, to);
164
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);
178
179
view.dispatch(tr);
180
view.focus();
181
},
182
-
[],
183
);
184
185
-
const mentionStateRef = useRef(mentionState);
186
-
mentionStateRef.current = mentionState;
187
188
useLayoutEffect(() => {
189
if (!mountRef.current) return;
190
191
const initialState = EditorState.create({
192
schema: bskyPostSchema,
193
doc: props.initialContent
···
200
})
201
: undefined,
202
plugins: [
203
-
inputRules({ rules: [createHashtagInputRule()] }),
204
keymap({
205
"Mod-z": undo,
206
"Mod-y": redo,
207
"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
}),
226
keymap(baseKeymap),
227
autolink({
···
258
view.destroy();
259
viewRef.current = null;
260
};
261
-
}, [handleMentionSelect]);
262
263
return (
264
<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
-
)}
275
{editorState?.doc.textContent.length === 0 && (
276
<div className="italic text-tertiary absolute top-0 left-0 pointer-events-none">
277
Write a post to share your writing!
···
279
)}
280
<div
281
ref={mountRef}
282
-
className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm"
283
style={{
284
wordWrap: "break-word",
285
overflowWrap: "break-word",
···
290
);
291
}
292
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
/**
515
* Converts a ProseMirror editor state to Bluesky post facets.
516
* Extracts mentions, links, and hashtags from the editor state and returns them
···
595
596
return features;
597
}
···
1
"use client";
2
+
import { AppBskyRichtextFacet, UnicodeString } from "@atproto/api";
3
+
import { useState, useCallback, useRef, useLayoutEffect } from "react";
4
+
import { EditorState } from "prosemirror-state";
5
import { EditorView } from "prosemirror-view";
6
import { Schema, MarkSpec, Mark } from "prosemirror-model";
7
import { baseKeymap } from "prosemirror-commands";
···
10
import { inputRules, InputRule } from "prosemirror-inputrules";
11
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
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";
15
16
// Schema with only links, mentions, and hashtags marks
17
const bskyPostSchema = new Schema({
···
127
return tr;
128
});
129
}
130
export function BlueskyPostEditorProsemirror(props: {
131
+
editorStateRef: React.RefObject<EditorState | null>;
132
initialContent?: string;
133
onCharCountChange?: (count: number) => void;
134
}) {
135
const mountRef = useRef<HTMLDivElement | null>(null);
136
const viewRef = useRef<EditorView | null>(null);
137
const [editorState, setEditorState] = useState<EditorState | null>(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
+
}, []);
170
171
const handleMentionSelect = useCallback(
172
+
(mention: Mention) => {
173
+
if (!viewRef.current || mentionInsertPos === null) return;
174
const view = viewRef.current;
175
+
const from = mentionInsertPos - 1;
176
+
const to = mentionInsertPos;
177
const tr = view.state.tr;
178
179
+
// Delete the @ symbol
180
+
tr.delete(from, to);
181
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
+
}
213
214
view.dispatch(tr);
215
view.focus();
216
},
217
+
[mentionInsertPos],
218
);
219
220
+
const handleMentionOpenChange = useCallback((open: boolean) => {
221
+
setMentionOpen(open);
222
+
if (!open) {
223
+
setMentionCoords(null);
224
+
setMentionInsertPos(null);
225
+
}
226
+
}, []);
227
228
useLayoutEffect(() => {
229
if (!mountRef.current) return;
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
+
240
const initialState = EditorState.create({
241
schema: bskyPostSchema,
242
doc: props.initialContent
···
249
})
250
: undefined,
251
plugins: [
252
+
inputRules({ rules: [createHashtagInputRule(), mentionInputRule] }),
253
keymap({
254
"Mod-z": undo,
255
"Mod-y": redo,
256
"Shift-Mod-z": redo,
257
}),
258
keymap(baseKeymap),
259
autolink({
···
290
view.destroy();
291
viewRef.current = null;
292
};
293
+
}, [openMentionAutocomplete]);
294
295
return (
296
<div className="relative w-full h-full group">
297
+
<MentionAutocomplete
298
+
open={mentionOpen}
299
+
onOpenChange={handleMentionOpenChange}
300
+
view={viewRef}
301
+
onSelect={handleMentionSelect}
302
+
coords={mentionCoords}
303
+
placeholder="Search people..."
304
+
/>
305
{editorState?.doc.textContent.length === 0 && (
306
<div className="italic text-tertiary absolute top-0 left-0 pointer-events-none">
307
Write a post to share your writing!
···
309
)}
310
<div
311
ref={mountRef}
312
+
className="border-none outline-none whitespace-pre-wrap max-h-[240px] overflow-y-auto prose-sm"
313
style={{
314
wordWrap: "break-word",
315
overflowWrap: "break-word",
···
320
);
321
}
322
323
/**
324
* Converts a ProseMirror editor state to Bluesky post facets.
325
* Extracts mentions, links, and hashtags from the editor state and returns them
···
404
405
return features;
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
import { Radio } from "components/Checkbox";
7
import { useParams } from "next/navigation";
8
import Link from "next/link";
9
-
import { AutosizeTextarea } from "components/utils/AutosizeTextarea";
10
import { PubLeafletPublication } from "lexicons/api";
11
import { publishPostToBsky } from "./publishBskyPost";
12
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
13
import { AtUri } from "@atproto/syntax";
14
import { PublishIllustration } from "./PublishIllustration/PublishIllustration";
15
import { useReplicache } from "src/replicache";
16
import {
17
BlueskyPostEditorProsemirror,
18
editorStateToFacetedText,
19
} from "./BskyPostEditorProsemirror";
20
import { EditorState } from "prosemirror-state";
21
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
22
import { PubIcon } from "components/ActionBar/Publications";
23
···
31
record?: PubLeafletPublication.Record;
32
posts_in_pub?: number;
33
entitiesToDelete?: string[];
34
};
35
36
export function PublishPost(props: Props) {
···
38
{ state: "default" } | { state: "success"; post_url: string }
39
>({ state: "default" });
40
return (
41
-
<div className="publishPage w-screen h-full bg-bg-page flex sm:pt-0 pt-4 sm:place-items-center justify-center">
42
{publishState.state === "default" ? (
43
<PublishPostForm setPublishState={setPublishState} {...props} />
44
) : (
···
58
setPublishState: (s: { state: "success"; post_url: string }) => void;
59
} & Props,
60
) => {
61
let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
62
-
let editorStateRef = useRef<EditorState | null>(null);
63
let [isLoading, setIsLoading] = useState(false);
64
-
let [charCount, setCharCount] = useState(0);
65
let params = useParams();
66
let { rep } = useReplicache();
67
68
async function submit() {
69
if (isLoading) return;
70
setIsLoading(true);
···
75
leaflet_id: props.leaflet_id,
76
title: props.title,
77
description: props.description,
78
entitiesToDelete: props.entitiesToDelete,
79
});
80
if (!doc) return;
···
109
submit();
110
}}
111
>
112
-
<div className="container flex flex-col gap-2 sm:p-3 p-4">
113
<PublishingTo
114
publication_uri={props.publication_uri}
115
record={props.record}
116
/>
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>
195
</div>
196
<div className="flex justify-between">
197
<Link
198
className="hover:no-underline! font-bold"
···
210
</div>
211
</div>
212
</form>
213
</div>
214
);
215
};
···
6
import { Radio } from "components/Checkbox";
7
import { useParams } from "next/navigation";
8
import Link from "next/link";
9
+
10
import { PubLeafletPublication } from "lexicons/api";
11
import { publishPostToBsky } from "./publishBskyPost";
12
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
13
import { AtUri } from "@atproto/syntax";
14
import { PublishIllustration } from "./PublishIllustration/PublishIllustration";
15
import { useReplicache } from "src/replicache";
16
+
import { useSubscribe } from "src/replicache/useSubscribe";
17
import {
18
BlueskyPostEditorProsemirror,
19
editorStateToFacetedText,
20
} from "./BskyPostEditorProsemirror";
21
import { EditorState } from "prosemirror-state";
22
+
import { TagSelector } from "../../../components/Tags";
23
import { LooseLeafSmall } from "components/Icons/LooseleafSmall";
24
import { PubIcon } from "components/ActionBar/Publications";
25
···
33
record?: PubLeafletPublication.Record;
34
posts_in_pub?: number;
35
entitiesToDelete?: string[];
36
+
hasDraft: boolean;
37
};
38
39
export function PublishPost(props: Props) {
···
41
{ state: "default" } | { state: "success"; post_url: string }
42
>({ state: "default" });
43
return (
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">
45
{publishState.state === "default" ? (
46
<PublishPostForm setPublishState={setPublishState} {...props} />
47
) : (
···
61
setPublishState: (s: { state: "success"; post_url: string }) => void;
62
} & Props,
63
) => {
64
+
let editorStateRef = useRef<EditorState | null>(null);
65
+
let [charCount, setCharCount] = useState(0);
66
let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky");
67
let [isLoading, setIsLoading] = useState(false);
68
let params = useParams();
69
let { rep } = useReplicache();
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
+
96
async function submit() {
97
if (isLoading) return;
98
setIsLoading(true);
···
103
leaflet_id: props.leaflet_id,
104
title: props.title,
105
description: props.description,
106
+
tags: currentTags,
107
entitiesToDelete: props.entitiesToDelete,
108
});
109
if (!doc) return;
···
138
submit();
139
}}
140
>
141
+
<div className="container flex flex-col gap-3 sm:p-3 p-4">
142
<PublishingTo
143
publication_uri={props.publication_uri}
144
record={props.record}
145
/>
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
+
/>
162
</div>
163
+
<hr className="border-border mb-2" />
164
+
165
<div className="flex justify-between">
166
<Link
167
className="hover:no-underline! font-bold"
···
179
</div>
180
</div>
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>
273
</div>
274
);
275
};
+8
-2
app/[leaflet_id]/publish/page.tsx
+8
-2
app/[leaflet_id]/publish/page.tsx
···
76
// Get title and description from either source
77
let title =
78
data.leaflets_in_publications[0]?.title ||
79
-
data.leaflets_to_documents?.title ||
80
decodeURIComponent((await props.searchParams).title || "");
81
let description =
82
data.leaflets_in_publications[0]?.description ||
83
-
data.leaflets_to_documents?.description ||
84
decodeURIComponent((await props.searchParams).description || "");
85
86
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
···
99
// If parsing fails, just use empty array
100
}
101
102
return (
103
<ReplicacheProvider
104
rootEntity={rootEntity}
···
116
record={publication?.record as PubLeafletPublication.Record | undefined}
117
posts_in_pub={publication?.documents_in_publications[0]?.count}
118
entitiesToDelete={entitiesToDelete}
119
/>
120
</ReplicacheProvider>
121
);
···
76
// Get title and description from either source
77
let title =
78
data.leaflets_in_publications[0]?.title ||
79
+
data.leaflets_to_documents[0]?.title ||
80
decodeURIComponent((await props.searchParams).title || "");
81
let description =
82
data.leaflets_in_publications[0]?.description ||
83
+
data.leaflets_to_documents[0]?.description ||
84
decodeURIComponent((await props.searchParams).description || "");
85
86
let agent = new AtpAgent({ service: "https://public.api.bsky.app" });
···
99
// If parsing fails, just use empty array
100
}
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
+
107
return (
108
<ReplicacheProvider
109
rootEntity={rootEntity}
···
121
record={publication?.record as PubLeafletPublication.Record | undefined}
122
posts_in_pub={publication?.documents_in_publications[0]?.count}
123
entitiesToDelete={entitiesToDelete}
124
+
hasDraft={hasDraft}
125
/>
126
</ReplicacheProvider>
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
import type { Env } from "./route";
6
import { scanIndexLocal } from "src/replicache/utils";
7
import * as base64 from "base64-js";
8
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
9
import { applyUpdate, Doc } from "yjs";
10
11
export const getFactsFromHomeLeaflets = makeRoute({
···
5
import type { Env } from "./route";
6
import { scanIndexLocal } from "src/replicache/utils";
7
import * as base64 from "base64-js";
8
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
9
import { applyUpdate, Doc } from "yjs";
10
11
export const getFactsFromHomeLeaflets = makeRoute({
+6
app/api/rpc/[command]/pull.ts
+6
app/api/rpc/[command]/pull.ts
···
73
let publication_data = data.publications as {
74
description: string;
75
title: string;
76
+
tags: string[];
77
}[];
78
let pub_patch = publication_data?.[0]
79
? [
···
86
op: "put",
87
key: "publication_title",
88
value: publication_data[0].title,
89
+
},
90
+
{
91
+
op: "put",
92
+
key: "publication_tags",
93
+
value: publication_data[0].tags || [],
94
},
95
]
96
: [];
+4
app/api/rpc/[command]/route.ts
+4
app/api/rpc/[command]/route.ts
···
11
} from "./domain_routes";
12
import { get_leaflet_data } from "./get_leaflet_data";
13
import { get_publication_data } from "./get_publication_data";
14
15
let supabase = createClient<Database>(
16
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
35
get_leaflet_subdomain_status,
36
get_leaflet_data,
37
get_publication_data,
38
];
39
export async function POST(
40
req: Request,
···
11
} from "./domain_routes";
12
import { get_leaflet_data } from "./get_leaflet_data";
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";
16
17
let supabase = createClient<Database>(
18
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
37
get_leaflet_subdomain_status,
38
get_leaflet_data,
39
get_publication_data,
40
+
search_publication_names,
41
+
search_publication_documents,
42
];
43
export async function POST(
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
212
/* END GLOBAL STYLING */
213
}
214
button:hover {
215
cursor: pointer;
216
}
···
289
.selected .selection-highlight {
290
background-color: Highlight;
291
@apply py-[1.5px];
292
}
293
294
.ProseMirror:focus-within .selection-highlight {
···
414
outline: none !important;
415
cursor: pointer;
416
background-color: transparent;
417
418
:hover {
419
text-decoration: none !important;
···
211
212
/* END GLOBAL STYLING */
213
}
214
+
215
+
img {
216
+
font-size: 0;
217
+
}
218
+
219
button:hover {
220
cursor: pointer;
221
}
···
294
.selected .selection-highlight {
295
background-color: Highlight;
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;
303
}
304
305
.ProseMirror:focus-within .selection-highlight {
···
425
outline: none !important;
426
cursor: pointer;
427
background-color: transparent;
428
+
display: flex;
429
+
gap: 0.5rem;
430
431
:hover {
432
text-decoration: none !important;
+36
-206
app/lish/Subscribe.tsx
+36
-206
app/lish/Subscribe.tsx
···
23
import { useSearchParams } from "next/navigation";
24
import LoginForm from "app/login/LoginForm";
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
183
export const SubscribeWithBluesky = (props: {
184
-
isPost?: boolean;
185
pubName: string;
186
pub_uri: string;
187
base_url: string;
···
208
}
209
return (
210
<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
<div className="flex flex-row gap-2 place-self-center">
217
<BlueskySubscribeButton
218
pub_uri={props.pub_uri}
···
231
);
232
};
233
234
-
const ManageSubscription = (props: {
235
-
isPost?: boolean;
236
-
pubName: string;
237
pub_uri: string;
238
subscribers: { identity: string }[];
239
base_url: string;
···
248
});
249
}, null);
250
return (
251
-
<div
252
-
className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`}
253
>
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>
267
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
-
280
<a
281
-
href={`${props.base_url}/rss`}
282
-
className="flex"
283
target="_blank"
284
-
aria-label="Subscribe to RSS"
285
>
286
-
<ButtonPrimary fullWidth compact>
287
-
Get RSS
288
</ButtonPrimary>
289
</a>
290
291
-
<hr className="border-border-light my-1" />
292
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>
301
);
302
};
303
···
430
</Dialog.Root>
431
);
432
};
···
23
import { useSearchParams } from "next/navigation";
24
import LoginForm from "app/login/LoginForm";
25
import { RSSSmall } from "components/Icons/RSSSmall";
26
27
export const SubscribeWithBluesky = (props: {
28
pubName: string;
29
pub_uri: string;
30
base_url: string;
···
51
}
52
return (
53
<div className="flex flex-col gap-2 text-center justify-center">
54
<div className="flex flex-row gap-2 place-self-center">
55
<BlueskySubscribeButton
56
pub_uri={props.pub_uri}
···
69
);
70
};
71
72
+
export const ManageSubscription = (props: {
73
pub_uri: string;
74
subscribers: { identity: string }[];
75
base_url: string;
···
84
});
85
}, null);
86
return (
87
+
<Popover
88
+
trigger={
89
+
<div className="text-accent-contrast text-sm">Manage Subscription</div>
90
+
}
91
>
92
+
<div className="max-w-sm flex flex-col gap-1">
93
+
<h4>Update Options</h4>
94
95
+
{!hasFeed && (
96
<a
97
+
href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"
98
target="_blank"
99
+
className=" place-self-center"
100
>
101
+
<ButtonPrimary fullWidth compact className="!px-4">
102
+
View Bluesky Custom Feed
103
</ButtonPrimary>
104
</a>
105
+
)}
106
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>
117
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>
127
);
128
};
129
···
256
</Dialog.Root>
257
);
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
import { UnicodeString } from "@atproto/api";
2
import { PubLeafletRichtextFacet } from "lexicons/api";
3
4
type Facet = PubLeafletRichtextFacet.Main;
5
export function BaseTextBlock(props: {
···
21
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
22
let isStrikethrough = segment.facet?.find(
23
PubLeafletRichtextFacet.isStrikethrough,
24
);
25
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
26
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
···
47
<code key={counter} className={className} id={id?.id}>
48
{renderedText}
49
</code>,
50
);
51
} else if (link) {
52
children.push(
···
1
import { UnicodeString } from "@atproto/api";
2
import { PubLeafletRichtextFacet } from "lexicons/api";
3
+
import { didToBlueskyUrl } from "src/utils/mentionUtils";
4
+
import { AtMentionLink } from "components/AtMentionLink";
5
6
type Facet = PubLeafletRichtextFacet.Main;
7
export function BaseTextBlock(props: {
···
23
let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode);
24
let isStrikethrough = segment.facet?.find(
25
PubLeafletRichtextFacet.isStrikethrough,
26
+
);
27
+
let isDidMention = segment.facet?.find(
28
+
PubLeafletRichtextFacet.isDidMention,
29
+
);
30
+
let isAtMention = segment.facet?.find(
31
+
PubLeafletRichtextFacet.isAtMention,
32
);
33
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
34
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
···
55
<code key={counter} className={className} id={id?.id}>
56
{renderedText}
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>,
80
);
81
} else if (link) {
82
children.push(
+6
-5
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+6
-5
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
22
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
23
import { PollData } from "./fetchPollData";
24
import { SharedPageProps } from "./PostPages";
25
26
export function CanvasPage({
27
blocks,
···
206
quotesCount: number | undefined;
207
commentsCount: number | undefined;
208
}) => {
209
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">
211
<Interactions
212
quotesCount={props.quotesCount || 0}
213
commentsCount={props.commentsCount || 0}
214
-
compact
215
showComments={props.preferences.showComments}
216
pageId={props.pageId}
217
/>
···
219
<>
220
<Separator classname="h-5" />
221
<Popover
222
-
side="left"
223
-
align="start"
224
-
className="flex flex-col gap-2 p-0! max-w-sm w-[1000px]"
225
trigger={<InfoSmall />}
226
>
227
<PostHeader
···
22
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
23
import { PollData } from "./fetchPollData";
24
import { SharedPageProps } from "./PostPages";
25
+
import { useIsMobile } from "src/hooks/isMobile";
26
27
export function CanvasPage({
28
blocks,
···
207
quotesCount: number | undefined;
208
commentsCount: number | undefined;
209
}) => {
210
+
let isMobile = useIsMobile();
211
return (
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">
213
<Interactions
214
quotesCount={props.quotesCount || 0}
215
commentsCount={props.commentsCount || 0}
216
showComments={props.preferences.showComments}
217
pageId={props.pageId}
218
/>
···
220
<>
221
<Separator classname="h-5" />
222
<Popover
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"}`}
226
trigger={<InfoSmall />}
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
import { EditorState, TextSelection } from "prosemirror-state";
9
import { EditorView } from "prosemirror-view";
10
import { history, redo, undo } from "prosemirror-history";
11
import {
12
MutableRefObject,
13
RefObject,
14
useEffect,
15
useLayoutEffect,
16
useRef,
···
36
import { CloseTiny } from "components/Icons/CloseTiny";
37
import { CloseFillTiny } from "components/Icons/CloseFillTiny";
38
import { betterIsUrl } from "src/utils/isURL";
39
40
export function CommentBox(props: {
41
doc_uri: string;
···
50
commentBox: { quote },
51
} = useInteractionState(props.doc_uri);
52
let [loading, setLoading] = useState(false);
53
54
-
const handleSubmit = async () => {
55
if (loading || !view.current) return;
56
57
setLoading(true);
···
114
"Mod-y": redo,
115
"Shift-Mod-z": redo,
116
"Ctrl-Enter": () => {
117
-
handleSubmit();
118
return true;
119
},
120
"Meta-Enter": () => {
121
-
handleSubmit();
122
return true;
123
},
124
}),
···
128
shouldAutoLink: () => true,
129
defaultProtocol: "https",
130
}),
131
history(),
132
],
133
}),
134
);
135
-
let view = useRef<null | EditorView>(null);
136
useLayoutEffect(() => {
137
if (!mountRef.current) return;
138
view.current = new EditorView(
···
187
handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
188
if (!direct) return;
189
if (node.nodeSize - 2 <= _pos) return;
190
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);
197
if (mark) {
198
window.open(mark.attrs.href, "_blank");
199
}
200
},
201
dispatchTransaction(tr) {
···
236
<div className="w-full relative group">
237
<pre
238
ref={mountRef}
239
className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`}
240
/>
241
<IOSBS view={view} />
242
</div>
243
<div className="flex justify-between pt-1">
244
<div className="flex gap-1">
···
261
view={view}
262
/>
263
</div>
264
-
<ButtonPrimary compact onClick={handleSubmit}>
265
{loading ? <DotLoader /> : <ShareSmall />}
266
</ButtonPrimary>
267
</div>
···
328
facets.push(facet);
329
}
330
}
331
332
fullText += text;
333
byteOffset += unicodeString.length;
···
8
import { EditorState, TextSelection } from "prosemirror-state";
9
import { EditorView } from "prosemirror-view";
10
import { history, redo, undo } from "prosemirror-history";
11
+
import { InputRule, inputRules } from "prosemirror-inputrules";
12
import {
13
MutableRefObject,
14
RefObject,
15
+
useCallback,
16
useEffect,
17
useLayoutEffect,
18
useRef,
···
38
import { CloseTiny } from "components/Icons/CloseTiny";
39
import { CloseFillTiny } from "components/Icons/CloseFillTiny";
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
+
};
83
84
export function CommentBox(props: {
85
doc_uri: string;
···
94
commentBox: { quote },
95
} = useInteractionState(props.doc_uri);
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;
112
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 () => {
159
if (loading || !view.current) return;
160
161
setLoading(true);
···
218
"Mod-y": redo,
219
"Shift-Mod-z": redo,
220
"Ctrl-Enter": () => {
221
+
handleSubmitRef.current();
222
return true;
223
},
224
"Meta-Enter": () => {
225
+
handleSubmitRef.current();
226
return true;
227
},
228
}),
···
232
shouldAutoLink: () => true,
233
defaultProtocol: "https",
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
+
}),
245
history(),
246
],
247
}),
248
);
249
useLayoutEffect(() => {
250
if (!mountRef.current) return;
251
view.current = new EditorView(
···
300
handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
301
if (!direct) return;
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
308
let mark =
309
+
nodeAt1?.marks.find(
310
+
(f) => f.type === multiBlockSchema.marks.link,
311
+
) ||
312
+
nodeAt2?.marks.find((f) => f.type === multiBlockSchema.marks.link);
313
if (mark) {
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;
352
}
353
},
354
dispatchTransaction(tr) {
···
389
<div className="w-full relative group">
390
<pre
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
+
}}
404
className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit px-2! py-[6px]!`}
405
/>
406
<IOSBS view={view} />
407
+
<MentionAutocomplete
408
+
open={mentionOpen}
409
+
onOpenChange={handleMentionOpenChange}
410
+
view={view}
411
+
onSelect={handleMentionSelect}
412
+
coords={mentionCoords}
413
+
/>
414
</div>
415
<div className="flex justify-between pt-1">
416
<div className="flex gap-1">
···
433
view={view}
434
/>
435
</div>
436
+
<ButtonPrimary compact onClick={() => handleSubmitRef.current()}>
437
{loading ? <DotLoader /> : <ShareSmall />}
438
</ButtonPrimary>
439
</div>
···
500
facets.push(facet);
501
}
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
+
});
543
544
fullText += text;
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
import { Json } from "supabase/database.types";
11
import {
12
Notification,
13
pingIdentityToUpdateNotification,
14
} from "src/notifications";
15
import { v7 } from "uuid";
···
84
parent_uri: args.comment.replyTo,
85
},
86
});
87
// SOMEDAY: move this out the action with inngest or workflows
88
await supabaseServerClient.from("notifications").insert(notifications);
89
-
await pingIdentityToUpdateNotification(recipient);
90
}
91
92
return {
···
95
uri: uri.toString(),
96
};
97
}
···
10
import { Json } from "supabase/database.types";
11
import {
12
Notification,
13
+
NotificationData,
14
pingIdentityToUpdateNotification,
15
} from "src/notifications";
16
import { v7 } from "uuid";
···
85
parent_uri: args.comment.replyTo,
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) {
100
// SOMEDAY: move this out the action with inngest or workflows
101
await supabaseServerClient.from("notifications").insert(notifications);
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
+
);
108
}
109
110
return {
···
113
uri: uri.toString(),
114
};
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
import { useContext } from "react";
10
import { PostPageContext } from "../PostPageContext";
11
import { scrollIntoView } from "src/utils/scrollIntoView";
12
import { PostPageData } from "../getPostPageData";
13
-
import { PubLeafletComment } from "lexicons/api";
14
import { prefetchQuotesData } from "./Quotes";
15
16
export type InteractionState = {
17
drawerOpen: undefined | boolean;
···
99
export const Interactions = (props: {
100
quotesCount: number;
101
commentsCount: number;
102
-
compact?: boolean;
103
className?: string;
104
showComments?: boolean;
105
pageId?: string;
106
}) => {
107
const data = useContext(PostPageContext);
108
const document_uri = data?.uri;
109
if (!document_uri)
110
throw new Error("document_uri not available in PostPageContext");
111
···
117
}
118
};
119
120
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>
142
{props.showComments === false ? null : (
143
<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"}`}
145
onClick={() => {
146
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
147
openInteractionDrawer("comments", document_uri, props.pageId);
···
149
}}
150
aria-label="Post comments"
151
>
152
-
<CommentTiny aria-hidden /> {props.commentsCount}{" "}
153
-
{!props.compact && (
154
-
<span
155
-
aria-hidden
156
-
>{`Comment${props.commentsCount === 1 ? "" : "s"}`}</span>
157
-
)}
158
</button>
159
)}
160
</div>
161
);
162
};
163
164
export function getQuoteCount(document: PostPageData, pageId?: string) {
165
if (!document) return;
166
return getQuoteCountFromArray(document.quotesAndMentions, pageId);
···
198
(c) => !(c.record as PubLeafletComment.Record)?.onPage,
199
).length;
200
}
···
9
import { useContext } from "react";
10
import { PostPageContext } from "../PostPageContext";
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";
15
import { PostPageData } from "../getPostPageData";
16
+
import { PubLeafletComment, PubLeafletPublication } from "lexicons/api";
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";
22
23
export type InteractionState = {
24
drawerOpen: undefined | boolean;
···
106
export const Interactions = (props: {
107
quotesCount: number;
108
commentsCount: number;
109
className?: string;
110
showComments?: boolean;
111
pageId?: string;
112
}) => {
113
const data = useContext(PostPageContext);
114
const document_uri = data?.uri;
115
+
let { identity } = useIdentityData();
116
if (!document_uri)
117
throw new Error("document_uri not available in PostPageContext");
118
···
124
}
125
};
126
127
+
const tags = (data?.data as any)?.tags as string[] | undefined;
128
+
const tagCount = tags?.length || 0;
129
+
130
return (
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
+
)}
149
{props.showComments === false ? null : (
150
<button
151
+
className="flex gap-2 items-center w-fit"
152
onClick={() => {
153
if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId)
154
openInteractionDrawer("comments", document_uri, props.pageId);
···
156
}}
157
aria-label="Post comments"
158
>
159
+
<CommentTiny aria-hidden /> {props.commentsCount}
160
</button>
161
)}
162
</div>
163
);
164
};
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
+
};
322
export function getQuoteCount(document: PostPageData, pageId?: string) {
323
if (!document) return;
324
return getQuoteCountFromArray(document.quotesAndMentions, pageId);
···
356
(c) => !(c.record as PubLeafletComment.Record)?.onPage,
357
).length;
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
import { SubscribeWithBluesky } from "app/lish/Subscribe";
12
import { EditTiny } from "components/Icons/EditTiny";
13
import {
14
getCommentCount,
15
getQuoteCount,
16
Interactions,
···
47
fullPageScroll,
48
hasPageBackground,
49
} = props;
50
-
let { identity } = useIdentityData();
51
let drawer = useDrawerOpen(document_uri);
52
53
if (!document) return null;
···
84
did={did}
85
prerenderedCodeBlocks={prerenderedCodeBlocks}
86
/>
87
-
<Interactions
88
pageId={pageId}
89
showComments={preferences.showComments}
90
commentsCount={getCommentCount(document, pageId) || 0}
91
quotesCount={getQuoteCount(document, pageId) || 0}
92
/>
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
-
)}
131
</PageWrapper>
132
</>
133
);
···
11
import { SubscribeWithBluesky } from "app/lish/Subscribe";
12
import { EditTiny } from "components/Icons/EditTiny";
13
import {
14
+
ExpandedInteractions,
15
getCommentCount,
16
getQuoteCount,
17
Interactions,
···
48
fullPageScroll,
49
hasPageBackground,
50
} = props;
51
let drawer = useDrawerOpen(document_uri);
52
53
if (!document) return null;
···
84
did={did}
85
prerenderedCodeBlocks={prerenderedCodeBlocks}
86
/>
87
+
88
+
<ExpandedInteractions
89
pageId={pageId}
90
showComments={preferences.showComments}
91
commentsCount={getCommentCount(document, pageId) || 0}
92
quotesCount={getQuoteCount(document, pageId) || 0}
93
/>
94
+
{!hasPageBackground && <div className={`spacer h-8 w-full`} />}
95
</PageWrapper>
96
</>
97
);
+11
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+11
-8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
···
59
return (
60
<div
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}`}
63
>
64
{blocks.map((b, index) => {
65
return (
···
293
}
294
case PubLeafletBlocksImage.isMain(b.block): {
295
return (
296
-
<div className={`relative flex ${alignment}`} {...blockProps}>
297
<img
298
alt={b.block.alt}
299
height={b.block.aspectRatio?.height}
···
321
return (
322
// 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
<blockquote
324
-
className={` blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`}
325
{...blockProps}
326
>
327
<TextBlock
···
336
}
337
case PubLeafletBlocksText.isMain(b.block):
338
return (
339
-
<p className={` ${className}`} {...blockProps}>
340
<TextBlock
341
facets={b.block.facets}
342
plaintext={b.block.plaintext}
···
349
case PubLeafletBlocksHeader.isMain(b.block): {
350
if (b.block.level === 1)
351
return (
352
-
<h2 className={`${className}`} {...blockProps}>
353
<TextBlock
354
{...b.block}
355
index={index}
···
360
);
361
if (b.block.level === 2)
362
return (
363
-
<h3 className={`${className}`} {...blockProps}>
364
<TextBlock
365
{...b.block}
366
index={index}
···
371
);
372
if (b.block.level === 3)
373
return (
374
-
<h4 className={`${className}`} {...blockProps}>
375
<TextBlock
376
{...b.block}
377
index={index}
···
383
// if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>;
384
// if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>;
385
return (
386
-
<h6 className={`${className}`} {...blockProps}>
387
<TextBlock
388
{...b.block}
389
index={index}
···
59
return (
60
<div
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-4 ${className}`}
63
>
64
{blocks.map((b, index) => {
65
return (
···
293
}
294
case PubLeafletBlocksImage.isMain(b.block): {
295
return (
296
+
<div
297
+
className={`imageBlock relative flex ${alignment}`}
298
+
{...blockProps}
299
+
>
300
<img
301
alt={b.block.alt}
302
height={b.block.aspectRatio?.height}
···
324
return (
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.
326
<blockquote
327
+
className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`}
328
{...blockProps}
329
>
330
<TextBlock
···
339
}
340
case PubLeafletBlocksText.isMain(b.block):
341
return (
342
+
<p className={`textBlock ${className}`} {...blockProps}>
343
<TextBlock
344
facets={b.block.facets}
345
plaintext={b.block.plaintext}
···
352
case PubLeafletBlocksHeader.isMain(b.block): {
353
if (b.block.level === 1)
354
return (
355
+
<h2 className={`h1Block ${className}`} {...blockProps}>
356
<TextBlock
357
{...b.block}
358
index={index}
···
363
);
364
if (b.block.level === 2)
365
return (
366
+
<h3 className={`h2Block ${className}`} {...blockProps}>
367
<TextBlock
368
{...b.block}
369
index={index}
···
374
);
375
if (b.block.level === 3)
376
return (
377
+
<h4 className={`h3Block ${className}`} {...blockProps}>
378
<TextBlock
379
{...b.block}
380
index={index}
···
386
// if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>;
387
// if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>;
388
return (
389
+
<h6 className={`h6Block ${className}`} {...blockProps}>
390
<TextBlock
391
{...b.block}
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
import { EditTiny } from "components/Icons/EditTiny";
17
import { SpeedyLink } from "components/SpeedyLink";
18
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
19
20
export function PostHeader(props: {
21
data: PostPageData;
···
40
41
if (!document?.data) return;
42
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">
49
{pub && (
50
<SpeedyLink
51
className="font-bold hover:no-underline text-accent-contrast"
···
65
<EditTiny className="shrink-0" />
66
</a>
67
)}
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
-
|{" "}
91
<Interactions
92
showComments={props.preferences.showComments}
93
-
compact
94
quotesCount={getQuoteCount(document) || 0}
95
commentsCount={getCommentCount(document) || 0}
96
/>
97
-
</div>
98
</div>
99
</div>
100
);
101
-
}
···
16
import { EditTiny } from "components/Icons/EditTiny";
17
import { SpeedyLink } from "components/SpeedyLink";
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";
21
22
export function PostHeader(props: {
23
data: PostPageData;
···
42
43
if (!document?.data) return;
44
return (
45
+
<PostHeaderLayout
46
+
pubLink={
47
+
<>
48
{pub && (
49
<SpeedyLink
50
className="font-bold hover:no-underline text-accent-contrast"
···
64
<EditTiny className="shrink-0" />
65
</a>
66
)}
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
<Interactions
92
showComments={props.preferences.showComments}
93
quotesCount={getQuoteCount(document) || 0}
94
commentsCount={getCommentCount(document) || 0}
95
/>
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}
128
</div>
129
</div>
130
);
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
import React from "react";
5
import { usePublicationData } from "./PublicationSWRProvider";
6
import { LeafletList } from "app/(home-pages)/home/HomeLayout";
7
-
import { EmptyState } from "components/EmptyState";
8
9
export function DraftList(props: {
10
searchValue: string;
···
13
let { data: pub_data } = usePublicationData();
14
if (!pub_data?.publication) return null;
15
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
return (
48
<div className="flex flex-col gap-4">
49
<NewDraftSecondaryButton
···
56
showPreview={false}
57
defaultDisplay="list"
58
cardBorderHidden={!props.showPageBackground}
59
-
leaflets={filteredLeaflets}
60
initialFacts={pub_data.leaflet_data.facts || {}}
61
titles={{
62
...leaflets_in_publications.reduce(
···
4
import React from "react";
5
import { usePublicationData } from "./PublicationSWRProvider";
6
import { LeafletList } from "app/(home-pages)/home/HomeLayout";
7
8
export function DraftList(props: {
9
searchValue: string;
···
12
let { data: pub_data } = usePublicationData();
13
if (!pub_data?.publication) return null;
14
let { leaflets_in_publications, ...publication } = pub_data.publication;
15
return (
16
<div className="flex flex-col gap-4">
17
<NewDraftSecondaryButton
···
24
showPreview={false}
25
defaultDisplay="list"
26
cardBorderHidden={!props.showPageBackground}
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
+
})}
47
initialFacts={pub_data.leaflet_data.facts || {}}
48
titles={{
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
"use client";
2
import { AtUri } from "@atproto/syntax";
3
-
import { PubLeafletDocument } from "lexicons/api";
4
import { EditTiny } from "components/Icons/EditTiny";
5
6
import { usePublicationData } from "./PublicationSWRProvider";
···
17
import { SpeedyLink } from "components/SpeedyLink";
18
import { QuoteTiny } from "components/Icons/QuoteTiny";
19
import { CommentTiny } from "components/Icons/CommentTiny";
20
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
21
import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions";
22
import { StaticLeafletDataContext } from "components/PageSWRDataProvider";
23
-
import { EmptyState } from "components/EmptyState";
24
25
export function PublishedPostsList(props: {
26
searchValue: string;
···
29
let { data } = usePublicationData();
30
let params = useParams();
31
let { publication } = data!;
32
if (!publication) return null;
33
if (publication.documents_in_publications.length === 0)
34
-
return <EmptyState>Nothing's been published yet...</EmptyState>;
35
return (
36
<div className="publishedList w-full flex flex-col gap-2 pb-4">
37
{publication.documents_in_publications
···
52
(l) => doc.documents && l.doc === doc.documents.uri,
53
);
54
let uri = new AtUri(doc.documents.uri);
55
-
let record = doc.documents.data as PubLeafletDocument.Record;
56
let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0;
57
let comments = doc.documents.comments_on_documents[0]?.count || 0;
58
59
let postLink = data?.publication
60
? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}`
···
78
href={`${getPublicationURL(publication)}/${uri.rkey}`}
79
>
80
<h3 className="text-primary grow leading-snug">
81
-
{record.title}
82
</h3>
83
</a>
84
<div className="flex justify-start align-top flex-row gap-1">
···
107
: null,
108
},
109
],
110
-
leaflets_to_documents: null,
111
blocked_by_admin: null,
112
custom_domain_routes: [],
113
}}
···
119
</div>
120
</div>
121
122
-
{record.description ? (
123
<p className="italic text-secondary">
124
-
{record.description}
125
</p>
126
) : null}
127
-
<div className="text-sm text-tertiary flex gap-1 flex-wrap pt-3">
128
-
{record.publishedAt ? (
129
-
<PublishedDate dateString={record.publishedAt} />
130
) : 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
-
)}
151
</div>
152
</div>
153
</div>
···
1
"use client";
2
import { AtUri } from "@atproto/syntax";
3
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
4
import { EditTiny } from "components/Icons/EditTiny";
5
6
import { usePublicationData } from "./PublicationSWRProvider";
···
17
import { SpeedyLink } from "components/SpeedyLink";
18
import { QuoteTiny } from "components/Icons/QuoteTiny";
19
import { CommentTiny } from "components/Icons/CommentTiny";
20
+
import { InteractionPreview } from "components/InteractionsPreview";
21
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
22
import { LeafletOptions } from "app/(home-pages)/home/LeafletList/LeafletOptions";
23
import { StaticLeafletDataContext } from "components/PageSWRDataProvider";
24
25
export function PublishedPostsList(props: {
26
searchValue: string;
···
29
let { data } = usePublicationData();
30
let params = useParams();
31
let { publication } = data!;
32
+
let pubRecord = publication?.record as PubLeafletPublication.Record;
33
+
34
if (!publication) return null;
35
if (publication.documents_in_publications.length === 0)
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
+
);
41
return (
42
<div className="publishedList w-full flex flex-col gap-2 pb-4">
43
{publication.documents_in_publications
···
58
(l) => doc.documents && l.doc === doc.documents.uri,
59
);
60
let uri = new AtUri(doc.documents.uri);
61
+
let postRecord = doc.documents.data as PubLeafletDocument.Record;
62
let quotes = doc.documents.document_mentions_in_bsky[0]?.count || 0;
63
let comments = doc.documents.comments_on_documents[0]?.count || 0;
64
+
let tags = (postRecord?.tags as string[] | undefined) || [];
65
66
let postLink = data?.publication
67
? `${getPublicationURL(data?.publication)}/${new AtUri(doc.documents.uri).rkey}`
···
85
href={`${getPublicationURL(publication)}/${uri.rkey}`}
86
>
87
<h3 className="text-primary grow leading-snug">
88
+
{postRecord.title}
89
</h3>
90
</a>
91
<div className="flex justify-start align-top flex-row gap-1">
···
114
: null,
115
},
116
],
117
+
leaflets_to_documents: [],
118
blocked_by_admin: null,
119
custom_domain_routes: [],
120
}}
···
126
</div>
127
</div>
128
129
+
{postRecord.description ? (
130
<p className="italic text-secondary">
131
+
{postRecord.description}
132
</p>
133
) : null}
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} />
137
) : null}
138
+
<InteractionPreview
139
+
quotesCount={quotes}
140
+
commentsCount={comments}
141
+
tags={tags}
142
+
showComments={pubRecord?.preferences?.showComments}
143
+
postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
144
+
/>
145
</div>
146
</div>
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
title: pubRecord?.name || "Untitled Publication",
48
description: pubRecord?.description || "",
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
+
},
58
other: {
59
rel: "alternate",
60
url: publication.uri,
+9
-17
app/lish/[did]/[publication]/page.tsx
+9
-17
app/lish/[did]/[publication]/page.tsx
···
14
import { SpeedyLink } from "components/SpeedyLink";
15
import { QuoteTiny } from "components/Icons/QuoteTiny";
16
import { CommentTiny } from "components/Icons/CommentTiny";
17
import { LocalizedDate } from "./LocalizedDate";
18
import { PublicationHomeLayout } from "./PublicationHomeLayout";
19
···
134
record?.preferences?.showComments === false
135
? 0
136
: doc.documents.comments_on_documents[0].count || 0;
137
138
return (
139
<React.Fragment key={doc.documents?.uri}>
···
162
)}{" "}
163
</p>
164
{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
-
)}
182
</div>
183
</div>
184
<hr className="last:hidden border-border-light" />
···
14
import { SpeedyLink } from "components/SpeedyLink";
15
import { QuoteTiny } from "components/Icons/QuoteTiny";
16
import { CommentTiny } from "components/Icons/CommentTiny";
17
+
import { InteractionPreview } from "components/InteractionsPreview";
18
import { LocalizedDate } from "./LocalizedDate";
19
import { PublicationHomeLayout } from "./PublicationHomeLayout";
20
···
135
record?.preferences?.showComments === false
136
? 0
137
: doc.documents.comments_on_documents[0].count || 0;
138
+
let tags = (doc_record?.tags as string[] | undefined) || [];
139
140
return (
141
<React.Fragment key={doc.documents?.uri}>
···
164
)}{" "}
165
</p>
166
{comments > 0 || quotes > 0 ? "| " : ""}
167
+
<InteractionPreview
168
+
quotesCount={quotes}
169
+
commentsCount={comments}
170
+
tags={tags}
171
+
postUrl=""
172
+
showComments={record?.preferences?.showComments}
173
+
/>
174
</div>
175
</div>
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
import { useContext, useEffect } from "react";
4
import { SidebarContext } from "./Sidebar";
5
import React, { forwardRef, type JSX } from "react";
6
-
import { PopoverOpenContext } from "components/Popover";
7
8
type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">;
9
···
11
_props: ButtonProps & {
12
id?: string;
13
icon: React.ReactNode;
14
-
label?: React.ReactNode;
15
primary?: boolean;
16
secondary?: boolean;
17
nav?: boolean;
···
69
`}
70
>
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
-
)}
84
</button>
85
);
86
};
···
3
import { useContext, useEffect } from "react";
4
import { SidebarContext } from "./Sidebar";
5
import React, { forwardRef, type JSX } from "react";
6
+
import { PopoverOpenContext } from "components/Popover/PopoverContext";
7
8
type ButtonProps = Omit<JSX.IntrinsicElements["button"], "content">;
9
···
11
_props: ButtonProps & {
12
id?: string;
13
icon: React.ReactNode;
14
+
label: React.ReactNode;
15
primary?: boolean;
16
secondary?: boolean;
17
nav?: boolean;
···
69
`}
70
>
71
<div className="shrink-0">{icon}</div>
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>
82
</button>
83
);
84
};
+2
-3
components/ActionBar/Publications.tsx
+2
-3
components/ActionBar/Publications.tsx
···
23
currentPubUri: string | undefined;
24
}) => {
25
let { identity } = useIdentityData();
26
-
let hasLooseleafs = identity?.permission_token_on_homepage.find(
27
(f) =>
28
f.permission_tokens.leaflets_to_documents &&
29
-
f.permission_tokens.leaflets_to_documents.document,
30
);
31
-
console.log(hasLooseleafs);
32
33
// don't show pub list button if not logged in or no pub list
34
// we show a "start a pub" banner instead
···
23
currentPubUri: string | undefined;
24
}) => {
25
let { identity } = useIdentityData();
26
+
let hasLooseleafs = !!identity?.permission_token_on_homepage.find(
27
(f) =>
28
f.permission_tokens.leaflets_to_documents &&
29
+
f.permission_tokens.leaflets_to_documents[0]?.document,
30
);
31
32
// don't show pub list button if not logged in or no pub list
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
const clearCommandSearchText = () => {
38
if (!props.entityID) return;
39
const entityID = props.entityID;
40
-
41
const existingState = useEditorStates.getState().editorStates[entityID];
42
if (!existingState) return;
43
···
69
setHighlighted(commandResults[0].name);
70
}
71
}, [commandResults, setHighlighted, highlighted]);
72
useEffect(() => {
73
let listener = async (e: KeyboardEvent) => {
74
let reverseDir = ref.current?.dataset.side === "top";
···
118
return;
119
}
120
};
121
window.addEventListener("keydown", listener);
122
123
return () => window.removeEventListener("keydown", listener);
···
200
201
return (
202
<button
203
-
className={`commandResult text-left flex gap-2 mx-1 pr-2 py-0.5 rounded-md text-secondary ${isHighlighted && "bg-border-light"}`}
204
onMouseOver={() => {
205
props.setHighlighted(props.name);
206
}}
···
37
const clearCommandSearchText = () => {
38
if (!props.entityID) return;
39
const entityID = props.entityID;
40
+
41
const existingState = useEditorStates.getState().editorStates[entityID];
42
if (!existingState) return;
43
···
69
setHighlighted(commandResults[0].name);
70
}
71
}, [commandResults, setHighlighted, highlighted]);
72
+
73
useEffect(() => {
74
let listener = async (e: KeyboardEvent) => {
75
let reverseDir = ref.current?.dataset.side === "top";
···
119
return;
120
}
121
};
122
+
123
window.addEventListener("keydown", listener);
124
125
return () => window.removeEventListener("keydown", listener);
···
202
203
return (
204
<button
205
+
className={`commandResult menuItem text-secondary font-normal! py-0.5! mx-1 pl-0! ${isHighlighted && "bg-[var(--accent-light)]!"}`}
206
onMouseOver={() => {
207
props.setHighlighted(props.name);
208
}}
+3
-3
components/Blocks/BlockCommands.tsx
+3
-3
components/Blocks/BlockCommands.tsx
···
2
import { useUIState } from "src/useUIState";
3
4
import { generateKeyBetween } from "fractional-indexing";
5
-
import { focusPage } from "components/Pages";
6
import { v7 } from "uuid";
7
import { Replicache } from "replicache";
8
import { useEditorStates } from "src/state/useEditorState";
9
import { elementId } from "src/utils/elementId";
10
import { UndoManager } from "src/undoManager";
11
import { focusBlock } from "src/utils/focusBlock";
12
-
import { usePollBlockUIState } from "./PollBlock";
13
-
import { focusElement } from "components/Input";
14
import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall";
15
import { BlockButtonSmall } from "components/Icons/BlockButtonSmall";
16
import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
···
2
import { useUIState } from "src/useUIState";
3
4
import { generateKeyBetween } from "fractional-indexing";
5
+
import { focusPage } from "src/utils/focusPage";
6
import { v7 } from "uuid";
7
import { Replicache } from "replicache";
8
import { useEditorStates } from "src/state/useEditorState";
9
import { elementId } from "src/utils/elementId";
10
import { UndoManager } from "src/undoManager";
11
import { focusBlock } from "src/utils/focusBlock";
12
+
import { usePollBlockUIState } from "./PollBlock/pollBlockState";
13
+
import { focusElement } from "src/utils/focusElement";
14
import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall";
15
import { BlockButtonSmall } from "components/Icons/BlockButtonSmall";
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";
12
import { ButtonPrimary } from "components/Buttons";
13
import { CloseTiny } from "components/Icons/CloseTiny";
14
15
export const AreYouSure = (props: {
16
entityID: string[] | string;
···
82
);
83
};
84
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
-
}
···
1
+
import { Fact, useReplicache } from "src/replicache";
2
import { ButtonPrimary } from "components/Buttons";
3
import { CloseTiny } from "components/Icons/CloseTiny";
4
+
import { deleteBlock } from "src/utils/deleteBlock";
5
6
export const AreYouSure = (props: {
7
entityID: string[] | string;
···
73
);
74
};
75
+2
-1
components/Blocks/ExternalLinkBlock.tsx
+2
-1
components/Blocks/ExternalLinkBlock.tsx
···
8
import { v7 } from "uuid";
9
import { useSmoker } from "components/Toast";
10
import { Separator } from "components/Layout";
11
-
import { focusElement, Input } from "components/Input";
12
import { isUrl } from "src/utils/isURL";
13
import { elementId } from "src/utils/elementId";
14
import { focusBlock } from "src/utils/focusBlock";
···
8
import { v7 } from "uuid";
9
import { useSmoker } from "components/Toast";
10
import { Separator } from "components/Layout";
11
+
import { Input } from "components/Input";
12
+
import { focusElement } from "src/utils/focusElement";
13
import { isUrl } from "src/utils/isURL";
14
import { elementId } from "src/utils/elementId";
15
import { focusBlock } from "src/utils/focusBlock";
+1
-1
components/Blocks/MailboxBlock.tsx
+1
-1
components/Blocks/MailboxBlock.tsx
···
9
import { useEntitySetContext } from "components/EntitySetProvider";
10
import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail";
11
import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription";
12
-
import { focusPage } from "components/Pages";
13
import { v7 } from "uuid";
14
import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers";
15
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
···
9
import { useEntitySetContext } from "components/EntitySetProvider";
10
import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail";
11
import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription";
12
+
import { focusPage } from "src/utils/focusPage";
13
import { v7 } from "uuid";
14
import { sendPostToSubscribers } from "actions/subscriptions/sendPostToSubscribers";
15
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
+1
-1
components/Blocks/PageLinkBlock.tsx
+1
-1
components/Blocks/PageLinkBlock.tsx
···
2
import { BlockProps, BaseBlock, ListMarker, Block } from "./Block";
3
import { focusBlock } from "src/utils/focusBlock";
4
5
-
import { focusPage } from "components/Pages";
6
import { useEntity, useReplicache } from "src/replicache";
7
import { useUIState } from "src/useUIState";
8
import { RenderedTextBlock } from "components/Blocks/TextBlock";
···
2
import { BlockProps, BaseBlock, ListMarker, Block } from "./Block";
3
import { focusBlock } from "src/utils/focusBlock";
4
5
+
import { focusPage } from "src/utils/focusPage";
6
import { useEntity, useReplicache } from "src/replicache";
7
import { useUIState } from "src/useUIState";
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
import { useUIState } from "src/useUIState";
2
import { BlockProps } from "./Block";
3
import { useMemo } from "react";
4
-
import { focusElement, AsyncValueInput } from "components/Input";
5
import { useEntitySetContext } from "components/EntitySetProvider";
6
import { useEntity, useReplicache } from "src/replicache";
7
import { v7 } from "uuid";
···
1
import { useUIState } from "src/useUIState";
2
import { BlockProps } from "./Block";
3
import { useMemo } from "react";
4
+
import { AsyncValueInput } from "components/Input";
5
+
import { focusElement } from "src/utils/focusElement";
6
import { useEntitySetContext } from "components/EntitySetProvider";
7
import { useEntity, useReplicache } from "src/replicache";
8
import { v7 } from "uuid";
+31
-35
components/Blocks/TextBlock/RenderYJSFragment.tsx
+31
-35
components/Blocks/TextBlock/RenderYJSFragment.tsx
···
3
import { CSSProperties, Fragment } from "react";
4
import { theme } from "tailwind.config";
5
import * as base64 from "base64-js";
6
7
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
8
export function RenderYJSFragment({
···
64
return <br key={index} />;
65
}
66
67
return null;
68
})
69
)}
···
101
}
102
};
103
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
function attributesToStyle(d: Delta) {
118
let props = {
119
style: {},
···
144
return props;
145
}
146
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
-
}
···
3
import { CSSProperties, Fragment } from "react";
4
import { theme } from "tailwind.config";
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";
9
10
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
11
export function RenderYJSFragment({
···
67
return <br key={index} />;
68
}
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
+
98
return null;
99
})
100
)}
···
132
}
133
};
134
135
function attributesToStyle(d: Delta) {
136
let props = {
137
style: {},
···
162
return props;
163
}
164
+109
-14
components/Blocks/TextBlock/index.tsx
+109
-14
components/Blocks/TextBlock/index.tsx
···
1
-
import { useRef, useEffect, useState } from "react";
2
import { elementId } from "src/utils/elementId";
3
import { useReplicache, useEntity } from "src/replicache";
4
import { isVisible } from "src/utils/isVisible";
5
import { EditorState, TextSelection } from "prosemirror-state";
6
import { RenderYJSFragment } from "./RenderYJSFragment";
7
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
8
import { BlockProps } from "../Block";
···
23
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
24
import { DotLoader } from "components/utils/DotLoader";
25
import { useMountProsemirror } from "./mountProsemirror";
26
27
const HeadingStyle = {
28
1: "text-xl font-bold",
···
183
let editorState = useEditorStates(
184
(s) => s.editorStates[props.entityID],
185
)?.editor;
186
187
let { mountRef, actionTimeout } = useMountProsemirror({
188
props,
189
});
190
191
return (
···
199
? "blockquote pt-3"
200
: "blockquote"
201
: ""
202
-
}
203
-
204
-
`}
205
>
206
<pre
207
data-entityid={props.entityID}
···
224
}
225
}}
226
onFocus={() => {
227
setTimeout(() => {
228
useUIState.getState().setSelectedBlock(props);
229
useUIState.setState(() => ({
···
249
${props.className}`}
250
ref={mountRef}
251
/>
252
{editorState?.doc.textContent.length === 0 &&
253
props.previousBlock === null &&
254
props.nextBlock === null ? (
···
439
);
440
};
441
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 };
452
};
···
1
+
import { useRef, useEffect, useState, useCallback } from "react";
2
import { elementId } from "src/utils/elementId";
3
import { useReplicache, useEntity } from "src/replicache";
4
import { isVisible } from "src/utils/isVisible";
5
import { EditorState, TextSelection } from "prosemirror-state";
6
+
import { EditorView } from "prosemirror-view";
7
import { RenderYJSFragment } from "./RenderYJSFragment";
8
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
9
import { BlockProps } from "../Block";
···
24
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
25
import { DotLoader } from "components/utils/DotLoader";
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";
31
32
const HeadingStyle = {
33
1: "text-xl font-bold",
···
188
let editorState = useEditorStates(
189
(s) => s.editorStates[props.entityID],
190
)?.editor;
191
+
const {
192
+
viewRef,
193
+
mentionOpen,
194
+
mentionCoords,
195
+
openMentionAutocomplete,
196
+
handleMentionSelect,
197
+
handleMentionOpenChange,
198
+
} = useMentionState(props.entityID);
199
200
let { mountRef, actionTimeout } = useMountProsemirror({
201
props,
202
+
openMentionAutocomplete,
203
});
204
205
return (
···
213
? "blockquote pt-3"
214
: "blockquote"
215
: ""
216
+
}`}
217
>
218
<pre
219
data-entityid={props.entityID}
···
236
}
237
}}
238
onFocus={() => {
239
+
handleMentionOpenChange(false);
240
setTimeout(() => {
241
useUIState.getState().setSelectedBlock(props);
242
useUIState.setState(() => ({
···
262
${props.className}`}
263
ref={mountRef}
264
/>
265
+
{focused && (
266
+
<MentionAutocomplete
267
+
open={mentionOpen}
268
+
onOpenChange={handleMentionOpenChange}
269
+
view={viewRef}
270
+
onSelect={handleMentionSelect}
271
+
coords={mentionCoords}
272
+
/>
273
+
)}
274
{editorState?.doc.textContent.length === 0 &&
275
props.previousBlock === null &&
276
props.nextBlock === null ? (
···
461
);
462
};
463
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
+
};
547
};
+20
components/Blocks/TextBlock/inputRules.ts
+20
components/Blocks/TextBlock/inputRules.ts
···
15
export const inputrules = (
16
propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>,
17
repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>,
18
) =>
19
inputRules({
20
//Strikethrough
···
189
data: { type: "number", value: headingLevel },
190
});
191
return tr;
192
}),
193
],
194
});
···
15
export const inputrules = (
16
propsRef: MutableRefObject<BlockProps & { entity_set: { set: string } }>,
17
repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>,
18
+
openMentionAutocomplete?: () => void,
19
) =>
20
inputRules({
21
//Strikethrough
···
190
data: { type: "number", value: headingLevel },
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
212
}),
213
],
214
});
+5
-8
components/Blocks/TextBlock/keymap.ts
+5
-8
components/Blocks/TextBlock/keymap.ts
···
17
import { schema } from "./schema";
18
import { useUIState } from "src/useUIState";
19
import { setEditorState, useEditorStates } from "src/state/useEditorState";
20
-
import { focusPage } from "components/Pages";
21
import { v7 } from "uuid";
22
import { scanIndex } from "src/replicache/utils";
23
import { indent, outdent } from "src/utils/list-operations";
24
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
25
import { isTextBlock } from "src/utils/isTextBlock";
26
import { UndoManager } from "src/undoManager";
27
-
28
type PropsRef = RefObject<
29
BlockProps & {
30
entity_set: { set: string };
···
35
propsRef: PropsRef,
36
repRef: RefObject<Replicache<ReplicacheMutators> | null>,
37
um: UndoManager,
38
-
multiLine?: boolean,
39
) =>
40
({
41
"Meta-b": toggleMark(schema.marks.strong),
···
138
),
139
"Shift-Backspace": backspace(propsRef, repRef),
140
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
-
);
146
},
147
"Shift-Enter": (state, dispatch, view) => {
148
// Insert a hard break
···
17
import { schema } from "./schema";
18
import { useUIState } from "src/useUIState";
19
import { setEditorState, useEditorStates } from "src/state/useEditorState";
20
+
import { focusPage } from "src/utils/focusPage";
21
import { v7 } from "uuid";
22
import { scanIndex } from "src/replicache/utils";
23
import { indent, outdent } from "src/utils/list-operations";
24
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
25
import { isTextBlock } from "src/utils/isTextBlock";
26
import { UndoManager } from "src/undoManager";
27
type PropsRef = RefObject<
28
BlockProps & {
29
entity_set: { set: string };
···
34
propsRef: PropsRef,
35
repRef: RefObject<Replicache<ReplicacheMutators> | null>,
36
um: UndoManager,
37
+
openMentionAutocomplete: () => void,
38
) =>
39
({
40
"Meta-b": toggleMark(schema.marks.strong),
···
137
),
138
"Shift-Backspace": backspace(propsRef, repRef),
139
Enter: (state, dispatch, view) => {
140
+
return um.withUndoGroup(() => {
141
+
return enter(propsRef, repRef)(state, dispatch, view);
142
+
});
143
},
144
"Shift-Enter": (state, dispatch, view) => {
145
// Insert a hard break
+48
-12
components/Blocks/TextBlock/mountProsemirror.ts
+48
-12
components/Blocks/TextBlock/mountProsemirror.ts
···
23
import { useHandlePaste } from "./useHandlePaste";
24
import { BlockProps } from "../Block";
25
import { useEntitySetContext } from "components/EntitySetProvider";
26
27
-
export function useMountProsemirror({ props }: { props: BlockProps }) {
28
let { entityID, parent } = props;
29
let rep = useReplicache();
30
let mountRef = useRef<HTMLPreElement | null>(null);
···
44
useLayoutEffect(() => {
45
if (!mountRef.current) return;
46
47
-
const km = TextBlockKeymap(propsRef, repRef, rep.undoManager);
48
const editor = EditorState.create({
49
schema: schema,
50
plugins: [
51
ySyncPlugin(value),
52
keymap(km),
53
-
inputrules(propsRef, repRef),
54
keymap(baseKeymap),
55
highlightSelectionPlugin,
56
autolink({
···
69
handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
70
if (!direct) return;
71
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");
81
}
82
},
83
dispatchTransaction,
···
23
import { useHandlePaste } from "./useHandlePaste";
24
import { BlockProps } from "../Block";
25
import { useEntitySetContext } from "components/EntitySetProvider";
26
+
import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
27
28
+
export function useMountProsemirror({
29
+
props,
30
+
openMentionAutocomplete,
31
+
}: {
32
+
props: BlockProps;
33
+
openMentionAutocomplete: () => void;
34
+
}) {
35
let { entityID, parent } = props;
36
let rep = useReplicache();
37
let mountRef = useRef<HTMLPreElement | null>(null);
···
51
useLayoutEffect(() => {
52
if (!mountRef.current) return;
53
54
+
const km = TextBlockKeymap(
55
+
propsRef,
56
+
repRef,
57
+
rep.undoManager,
58
+
openMentionAutocomplete,
59
+
);
60
const editor = EditorState.create({
61
schema: schema,
62
plugins: [
63
ySyncPlugin(value),
64
keymap(km),
65
+
inputrules(propsRef, repRef, openMentionAutocomplete),
66
keymap(baseKeymap),
67
highlightSelectionPlugin,
68
autolink({
···
81
handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
82
if (!direct) return;
83
if (node.nodeSize - 2 <= _pos) return;
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;
117
}
118
},
119
dispatchTransaction,
+100
-1
components/Blocks/TextBlock/schema.ts
+100
-1
components/Blocks/TextBlock/schema.ts
···
1
-
import { Schema, Node, MarkSpec } from "prosemirror-model";
2
import { marks } from "prosemirror-schema-basic";
3
import { theme } from "tailwind.config";
4
···
122
parseDOM: [{ tag: "br" }],
123
toDOM: () => ["br"] as const,
124
},
125
},
126
};
127
export const schema = new Schema(baseSchema);
···
1
+
import { AtUri } from "@atproto/api";
2
+
import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model";
3
import { marks } from "prosemirror-schema-basic";
4
import { theme } from "tailwind.config";
5
···
123
parseDOM: [{ tag: "br" }],
124
toDOM: () => ["br"] as const,
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,
224
},
225
};
226
export const schema = new Schema(baseSchema);
+1
-1
components/Blocks/useBlockKeyboardHandlers.ts
+1
-1
components/Blocks/useBlockKeyboardHandlers.ts
···
12
import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
13
import { useEntitySetContext } from "components/EntitySetProvider";
14
import { Replicache } from "replicache";
15
-
import { deleteBlock } from "./DeleteBlock";
16
import { entities } from "drizzle/schema";
17
import { scanIndex } from "src/replicache/utils";
18
···
12
import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache";
13
import { useEntitySetContext } from "components/EntitySetProvider";
14
import { Replicache } from "replicache";
15
+
import { deleteBlock } from "src/utils/deleteBlock";
16
import { entities } from "drizzle/schema";
17
import { scanIndex } from "src/replicache/utils";
18
+1
-1
components/Blocks/useBlockMouseHandlers.ts
+1
-1
components/Blocks/useBlockMouseHandlers.ts
-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
import { useEffect, useRef, useState, type JSX } from "react";
3
import { onMouseDown } from "src/utils/iosInputMouseDown";
4
import { isIOS } from "src/utils/isDevice";
5
6
export const Input = (
7
props: {
···
56
}}
57
/>
58
);
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
};
94
95
export const InputWithLabel = (
···
2
import { useEffect, useRef, useState, type JSX } from "react";
3
import { onMouseDown } from "src/utils/iosInputMouseDown";
4
import { isIOS } from "src/utils/isDevice";
5
+
import { focusElement } from "src/utils/focusElement";
6
7
export const Input = (
8
props: {
···
57
}}
58
/>
59
);
60
};
61
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
import { theme } from "tailwind.config";
4
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
5
import { PopoverArrow } from "./Icons/PopoverArrow";
6
-
import { PopoverOpenContext } from "./Popover";
7
import { useState } from "react";
8
9
export const Separator = (props: { classname?: string }) => {
···
3
import { theme } from "tailwind.config";
4
import { NestedCardThemeProvider } from "./ThemeManager/ThemeProvider";
5
import { PopoverArrow } from "./Icons/PopoverArrow";
6
+
import { PopoverOpenContext } from "./Popover/PopoverContext";
7
import { useState } from "react";
8
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
);
373
}
374
375
-
const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => {
376
let { filter } = useDashboardState();
377
let setState = useSetDashboardState();
378
let filterCount = Object.values(filter).filter(Boolean).length;
···
469
type="text"
470
id="pubName"
471
size={1}
472
-
placeholder="searchโฆ"
473
value={props.searchValue}
474
onChange={(e) => {
475
props.setSearchValue(e.currentTarget.value);
···
372
);
373
}
374
375
+
const FilterOptions = (props: {
376
+
hasPubs: boolean;
377
+
hasArchived: boolean;
378
+
}) => {
379
let { filter } = useDashboardState();
380
let setState = useSetDashboardState();
381
let filterCount = Object.values(filter).filter(Boolean).length;
···
472
type="text"
473
id="pubName"
474
size={1}
475
+
placeholder="search..."
476
value={props.searchValue}
477
onChange={(e) => {
478
props.setSearchValue(e.currentTarget.value);
+4
-8
components/PageSWRDataProvider.tsx
+4
-8
components/PageSWRDataProvider.tsx
···
90
const publishedInPublication = data.leaflets_in_publications?.find(
91
(l) => l.doc,
92
);
93
-
const publishedStandalone =
94
-
data.leaflets_to_documents && data.leaflets_to_documents.documents
95
-
? data.leaflets_to_documents
96
-
: null;
97
98
const documentUri =
99
publishedInPublication?.documents?.uri ?? publishedStandalone?.document;
100
101
// Compute the full post URL for sharing
102
let postShareLink: string | undefined;
103
-
if (
104
-
publishedInPublication?.publications &&
105
-
publishedInPublication.documents
106
-
) {
107
// Published in a publication - use publication URL + document rkey
108
const docUri = new AtUri(publishedInPublication.documents.uri);
109
postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
···
90
const publishedInPublication = data.leaflets_in_publications?.find(
91
(l) => l.doc,
92
);
93
+
const publishedStandalone = data.leaflets_to_documents?.find(
94
+
(l) => !!l.documents,
95
+
);
96
97
const documentUri =
98
publishedInPublication?.documents?.uri ?? publishedStandalone?.document;
99
100
// Compute the full post URL for sharing
101
let postShareLink: string | undefined;
102
+
if (publishedInPublication?.publications && publishedInPublication.documents) {
103
// Published in a publication - use publication URL + document rkey
104
const docUri = new AtUri(publishedInPublication.documents.uri);
105
postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
+1
-1
components/Pages/Page.tsx
+1
-1
components/Pages/Page.tsx
···
12
import { Blocks } from "components/Blocks";
13
import { PublicationMetadata } from "./PublicationMetadata";
14
import { useCardBorderHidden } from "./useCardBorderHidden";
15
-
import { focusPage } from ".";
16
import { PageOptions } from "./PageOptions";
17
import { CardThemeProvider } from "components/ThemeManager/ThemeProvider";
18
import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
···
12
import { Blocks } from "components/Blocks";
13
import { PublicationMetadata } from "./PublicationMetadata";
14
import { useCardBorderHidden } from "./useCardBorderHidden";
15
+
import { focusPage } from "src/utils/focusPage";
16
import { PageOptions } from "./PageOptions";
17
import { CardThemeProvider } from "components/ThemeManager/ThemeProvider";
18
import { useDrawerOpen } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer";
+156
-84
components/Pages/PublicationMetadata.tsx
+156
-84
components/Pages/PublicationMetadata.tsx
···
1
import Link from "next/link";
2
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
3
-
import { useRef } from "react";
4
import { useReplicache } from "src/replicache";
5
import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
6
import { Separator } from "components/Layout";
7
import { AtUri } from "@atproto/syntax";
8
-
import { PubLeafletDocument } from "lexicons/api";
9
import {
10
getBasePublicationURL,
11
getPublicationURL,
···
13
import { useSubscribe } from "src/replicache/useSubscribe";
14
import { useEntitySetContext } from "components/EntitySetProvider";
15
import { timeAgo } from "src/utils/timeAgo";
16
import { useIdentityData } from "components/IdentityProvider";
17
export const PublicationMetadata = () => {
18
let { rep } = useReplicache();
19
let { data: pub } = useLeafletPublicationData();
···
23
tx.get<string>("publication_description"),
24
);
25
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
26
let publishedAt = record?.publishedAt;
27
28
if (!pub) return null;
···
33
if (typeof description !== "string") {
34
description = pub?.description || "";
35
}
36
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>
94
</div>
95
-
) : (
96
-
<p className="text-sm text-tertiary pt-2">Draft</p>
97
-
)}
98
-
</div>
99
);
100
};
101
···
178
if (!pub) return null;
179
180
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>
185
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>
194
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>
200
</div>
201
-
) : (
202
-
<p className="text-sm text-tertiary pt-2">Draft</p>
203
-
)}
204
-
</div>
205
);
206
};
···
1
import Link from "next/link";
2
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
3
+
import { useRef, useState } from "react";
4
import { useReplicache } from "src/replicache";
5
import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
6
import { Separator } from "components/Layout";
7
import { AtUri } from "@atproto/syntax";
8
+
import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
9
import {
10
getBasePublicationURL,
11
getPublicationURL,
···
13
import { useSubscribe } from "src/replicache/useSubscribe";
14
import { useEntitySetContext } from "components/EntitySetProvider";
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";
21
import { useIdentityData } from "components/IdentityProvider";
22
+
import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader";
23
export const PublicationMetadata = () => {
24
let { rep } = useReplicache();
25
let { data: pub } = useLeafletPublicationData();
···
29
tx.get<string>("publication_description"),
30
);
31
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
32
+
let pubRecord = pub?.publications?.record as
33
+
| PubLeafletPublication.Record
34
+
| undefined;
35
let publishedAt = record?.publishedAt;
36
37
if (!pub) return null;
···
42
if (typeof description !== "string") {
43
description = pub?.description || "";
44
}
45
+
let tags = true;
46
+
47
return (
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>
66
</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
+
/>
136
);
137
};
138
···
215
if (!pub) return null;
216
217
return (
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
+
};
236
237
+
const AddTags = () => {
238
+
let { data: pub } = useLeafletPublicationData();
239
+
let { rep } = useReplicache();
240
+
let record = pub?.documents?.data as PubLeafletDocument.Record | null;
241
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"}
272
</div>
273
+
}
274
+
>
275
+
<TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} />
276
+
</Popover>
277
);
278
};
+2
-75
components/Pages/index.tsx
+2
-75
components/Pages/index.tsx
···
4
import { useUIState } from "src/useUIState";
5
import { useSearchParams } from "next/navigation";
6
7
-
import { focusBlock } from "src/utils/focusBlock";
8
-
import { elementId } from "src/utils/elementId";
9
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
import { useCardBorderHidden } from "./useCardBorderHidden";
17
import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout";
18
import { LeafletSidebar } from "app/[leaflet_id]/Sidebar";
···
62
);
63
}
64
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 = () => {
132
useUIState.setState(() => ({
133
focusedEntity: null,
134
selectedBlocks: [],
···
4
import { useUIState } from "src/useUIState";
5
import { useSearchParams } from "next/navigation";
6
7
+
import { useEntity } from "src/replicache";
8
9
import { useCardBorderHidden } from "./useCardBorderHidden";
10
import { BookendSpacer, SandwichSpacer } from "components/LeafletLayout";
11
import { LeafletSidebar } from "app/[leaflet_id]/Sidebar";
···
55
);
56
}
57
58
+
const blurPage = () => {
59
useUIState.setState(() => ({
60
focusedEntity: null,
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
import { useMemo, useState } from "react";
3
import { parseColor } from "react-aria-components";
4
import { useEntity } from "src/replicache";
5
-
import { getColorContrast } from "./ThemeProvider";
6
import { useColorAttribute, colorToString } from "./useColorAttribute";
7
import { BaseThemeProvider } from "./ThemeProvider";
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
···
84
<div
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
style={{
87
-
backgroundImage: `url(${backgroundImage})`,
88
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
89
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
90
}}
···
2
import { useMemo, useState } from "react";
3
import { parseColor } from "react-aria-components";
4
import { useEntity } from "src/replicache";
5
+
import { getColorContrast } from "./themeUtils";
6
import { useColorAttribute, colorToString } from "./useColorAttribute";
7
import { BaseThemeProvider } from "./ThemeProvider";
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
···
84
<div
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
style={{
87
+
backgroundImage: backgroundImage
88
+
? `url(${backgroundImage})`
89
+
: undefined,
90
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
91
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
92
}}
+1
-40
components/ThemeManager/ThemeProvider.tsx
+1
-40
components/ThemeManager/ThemeProvider.tsx
···
5
CSSProperties,
6
useContext,
7
useEffect,
8
-
useMemo,
9
-
useState,
10
} from "react";
11
import {
12
colorToString,
···
14
useColorAttributeNullable,
15
} from "./useColorAttribute";
16
import { Color as AriaColor, parseColor } from "react-aria-components";
17
-
import { parse, contrastLstar, ColorSpace, sRGB } from "colorjs.io/fn";
18
19
import { useEntity } from "src/replicache";
20
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
···
23
PublicationThemeProvider,
24
} from "./PublicationThemeProvider";
25
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
-
};
54
55
// define a function to set an Aria Color to a CSS Variable in RGB
56
function setCSSVariableToColor(
···
368
);
369
};
370
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
-
}
···
5
CSSProperties,
6
useContext,
7
useEffect,
8
} from "react";
9
import {
10
colorToString,
···
12
useColorAttributeNullable,
13
} from "./useColorAttribute";
14
import { Color as AriaColor, parseColor } from "react-aria-components";
15
16
import { useEntity } from "src/replicache";
17
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
···
20
PublicationThemeProvider,
21
} from "./PublicationThemeProvider";
22
import { PubLeafletPublication } from "lexicons/api";
23
+
import { getColorContrast } from "./themeUtils";
24
25
// define a function to set an Aria Color to a CSS Variable in RGB
26
function setCSSVariableToColor(
···
338
);
339
};
340
+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
import { Color, parseColor } from "react-aria-components";
3
import { useEntity, useReplicache } from "src/replicache";
4
import { FilterAttributes } from "src/replicache/attributes";
5
-
import { ThemeDefaults } from "./ThemeProvider";
6
7
export function useColorAttribute(
8
entity: string | null,
···
2
import { Color, parseColor } from "react-aria-components";
3
import { useEntity, useReplicache } from "src/replicache";
4
import { FilterAttributes } from "src/replicache/attributes";
5
+
import { ThemeDefaults } from "./themeUtils";
6
7
export function useColorAttribute(
8
entity: string | null,
+5
-14
components/Toolbar/BlockToolbar.tsx
+5
-14
components/Toolbar/BlockToolbar.tsx
···
2
import { ToolbarButton } from ".";
3
import { Separator, ShortcutKey } from "components/Layout";
4
import { metaKey } from "src/utils/metaKey";
5
-
import { getBlocksWithType } from "src/hooks/queries/useBlocks";
6
import { useUIState } from "src/useUIState";
7
import { LockBlockButton } from "./LockBlockButton";
8
import { TextAlignmentButton } from "./TextAlignmentToolbar";
9
import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
10
import { DeleteSmall } from "components/Icons/DeleteSmall";
11
12
export const BlockToolbar = (props: {
13
setToolbarState: (
···
66
67
const MoveBlockButtons = () => {
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
return (
81
<>
82
<ToolbarButton
83
hiddenOnCanvas
84
onClick={async () => {
85
-
let [sortedBlocks, siblings] = await getSortedSelection();
86
if (sortedBlocks.length > 1) return;
87
let block = sortedBlocks[0];
88
let previousBlock =
···
139
<ToolbarButton
140
hiddenOnCanvas
141
onClick={async () => {
142
-
let [sortedBlocks, siblings] = await getSortedSelection();
143
if (sortedBlocks.length > 1) return;
144
let block = sortedBlocks[0];
145
let nextBlock = siblings
···
2
import { ToolbarButton } from ".";
3
import { Separator, ShortcutKey } from "components/Layout";
4
import { metaKey } from "src/utils/metaKey";
5
import { useUIState } from "src/useUIState";
6
import { LockBlockButton } from "./LockBlockButton";
7
import { TextAlignmentButton } from "./TextAlignmentToolbar";
8
import { ImageFullBleedButton, ImageAltTextButton } from "./ImageToolbar";
9
import { DeleteSmall } from "components/Icons/DeleteSmall";
10
+
import { getSortedSelection } from "components/SelectionManager/selectionState";
11
12
export const BlockToolbar = (props: {
13
setToolbarState: (
···
66
67
const MoveBlockButtons = () => {
68
let { rep } = useReplicache();
69
return (
70
<>
71
<ToolbarButton
72
hiddenOnCanvas
73
onClick={async () => {
74
+
if (!rep) return;
75
+
let [sortedBlocks, siblings] = await getSortedSelection(rep);
76
if (sortedBlocks.length > 1) return;
77
let block = sortedBlocks[0];
78
let previousBlock =
···
129
<ToolbarButton
130
hiddenOnCanvas
131
onClick={async () => {
132
+
if (!rep) return;
133
+
let [sortedBlocks, siblings] = await getSortedSelection(rep);
134
if (sortedBlocks.length > 1) return;
135
let block = sortedBlocks[0];
136
let nextBlock = siblings
+1
-1
components/Toolbar/MultiSelectToolbar.tsx
+1
-1
components/Toolbar/MultiSelectToolbar.tsx
···
8
import { LockBlockButton } from "./LockBlockButton";
9
import { Props } from "components/Icons/Props";
10
import { TextAlignmentButton } from "./TextAlignmentToolbar";
11
-
import { getSortedSelection } from "components/SelectionManager";
12
13
export const MultiselectToolbar = (props: {
14
setToolbarState: (
···
8
import { LockBlockButton } from "./LockBlockButton";
9
import { Props } from "components/Icons/Props";
10
import { TextAlignmentButton } from "./TextAlignmentToolbar";
11
+
import { getSortedSelection } from "components/SelectionManager/selectionState";
12
13
export const MultiselectToolbar = (props: {
14
setToolbarState: (
+2
-1
components/Toolbar/index.tsx
+2
-1
components/Toolbar/index.tsx
···
13
import { TextToolbar } from "./TextToolbar";
14
import { BlockToolbar } from "./BlockToolbar";
15
import { MultiselectToolbar } from "./MultiSelectToolbar";
16
-
import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock";
17
import { TooltipButton } from "components/Buttons";
18
import { TextAlignmentToolbar } from "./TextAlignmentToolbar";
19
import { useIsMobile } from "src/hooks/isMobile";
···
13
import { TextToolbar } from "./TextToolbar";
14
import { BlockToolbar } from "./BlockToolbar";
15
import { MultiselectToolbar } from "./MultiSelectToolbar";
16
+
import { AreYouSure } from "components/Blocks/DeleteBlock";
17
+
import { deleteBlock } from "src/utils/deleteBlock";
18
import { TooltipButton } from "components/Buttons";
19
import { TextAlignmentToolbar } from "./TextAlignmentToolbar";
20
import { useIsMobile } from "src/hooks/isMobile";
+1
-1
components/utils/UpdateLeafletTitle.tsx
+1
-1
components/utils/UpdateLeafletTitle.tsx
···
8
import { useEntity, useReplicache } from "src/replicache";
9
import * as Y from "yjs";
10
import * as base64 from "base64-js";
11
-
import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment";
12
import { useParams, useRouter, useSearchParams } from "next/navigation";
13
import { focusBlock } from "src/utils/focusBlock";
14
import { useIsMobile } from "src/hooks/isMobile";
···
8
import { useEntity, useReplicache } from "src/replicache";
9
import * as Y from "yjs";
10
import * as base64 from "base64-js";
11
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
12
import { useParams, useRouter, useSearchParams } from "next/navigation";
13
import { focusBlock } from "src/utils/focusBlock";
14
import { useIsMobile } from "src/hooks/isMobile";
+31
lexicons/api/lexicons.ts
+31
lexicons/api/lexicons.ts
···
1440
type: 'ref',
1441
ref: 'lex:pub.leaflet.publication#theme',
1442
},
1443
pages: {
1444
type: 'array',
1445
items: {
···
1865
type: 'union',
1866
refs: [
1867
'lex:pub.leaflet.richtext.facet#link',
1868
'lex:pub.leaflet.richtext.facet#code',
1869
'lex:pub.leaflet.richtext.facet#highlight',
1870
'lex:pub.leaflet.richtext.facet#underline',
···
1901
properties: {
1902
uri: {
1903
type: 'string',
1904
},
1905
},
1906
},
···
1440
type: 'ref',
1441
ref: 'lex:pub.leaflet.publication#theme',
1442
},
1443
+
tags: {
1444
+
type: 'array',
1445
+
items: {
1446
+
type: 'string',
1447
+
maxLength: 50,
1448
+
},
1449
+
},
1450
pages: {
1451
type: 'array',
1452
items: {
···
1872
type: 'union',
1873
refs: [
1874
'lex:pub.leaflet.richtext.facet#link',
1875
+
'lex:pub.leaflet.richtext.facet#didMention',
1876
+
'lex:pub.leaflet.richtext.facet#atMention',
1877
'lex:pub.leaflet.richtext.facet#code',
1878
'lex:pub.leaflet.richtext.facet#highlight',
1879
'lex:pub.leaflet.richtext.facet#underline',
···
1910
properties: {
1911
uri: {
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',
1935
},
1936
},
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
index: ByteSlice
21
features: (
22
| $Typed<Link>
23
+
| $Typed<DidMention>
24
+
| $Typed<AtMention>
25
| $Typed<Code>
26
| $Typed<Highlight>
27
| $Typed<Underline>
···
74
75
export function validateLink<V>(v: V) {
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)
109
}
110
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
"type": "union",
21
"refs": [
22
"#link",
23
+
"#didMention",
24
+
"#atMention",
25
"#code",
26
"#highlight",
27
"#underline",
···
61
"properties": {
62
"uri": {
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"
90
}
91
}
92
},
+1
lexicons/src/document.ts
+1
lexicons/src/document.ts
+12
lexicons/src/facet.ts
+12
lexicons/src/facet.ts
···
9
uri: { type: "string" },
10
},
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
+
},
24
code: {
25
type: "object",
26
description: "Facet feature for inline code.",
+68
-49
package-lock.json
+68
-49
package-lock.json
···
48
"inngest": "^3.40.1",
49
"ioredis": "^5.6.1",
50
"katex": "^0.16.22",
51
"linkifyjs": "^4.2.0",
52
"luxon": "^3.7.2",
53
"multiformats": "^13.3.2",
54
-
"next": "16.0.3",
55
"pg": "^8.16.3",
56
"prosemirror-commands": "^1.5.2",
57
"prosemirror-inputrules": "^1.4.0",
···
59
"prosemirror-model": "^1.21.0",
60
"prosemirror-schema-basic": "^1.2.2",
61
"prosemirror-state": "^1.4.3",
62
-
"react": "19.2.0",
63
"react-aria-components": "^1.8.0",
64
"react-day-picker": "^9.3.0",
65
-
"react-dom": "19.2.0",
66
"react-use-measure": "^2.1.1",
67
"redlock": "^5.0.0-beta.2",
68
"rehype-parse": "^9.0.0",
···
2734
}
2735
},
2736
"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=="
2740
},
2741
"node_modules/@next/eslint-plugin-next": {
2742
"version": "16.0.3",
···
2804
}
2805
},
2806
"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==",
2810
"cpu": [
2811
"arm64"
2812
],
2813
"optional": true,
2814
"os": [
2815
"darwin"
···
2819
}
2820
},
2821
"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
"cpu": [
2826
"x64"
2827
],
2828
"optional": true,
2829
"os": [
2830
"darwin"
···
2834
}
2835
},
2836
"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==",
2840
"cpu": [
2841
"arm64"
2842
],
2843
"optional": true,
2844
"os": [
2845
"linux"
···
2849
}
2850
},
2851
"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==",
2855
"cpu": [
2856
"arm64"
2857
],
2858
"optional": true,
2859
"os": [
2860
"linux"
···
2864
}
2865
},
2866
"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==",
2870
"cpu": [
2871
"x64"
2872
],
2873
"optional": true,
2874
"os": [
2875
"linux"
···
2879
}
2880
},
2881
"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==",
2885
"cpu": [
2886
"x64"
2887
],
2888
"optional": true,
2889
"os": [
2890
"linux"
···
2894
}
2895
},
2896
"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==",
2900
"cpu": [
2901
"arm64"
2902
],
2903
"optional": true,
2904
"os": [
2905
"win32"
···
2909
}
2910
},
2911
"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==",
2915
"cpu": [
2916
"x64"
2917
],
2918
"optional": true,
2919
"os": [
2920
"win32"
···
13360
"json-buffer": "3.0.1"
13361
}
13362
},
13363
"node_modules/language-subtag-registry": {
13364
"version": "0.3.23",
13365
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
···
15108
}
15109
},
15110
"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==",
15114
"dependencies": {
15115
-
"@next/env": "16.0.3",
15116
"@swc/helpers": "0.5.15",
15117
"caniuse-lite": "^1.0.30001579",
15118
"postcss": "8.4.31",
···
15125
"node": ">=20.9.0"
15126
},
15127
"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",
15136
"sharp": "^0.34.4"
15137
},
15138
"peerDependencies": {
···
16321
}
16322
},
16323
"node_modules/react": {
16324
-
"version": "19.2.0",
16325
-
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
16326
-
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
16327
"engines": {
16328
"node": ">=0.10.0"
16329
}
···
16442
}
16443
},
16444
"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==",
16448
"dependencies": {
16449
"scheduler": "^0.27.0"
16450
},
16451
"peerDependencies": {
16452
-
"react": "^19.2.0"
16453
}
16454
},
16455
"node_modules/react-is": {
···
48
"inngest": "^3.40.1",
49
"ioredis": "^5.6.1",
50
"katex": "^0.16.22",
51
+
"l": "^0.6.0",
52
"linkifyjs": "^4.2.0",
53
"luxon": "^3.7.2",
54
"multiformats": "^13.3.2",
55
+
"next": "^16.0.7",
56
"pg": "^8.16.3",
57
"prosemirror-commands": "^1.5.2",
58
"prosemirror-inputrules": "^1.4.0",
···
60
"prosemirror-model": "^1.21.0",
61
"prosemirror-schema-basic": "^1.2.2",
62
"prosemirror-state": "^1.4.3",
63
+
"react": "19.2.1",
64
"react-aria-components": "^1.8.0",
65
"react-day-picker": "^9.3.0",
66
+
"react-dom": "19.2.1",
67
"react-use-measure": "^2.1.1",
68
"redlock": "^5.0.0-beta.2",
69
"rehype-parse": "^9.0.0",
···
2735
}
2736
},
2737
"node_modules/@next/env": {
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"
2742
},
2743
"node_modules/@next/eslint-plugin-next": {
2744
"version": "16.0.3",
···
2806
}
2807
},
2808
"node_modules/@next/swc-darwin-arm64": {
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==",
2812
"cpu": [
2813
"arm64"
2814
],
2815
+
"license": "MIT",
2816
"optional": true,
2817
"os": [
2818
"darwin"
···
2822
}
2823
},
2824
"node_modules/@next/swc-darwin-x64": {
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==",
2828
"cpu": [
2829
"x64"
2830
],
2831
+
"license": "MIT",
2832
"optional": true,
2833
"os": [
2834
"darwin"
···
2838
}
2839
},
2840
"node_modules/@next/swc-linux-arm64-gnu": {
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==",
2844
"cpu": [
2845
"arm64"
2846
],
2847
+
"license": "MIT",
2848
"optional": true,
2849
"os": [
2850
"linux"
···
2854
}
2855
},
2856
"node_modules/@next/swc-linux-arm64-musl": {
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==",
2860
"cpu": [
2861
"arm64"
2862
],
2863
+
"license": "MIT",
2864
"optional": true,
2865
"os": [
2866
"linux"
···
2870
}
2871
},
2872
"node_modules/@next/swc-linux-x64-gnu": {
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==",
2876
"cpu": [
2877
"x64"
2878
],
2879
+
"license": "MIT",
2880
"optional": true,
2881
"os": [
2882
"linux"
···
2886
}
2887
},
2888
"node_modules/@next/swc-linux-x64-musl": {
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==",
2892
"cpu": [
2893
"x64"
2894
],
2895
+
"license": "MIT",
2896
"optional": true,
2897
"os": [
2898
"linux"
···
2902
}
2903
},
2904
"node_modules/@next/swc-win32-arm64-msvc": {
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==",
2908
"cpu": [
2909
"arm64"
2910
],
2911
+
"license": "MIT",
2912
"optional": true,
2913
"os": [
2914
"win32"
···
2918
}
2919
},
2920
"node_modules/@next/swc-win32-x64-msvc": {
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==",
2924
"cpu": [
2925
"x64"
2926
],
2927
+
"license": "MIT",
2928
"optional": true,
2929
"os": [
2930
"win32"
···
13370
"json-buffer": "3.0.1"
13371
}
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
+
},
13379
"node_modules/language-subtag-registry": {
13380
"version": "0.3.23",
13381
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
···
15124
}
15125
},
15126
"node_modules/next": {
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",
15131
"dependencies": {
15132
+
"@next/env": "16.0.7",
15133
"@swc/helpers": "0.5.15",
15134
"caniuse-lite": "^1.0.30001579",
15135
"postcss": "8.4.31",
···
15142
"node": ">=20.9.0"
15143
},
15144
"optionalDependencies": {
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",
15153
"sharp": "^0.34.4"
15154
},
15155
"peerDependencies": {
···
16338
}
16339
},
16340
"node_modules/react": {
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",
16345
"engines": {
16346
"node": ">=0.10.0"
16347
}
···
16460
}
16461
},
16462
"node_modules/react-dom": {
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",
16467
"dependencies": {
16468
"scheduler": "^0.27.0"
16469
},
16470
"peerDependencies": {
16471
+
"react": "^19.2.1"
16472
}
16473
},
16474
"node_modules/react-is": {
+4
-3
package.json
+4
-3
package.json
···
58
"inngest": "^3.40.1",
59
"ioredis": "^5.6.1",
60
"katex": "^0.16.22",
61
"linkifyjs": "^4.2.0",
62
"luxon": "^3.7.2",
63
"multiformats": "^13.3.2",
64
-
"next": "16.0.3",
65
"pg": "^8.16.3",
66
"prosemirror-commands": "^1.5.2",
67
"prosemirror-inputrules": "^1.4.0",
···
69
"prosemirror-model": "^1.21.0",
70
"prosemirror-schema-basic": "^1.2.2",
71
"prosemirror-state": "^1.4.3",
72
-
"react": "19.2.0",
73
"react-aria-components": "^1.8.0",
74
"react-day-picker": "^9.3.0",
75
-
"react-dom": "19.2.0",
76
"react-use-measure": "^2.1.1",
77
"redlock": "^5.0.0-beta.2",
78
"rehype-parse": "^9.0.0",
···
58
"inngest": "^3.40.1",
59
"ioredis": "^5.6.1",
60
"katex": "^0.16.22",
61
+
"l": "^0.6.0",
62
"linkifyjs": "^4.2.0",
63
"luxon": "^3.7.2",
64
"multiformats": "^13.3.2",
65
+
"next": "^16.0.7",
66
"pg": "^8.16.3",
67
"prosemirror-commands": "^1.5.2",
68
"prosemirror-inputrules": "^1.4.0",
···
70
"prosemirror-model": "^1.21.0",
71
"prosemirror-schema-basic": "^1.2.2",
72
"prosemirror-state": "^1.4.3",
73
+
"react": "19.2.1",
74
"react-aria-components": "^1.8.0",
75
"react-day-picker": "^9.3.0",
76
+
"react-dom": "19.2.1",
77
"react-use-measure": "^2.1.1",
78
"redlock": "^5.0.0-beta.2",
79
"rehype-parse": "^9.0.0",
+3
-1
src/hooks/useLocalizedDate.ts
+3
-1
src/hooks/useLocalizedDate.ts
···
28
29
// On initial page load, use header timezone. After hydration, use system timezone
30
const effectiveTimezone = !hasPageLoaded
31
+
? timezone || "UTC"
32
: Intl.DateTimeFormat().resolvedOptions().timeZone;
33
+
34
+
console.log("tz", effectiveTimezone);
35
36
// Apply timezone if available
37
if (effectiveTimezone) {
+4
-3
src/hooks/usePreserveScroll.ts
+4
-3
src/hooks/usePreserveScroll.ts
+254
-37
src/notifications.ts
+254
-37
src/notifications.ts
···
2
3
import { supabaseServerClient } from "supabase/serverClient";
4
import { Tables, TablesInsert } from "supabase/database.types";
5
6
type NotificationRow = Tables<"notifications">;
7
···
12
export type NotificationData =
13
| { type: "comment"; comment_uri: string; parent_uri?: string }
14
| { type: "subscribe"; subscription_uri: string }
15
-
| { type: "quote"; bsky_post_uri: string; document_uri: string };
16
17
export type HydratedNotification =
18
| HydratedCommentNotification
19
| HydratedSubscribeNotification
20
-
| HydratedQuoteNotification;
21
export async function hydrateNotifications(
22
notifications: NotificationRow[],
23
): Promise<Array<HydratedNotification>> {
24
// Call all hydrators in parallel
25
-
const [commentNotifications, subscribeNotifications, quoteNotifications] = await Promise.all([
26
hydrateCommentNotifications(notifications),
27
hydrateSubscribeNotifications(notifications),
28
hydrateQuoteNotifications(notifications),
29
]);
30
31
// Combine all hydrated notifications
32
-
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications];
33
34
// Sort by created_at to maintain order
35
allHydrated.sort(
···
73
)
74
.in("uri", commentUris);
75
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
-
}));
89
}
90
91
export type HydratedSubscribeNotification = Awaited<
···
113
.select("*, identities(bsky_profiles(*)), publications(*)")
114
.in("uri", subscriptionUris);
115
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
-
}));
126
}
127
128
export type HydratedQuoteNotification = Awaited<
···
153
.select("*, documents_in_publications(publications(*))")
154
.in("uri", documentUris);
155
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
-
}));
166
}
167
168
export async function pingIdentityToUpdateNotification(did: string) {
···
2
3
import { supabaseServerClient } from "supabase/serverClient";
4
import { Tables, TablesInsert } from "supabase/database.types";
5
+
import { AtUri } from "@atproto/syntax";
6
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
7
8
type NotificationRow = Tables<"notifications">;
9
···
14
export type NotificationData =
15
| { type: "comment"; comment_uri: string; parent_uri?: string }
16
| { type: "subscribe"; subscription_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 };
24
25
export type HydratedNotification =
26
| HydratedCommentNotification
27
| HydratedSubscribeNotification
28
+
| HydratedQuoteNotification
29
+
| HydratedMentionNotification
30
+
| HydratedCommentMentionNotification;
31
export async function hydrateNotifications(
32
notifications: NotificationRow[],
33
): Promise<Array<HydratedNotification>> {
34
// Call all hydrators in parallel
35
+
const [commentNotifications, subscribeNotifications, quoteNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([
36
hydrateCommentNotifications(notifications),
37
hydrateSubscribeNotifications(notifications),
38
hydrateQuoteNotifications(notifications),
39
+
hydrateMentionNotifications(notifications),
40
+
hydrateCommentMentionNotifications(notifications),
41
]);
42
43
// Combine all hydrated notifications
44
+
const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...mentionNotifications, ...commentMentionNotifications];
45
46
// Sort by created_at to maintain order
47
allHydrated.sort(
···
85
)
86
.in("uri", commentUris);
87
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);
105
}
106
107
export type HydratedSubscribeNotification = Awaited<
···
129
.select("*, identities(bsky_profiles(*)), publications(*)")
130
.in("uri", subscriptionUris);
131
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);
146
}
147
148
export type HydratedQuoteNotification = Awaited<
···
173
.select("*, documents_in_publications(publications(*))")
174
.in("uri", documentUris);
175
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);
383
}
384
385
export async function pingIdentityToUpdateNotification(did: string) {
+34
-8
src/replicache/mutations.ts
+34
-8
src/replicache/mutations.ts
···
609
};
610
611
const updatePublicationDraft: Mutation<{
612
-
title: string;
613
-
description: string;
614
}> = async (args, ctx) => {
615
await ctx.runOnServer(async (serverCtx) => {
616
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);
621
});
622
await ctx.runOnClient(async ({ tx }) => {
623
-
await tx.set("publication_title", args.title);
624
-
await tx.set("publication_description", args.description);
625
});
626
};
627
···
609
};
610
611
const updatePublicationDraft: Mutation<{
612
+
title?: string;
613
+
description?: string;
614
+
tags?: string[];
615
}> = async (args, ctx) => {
616
await ctx.runOnServer(async (serverCtx) => {
617
console.log("updating");
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
+
}
644
});
645
await ctx.runOnClient(async ({ tx }) => {
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);
651
});
652
};
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
hostname = "leaflet.pub";
18
}
19
let full_path = `${protocol}://${hostname}${path}`;
20
-
return getWebpageImage(full_path, options);
21
}
22
23
export async function getWebpageImage(
24
url: string,
25
options?: {
26
width?: number;
27
height?: number;
28
deviceScaleFactor?: number;
···
39
},
40
body: JSON.stringify({
41
url,
42
scrollPage: true,
43
addStyleTag: [
44
{
···
17
hostname = "leaflet.pub";
18
}
19
let full_path = `${protocol}://${hostname}${path}`;
20
+
return getWebpageImage(full_path, {
21
+
...options,
22
+
setJavaScriptEnabled: false,
23
+
});
24
}
25
26
export async function getWebpageImage(
27
url: string,
28
options?: {
29
+
setJavaScriptEnabled?: boolean;
30
width?: number;
31
height?: number;
32
deviceScaleFactor?: number;
···
43
},
44
body: JSON.stringify({
45
url,
46
+
setJavaScriptEnabled: options?.setJavaScriptEnabled,
47
scrollPage: true,
48
addStyleTag: [
49
{
+7
-31
src/utils/getPublicationMetadataFromLeafletData.ts
+7
-31
src/utils/getPublicationMetadataFromLeafletData.ts
···
32
(p) => p.leaflets_in_publications?.length,
33
)?.leaflets_in_publications?.[0];
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
-
) {
45
// Transform standalone document data to match the expected format
46
pubData = {
47
...standaloneDoc,
48
publications: null, // No publication for standalone docs
49
doc: standaloneDoc.document,
50
-
leaflet: data.id,
51
};
52
}
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
return pubData;
74
}
···
32
(p) => p.leaflets_in_publications?.length,
33
)?.leaflets_in_publications?.[0];
34
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) {
42
// Transform standalone document data to match the expected format
43
pubData = {
44
...standaloneDoc,
45
publications: null, // No publication for standalone docs
46
doc: standaloneDoc.document,
47
};
48
}
49
return pubData;
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
Row: {
632
created_at: string
633
description: string
634
-
document: string | null
635
leaflet: string
636
title: string
637
}
638
Insert: {
639
created_at?: string
640
description?: string
641
-
document?: string | null
642
leaflet: string
643
title?: string
644
}
645
Update: {
646
created_at?: string
647
description?: string
648
-
document?: string | null
649
leaflet?: string
650
title?: string
651
}
···
660
{
661
foreignKeyName: "leaflets_to_documents_leaflet_fkey"
662
columns: ["leaflet"]
663
-
isOneToOne: true
664
referencedRelation: "permission_tokens"
665
referencedColumns: ["id"]
666
},
···
1157
client_group_id: string
1158
}
1159
Returns: Database["public"]["CompositeTypes"]["pull_result"]
1160
}
1161
}
1162
Enums: {
···
631
Row: {
632
created_at: string
633
description: string
634
+
document: string
635
leaflet: string
636
title: string
637
}
638
Insert: {
639
created_at?: string
640
description?: string
641
+
document: string
642
leaflet: string
643
title?: string
644
}
645
Update: {
646
created_at?: string
647
description?: string
648
+
document?: string
649
leaflet?: string
650
title?: string
651
}
···
660
{
661
foreignKeyName: "leaflets_to_documents_leaflet_fkey"
662
columns: ["leaflet"]
663
+
isOneToOne: false
664
referencedRelation: "permission_tokens"
665
referencedColumns: ["id"]
666
},
···
1157
client_group_id: string
1158
}
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
+
}[]
1169
}
1170
}
1171
Enums: {