+6
-6
actions/publishToPublication.ts
+6
-6
actions/publishToPublication.ts
···
305
305
if (!b) return [];
306
306
let block: PubLeafletPagesLinearDocument.Block = {
307
307
$type: "pub.leaflet.pages.linearDocument#block",
308
+
alignment,
308
309
block: b,
309
310
};
310
-
if (alignment) block.alignment = alignment;
311
311
return [block];
312
312
} else {
313
313
let block: PubLeafletPagesLinearDocument.Block = {
···
405
405
let [stringValue, facets] = getBlockContent(b.value);
406
406
let block: $Typed<PubLeafletBlocksHeader.Main> = {
407
407
$type: "pub.leaflet.blocks.header",
408
-
level: Math.floor(headingLevel?.data.value || 1),
408
+
level: headingLevel?.data.value || 1,
409
409
plaintext: stringValue,
410
410
facets,
411
411
};
···
438
438
let block: $Typed<PubLeafletBlocksIframe.Main> = {
439
439
$type: "pub.leaflet.blocks.iframe",
440
440
url: url.data.value,
441
-
height: Math.floor(height?.data.value || 600),
441
+
height: height?.data.value || 600,
442
442
};
443
443
return block;
444
444
}
···
452
452
$type: "pub.leaflet.blocks.image",
453
453
image: blobref,
454
454
aspectRatio: {
455
-
height: Math.floor(image.data.height),
456
-
width: Math.floor(image.data.width),
455
+
height: image.data.height,
456
+
width: image.data.width,
457
457
},
458
458
alt: altText ? altText.data.value : undefined,
459
459
};
···
770
770
image: blob.data.blob,
771
771
repeat: backgroundImageRepeat?.data.value ? true : false,
772
772
...(backgroundImageRepeat?.data.value && {
773
-
width: Math.floor(backgroundImageRepeat.data.value),
773
+
width: backgroundImageRepeat.data.value,
774
774
}),
775
775
};
776
776
}
+1
-3
app/(home-pages)/discover/page.tsx
+1
-3
app/(home-pages)/discover/page.tsx
···
17
17
return (
18
18
<DashboardLayout
19
19
id="discover"
20
-
cardBorderHidden={false}
21
20
currentPage="discover"
22
21
defaultTab="default"
23
22
actions={null}
···
32
31
}
33
32
34
33
const DiscoverContent = async (props: { order: string }) => {
35
-
const orderValue =
36
-
props.order === "popular" ? "popular" : "recentlyUpdated";
34
+
const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated";
37
35
let { publications, nextCursor } = await getPublications(orderValue);
38
36
39
37
return (
-8
app/(home-pages)/home/HomeLayout.tsx
-8
app/(home-pages)/home/HomeLayout.tsx
···
20
20
useDashboardState,
21
21
} from "components/PageLayouts/DashboardLayout";
22
22
import { Actions } from "./Actions/Actions";
23
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
24
23
import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
25
24
import { useState } from "react";
26
25
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
···
56
55
props.entityID,
57
56
"theme/background-image",
58
57
);
59
-
let cardBorderHidden = !!useCardBorderHidden(props.entityID);
60
58
61
59
let [searchValue, setSearchValue] = useState("");
62
60
let [debouncedSearchValue, setDebouncedSearchValue] = useState("");
···
81
79
return (
82
80
<DashboardLayout
83
81
id="home"
84
-
cardBorderHidden={cardBorderHidden}
85
82
currentPage="home"
86
83
defaultTab="home"
87
84
actions={<Actions />}
···
101
98
<HomeLeafletList
102
99
titles={props.titles}
103
100
initialFacts={props.initialFacts}
104
-
cardBorderHidden={cardBorderHidden}
105
101
searchValue={debouncedSearchValue}
106
102
/>
107
103
),
···
117
113
[root_entity: string]: Fact<Attribute>[];
118
114
};
119
115
searchValue: string;
120
-
cardBorderHidden: boolean;
121
116
}) {
122
117
let { identity } = useIdentityData();
123
118
let { data: initialFacts } = useSWR(
···
171
166
searchValue={props.searchValue}
172
167
leaflets={leaflets}
173
168
titles={initialFacts?.titles || {}}
174
-
cardBorderHidden={props.cardBorderHidden}
175
169
initialFacts={initialFacts?.facts || {}}
176
170
showPreview
177
171
/>
···
192
186
[root_entity: string]: Fact<Attribute>[];
193
187
};
194
188
searchValue: string;
195
-
cardBorderHidden: boolean;
196
189
showPreview?: boolean;
197
190
}) {
198
191
let { identity } = useIdentityData();
···
238
231
loggedIn={!!identity}
239
232
display={display}
240
233
added_at={added_at}
241
-
cardBorderHidden={props.cardBorderHidden}
242
234
index={index}
243
235
showPreview={props.showPreview}
244
236
isHidden={
+6
-5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
+6
-5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
···
4
4
import { useState, useRef, useEffect } from "react";
5
5
import { SpeedyLink } from "components/SpeedyLink";
6
6
import { useLeafletPublicationStatus } from "components/PageSWRDataProvider";
7
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
8
8
9
export const LeafletListItem = (props: {
9
10
archived?: boolean | null;
10
11
loggedIn: boolean;
11
12
display: "list" | "grid";
12
-
cardBorderHidden: boolean;
13
13
added_at: string;
14
14
title?: string;
15
15
index: number;
16
16
isHidden: boolean;
17
17
showPreview?: boolean;
18
18
}) => {
19
+
const cardBorderHidden = useCardBorderHidden();
19
20
const pubStatus = useLeafletPublicationStatus();
20
21
let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false);
21
22
let previewRef = useRef<HTMLDivElement | null>(null);
···
47
48
ref={previewRef}
48
49
className={`relative flex gap-3 w-full
49
50
${props.isHidden ? "hidden" : "flex"}
50
-
${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`}
51
+
${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`}
51
52
style={{
52
-
backgroundColor: props.cardBorderHidden
53
+
backgroundColor: cardBorderHidden
53
54
? "transparent"
54
55
: "rgba(var(--bg-page), var(--bg-page-alpha))",
55
56
}}
···
67
68
loggedIn={props.loggedIn}
68
69
/>
69
70
</div>
70
-
{props.cardBorderHidden && (
71
+
{cardBorderHidden && (
71
72
<hr
72
73
className="last:hidden border-border-light"
73
74
style={{
···
87
88
${props.isHidden ? "hidden" : "flex"}
88
89
`}
89
90
style={{
90
-
backgroundColor: props.cardBorderHidden
91
+
backgroundColor: cardBorderHidden
91
92
? "transparent"
92
93
: "rgba(var(--bg-page), var(--bg-page-alpha))",
93
94
}}
+16
-4
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
+16
-4
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
···
18
18
const firstPage = useEntity(root, "root/page")[0];
19
19
const page = firstPage?.data.value || root;
20
20
21
-
const cardBorderHidden = useCardBorderHidden(root);
21
+
const cardBorderHidden = useEntity(root, "theme/card-border-hidden")?.data
22
+
.value;
22
23
const rootBackgroundImage = useEntity(root, "theme/card-background-image");
23
24
const rootBackgroundRepeat = useEntity(
24
25
root,
···
49
50
50
51
const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`;
51
52
52
-
return { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass };
53
+
return {
54
+
root,
55
+
page,
56
+
cardBorderHidden,
57
+
contentWrapperStyle,
58
+
contentWrapperClass,
59
+
};
53
60
}
54
61
55
62
export const LeafletListPreview = (props: { isVisible: boolean }) => {
56
-
const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } =
57
-
useLeafletPreviewData();
63
+
const {
64
+
root,
65
+
page,
66
+
cardBorderHidden,
67
+
contentWrapperStyle,
68
+
contentWrapperClass,
69
+
} = useLeafletPreviewData();
58
70
59
71
return (
60
72
<Tooltip
-6
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
-6
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
···
1
1
"use client";
2
2
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
3
-
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
4
3
import { useState } from "react";
5
4
import { useDebouncedEffect } from "src/hooks/useDebouncedEffect";
6
5
import { Fact, PermissionToken } from "src/replicache";
···
30
29
[searchValue],
31
30
);
32
31
33
-
let cardBorderHidden = !!useCardBorderHidden(props.entityID);
34
32
return (
35
33
<DashboardLayout
36
34
id="looseleafs"
37
-
cardBorderHidden={cardBorderHidden}
38
35
currentPage="looseleafs"
39
36
defaultTab="home"
40
37
actions={<Actions />}
···
45
42
<LooseleafList
46
43
titles={props.titles}
47
44
initialFacts={props.initialFacts}
48
-
cardBorderHidden={cardBorderHidden}
49
45
searchValue={debouncedSearchValue}
50
46
/>
51
47
),
···
61
57
[root_entity: string]: Fact<Attribute>[];
62
58
};
63
59
searchValue: string;
64
-
cardBorderHidden: boolean;
65
60
}) => {
66
61
let { identity } = useIdentityData();
67
62
let { data: initialFacts } = useSWR(
···
108
103
searchValue={props.searchValue}
109
104
leaflets={leaflets}
110
105
titles={initialFacts?.titles || {}}
111
-
cardBorderHidden={props.cardBorderHidden}
112
106
initialFacts={initialFacts?.facts || {}}
113
107
showPreview
114
108
/>
-1
app/(home-pages)/notifications/page.tsx
-1
app/(home-pages)/notifications/page.tsx
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
···
1
+
"use client";
2
+
3
+
import { PostListing } from "components/PostListing";
4
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
5
+
import type { Cursor } from "./getProfilePosts";
6
+
import { getProfilePosts } from "./getProfilePosts";
7
+
import useSWRInfinite from "swr/infinite";
8
+
import { useEffect, useRef } from "react";
9
+
10
+
export const ProfilePostsContent = (props: {
11
+
did: string;
12
+
posts: Post[];
13
+
nextCursor: Cursor | null;
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;
24
+
25
+
// First page, we don't have previousPageData
26
+
if (pageIndex === 0) return ["profile-posts", props.did, null] as const;
27
+
28
+
// Add the cursor to the key
29
+
return ["profile-posts", props.did, previousPageData?.nextCursor] as const;
30
+
};
31
+
32
+
const { data, size, setSize, isValidating } = useSWRInfinite(
33
+
getKey,
34
+
([_, did, cursor]) => getProfilePosts(did, cursor),
35
+
{
36
+
fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }],
37
+
revalidateFirstPage: false,
38
+
},
39
+
);
40
+
41
+
const loadMoreRef = useRef<HTMLDivElement>(null);
42
+
43
+
// Set up intersection observer to load more when trigger element is visible
44
+
useEffect(() => {
45
+
const observer = new IntersectionObserver(
46
+
(entries) => {
47
+
if (entries[0].isIntersecting && !isValidating) {
48
+
const hasMore = data && data[data.length - 1]?.nextCursor;
49
+
if (hasMore) {
50
+
setSize(size + 1);
51
+
}
52
+
}
53
+
},
54
+
{ threshold: 0.1 },
55
+
);
56
+
57
+
if (loadMoreRef.current) {
58
+
observer.observe(loadMoreRef.current);
59
+
}
60
+
61
+
return () => observer.disconnect();
62
+
}, [data, size, setSize, isValidating]);
63
+
64
+
const allPosts = data ? data.flatMap((page) => page.posts) : [];
65
+
66
+
if (allPosts.length === 0 && !isValidating) {
67
+
return <div className="text-tertiary text-center py-4">No posts yet</div>;
68
+
}
69
+
70
+
return (
71
+
<div className="flex flex-col gap-3 text-left relative">
72
+
{allPosts.map((post) => (
73
+
<PostListing key={post.documents.uri} {...post} />
74
+
))}
75
+
{/* Trigger element for loading more posts */}
76
+
<div
77
+
ref={loadMoreRef}
78
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
79
+
aria-hidden="true"
80
+
/>
81
+
{isValidating && (
82
+
<div className="text-center text-tertiary py-4">
83
+
Loading more posts...
84
+
</div>
85
+
)}
86
+
</div>
87
+
);
88
+
};
+200
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
+200
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
···
1
+
"use client";
2
+
import { Avatar } from "components/Avatar";
3
+
import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api";
4
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
5
+
import type { ProfileData } from "./layout";
6
+
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
7
+
import { colorToString } from "components/ThemeManager/useColorAttribute";
8
+
import { PubIcon } from "components/ActionBar/Publications";
9
+
import { Json } from "supabase/database.types";
10
+
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
11
+
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
12
+
import { SpeedyLink } from "components/SpeedyLink";
13
+
import { ReactNode } from "react";
14
+
15
+
export const ProfileHeader = (props: {
16
+
profile: ProfileViewDetailed;
17
+
publications: { record: Json; uri: string }[];
18
+
popover?: boolean;
19
+
}) => {
20
+
let profileRecord = props.profile;
21
+
const profileUrl = `/p/${props.profile.handle}`;
22
+
23
+
const avatarElement = (
24
+
<Avatar
25
+
src={profileRecord.avatar}
26
+
displayName={profileRecord.displayName}
27
+
className="mx-auto mt-3 sm:mt-4"
28
+
giant
29
+
/>
30
+
);
31
+
32
+
const displayNameElement = (
33
+
<h3 className=" px-3 sm:px-4 pt-2 leading-tight">
34
+
{profileRecord.displayName
35
+
? profileRecord.displayName
36
+
: `@${props.profile.handle}`}
37
+
</h3>
38
+
);
39
+
40
+
const handleElement = profileRecord.displayName && (
41
+
<div
42
+
className={`text-tertiary ${props.popover ? "text-xs" : "text-sm"} pb-1 italic px-3 sm:px-4 truncate`}
43
+
>
44
+
@{props.profile.handle}
45
+
</div>
46
+
);
47
+
48
+
return (
49
+
<div
50
+
className={`flex flex-col relative ${props.popover && "text-sm"}`}
51
+
id="profile-header"
52
+
>
53
+
<ProfileLinks handle={props.profile.handle || ""} />
54
+
<div className="flex flex-col">
55
+
<div className="flex flex-col group">
56
+
{props.popover ? (
57
+
<SpeedyLink className={"hover:no-underline!"} href={profileUrl}>
58
+
{avatarElement}
59
+
</SpeedyLink>
60
+
) : (
61
+
avatarElement
62
+
)}
63
+
{props.popover ? (
64
+
<SpeedyLink
65
+
className={" text-primary group-hover:underline"}
66
+
href={profileUrl}
67
+
>
68
+
{displayNameElement}
69
+
</SpeedyLink>
70
+
) : (
71
+
displayNameElement
72
+
)}
73
+
{props.popover && handleElement ? (
74
+
<SpeedyLink className={"group-hover:underline"} href={profileUrl}>
75
+
{handleElement}
76
+
</SpeedyLink>
77
+
) : (
78
+
handleElement
79
+
)}
80
+
</div>
81
+
<pre className="text-secondary px-3 sm:px-4 whitespace-pre-wrap">
82
+
{profileRecord.description
83
+
? parseDescription(profileRecord.description)
84
+
: null}
85
+
</pre>
86
+
<div className=" w-full overflow-x-scroll py-3 mb-3 ">
87
+
<div
88
+
className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`}
89
+
>
90
+
{props.publications.map((p) => (
91
+
<PublicationCard
92
+
key={p.uri}
93
+
record={p.record as PubLeafletPublication.Record}
94
+
uri={p.uri}
95
+
/>
96
+
))}
97
+
</div>
98
+
</div>
99
+
</div>
100
+
</div>
101
+
);
102
+
};
103
+
104
+
const ProfileLinks = (props: { handle: string }) => {
105
+
return (
106
+
<div className="absolute sm:top-4 top-3 sm:right-4 right-3 flex flex-row gap-2">
107
+
<a
108
+
className="text-tertiary hover:text-accent-contrast hover:no-underline!"
109
+
href={`https://bsky.app/profile/${props.handle}`}
110
+
>
111
+
<BlueskyTiny />
112
+
</a>
113
+
</div>
114
+
);
115
+
};
116
+
const PublicationCard = (props: {
117
+
record: PubLeafletPublication.Record;
118
+
uri: string;
119
+
}) => {
120
+
const { record, uri } = props;
121
+
const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme);
122
+
123
+
return (
124
+
<a
125
+
href={`https://${record.base_path}`}
126
+
className="border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2"
127
+
style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }}
128
+
>
129
+
<div
130
+
className="rounded-md p-2 flex flex-row gap-2"
131
+
style={{
132
+
backgroundColor: record.theme?.showPageBackground
133
+
? `rgb(${colorToString(bgPage, "rgb")})`
134
+
: undefined,
135
+
}}
136
+
>
137
+
<PubIcon record={record} uri={uri} />
138
+
<h4
139
+
className="truncate min-w-0"
140
+
style={{
141
+
color: `rgb(${colorToString(primary, "rgb")})`,
142
+
}}
143
+
>
144
+
{record.name}
145
+
</h4>
146
+
</div>
147
+
</a>
148
+
);
149
+
};
150
+
151
+
function parseDescription(description: string): ReactNode[] {
152
+
const combinedRegex = /(@\S+|https?:\/\/\S+)/g;
153
+
154
+
const parts: ReactNode[] = [];
155
+
let lastIndex = 0;
156
+
let match;
157
+
let key = 0;
158
+
159
+
while ((match = combinedRegex.exec(description)) !== null) {
160
+
// Add text before this match
161
+
if (match.index > lastIndex) {
162
+
parts.push(description.slice(lastIndex, match.index));
163
+
}
164
+
165
+
const matched = match[0];
166
+
167
+
if (matched.startsWith("@")) {
168
+
// It's a mention
169
+
const handle = matched.slice(1);
170
+
parts.push(
171
+
<SpeedyLink key={key++} href={`/p/${handle}`}>
172
+
{matched}
173
+
</SpeedyLink>,
174
+
);
175
+
} else {
176
+
// It's a URL
177
+
const urlWithoutProtocol = matched
178
+
.replace(/^https?:\/\//, "")
179
+
.replace(/\/+$/, "");
180
+
const displayText =
181
+
urlWithoutProtocol.length > 50
182
+
? urlWithoutProtocol.slice(0, 50) + "โฆ"
183
+
: urlWithoutProtocol;
184
+
parts.push(
185
+
<a key={key++} href={matched} target="_blank" rel="noopener noreferrer">
186
+
{displayText}
187
+
</a>,
188
+
);
189
+
}
190
+
191
+
lastIndex = match.index + matched.length;
192
+
}
193
+
194
+
// Add remaining text after last match
195
+
if (lastIndex < description.length) {
196
+
parts.push(description.slice(lastIndex));
197
+
}
198
+
199
+
return parts;
200
+
}
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
···
1
+
"use client";
2
+
3
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
4
+
5
+
export function ProfileLayout(props: { children: React.ReactNode }) {
6
+
let cardBorderHidden = useCardBorderHidden();
7
+
return (
8
+
<div
9
+
id="profile-content"
10
+
className={`
11
+
${
12
+
cardBorderHidden
13
+
? ""
14
+
: "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page"
15
+
}
16
+
max-w-prose mx-auto w-full
17
+
flex flex-col
18
+
text-center
19
+
`}
20
+
>
21
+
{props.children}
22
+
</div>
23
+
);
24
+
}
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
···
1
+
"use client";
2
+
3
+
import { SpeedyLink } from "components/SpeedyLink";
4
+
import { useSelectedLayoutSegment } from "next/navigation";
5
+
import { useState, useEffect } from "react";
6
+
import { useCardBorderHidden } from "components/Pages/useCardBorderHidden";
7
+
8
+
export type ProfileTabType = "posts" | "comments" | "subscriptions";
9
+
10
+
export const ProfileTabs = (props: { didOrHandle: string }) => {
11
+
const cardBorderHidden = useCardBorderHidden();
12
+
const segment = useSelectedLayoutSegment();
13
+
const currentTab = (segment || "posts") as ProfileTabType;
14
+
const [scrollPosWithinTabContent, setScrollPosWithinTabContent] = useState(0);
15
+
const [headerHeight, setHeaderHeight] = useState(0);
16
+
useEffect(() => {
17
+
let headerHeight =
18
+
document.getElementById("profile-header")?.clientHeight || 0;
19
+
setHeaderHeight(headerHeight);
20
+
21
+
const profileContent = cardBorderHidden
22
+
? document.getElementById("home-content")
23
+
: document.getElementById("profile-content");
24
+
const handleScroll = () => {
25
+
if (profileContent) {
26
+
setScrollPosWithinTabContent(
27
+
profileContent.scrollTop - headerHeight > 0
28
+
? profileContent.scrollTop - headerHeight
29
+
: 0,
30
+
);
31
+
}
32
+
};
33
+
34
+
if (profileContent) {
35
+
profileContent.addEventListener("scroll", handleScroll);
36
+
return () => profileContent.removeEventListener("scroll", handleScroll);
37
+
}
38
+
}, []);
39
+
40
+
const baseUrl = `/p/${props.didOrHandle}`;
41
+
const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)";
42
+
43
+
return (
44
+
<div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 sm:px-4 px-3">
45
+
<div
46
+
style={
47
+
scrollPosWithinTabContent < 20
48
+
? {
49
+
paddingLeft: `calc(${scrollPosWithinTabContent / 20} * 12px )`,
50
+
paddingRight: `calc(${scrollPosWithinTabContent / 20} * 12px )`,
51
+
}
52
+
: { paddingLeft: "12px", paddingRight: "12px" }
53
+
}
54
+
>
55
+
<div
56
+
className={`
57
+
border rounded-lg
58
+
${scrollPosWithinTabContent > 20 ? "border-border-light" : "border-transparent"}
59
+
py-1
60
+
w-full `}
61
+
style={
62
+
scrollPosWithinTabContent < 20
63
+
? {
64
+
backgroundColor: !cardBorderHidden
65
+
? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})`
66
+
: `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`,
67
+
paddingLeft: !cardBorderHidden
68
+
? "4px"
69
+
: `calc(${scrollPosWithinTabContent / 20} * 4px)`,
70
+
paddingRight: !cardBorderHidden
71
+
? "4px"
72
+
: `calc(${scrollPosWithinTabContent / 20} * 4px)`,
73
+
}
74
+
: {
75
+
backgroundColor: `rgb(${bgColor})`,
76
+
paddingLeft: "4px",
77
+
paddingRight: "4px",
78
+
}
79
+
}
80
+
>
81
+
<div className="flex gap-2 justify-between">
82
+
<div className="flex gap-2">
83
+
<TabLink
84
+
href={baseUrl}
85
+
name="Posts"
86
+
selected={currentTab === "posts"}
87
+
/>
88
+
<TabLink
89
+
href={`${baseUrl}/comments`}
90
+
name="Comments"
91
+
selected={currentTab === "comments"}
92
+
/>
93
+
</div>
94
+
<TabLink
95
+
href={`${baseUrl}/subscriptions`}
96
+
name="Subscriptions"
97
+
selected={currentTab === "subscriptions"}
98
+
/>
99
+
</div>
100
+
</div>
101
+
</div>
102
+
</div>
103
+
);
104
+
};
105
+
106
+
const TabLink = (props: { href: string; name: string; selected: boolean }) => {
107
+
return (
108
+
<SpeedyLink
109
+
href={props.href}
110
+
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer hover:no-underline! ${
111
+
props.selected
112
+
? "text-accent-2 bg-accent-1 font-bold -mb-px"
113
+
: "text-tertiary"
114
+
}`}
115
+
>
116
+
{props.name}
117
+
</SpeedyLink>
118
+
);
119
+
};
+219
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
+219
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
···
1
+
"use client";
2
+
3
+
import { useEffect, useRef, useMemo } from "react";
4
+
import useSWRInfinite from "swr/infinite";
5
+
import { AppBskyActorProfile, AtUri } from "@atproto/api";
6
+
import { PubLeafletComment, PubLeafletDocument } from "lexicons/api";
7
+
import { ReplyTiny } from "components/Icons/ReplyTiny";
8
+
import { Avatar } from "components/Avatar";
9
+
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock";
10
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
11
+
import {
12
+
getProfileComments,
13
+
type ProfileComment,
14
+
type Cursor,
15
+
} from "./getProfileComments";
16
+
import { timeAgo } from "src/utils/timeAgo";
17
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
18
+
19
+
export const ProfileCommentsContent = (props: {
20
+
did: string;
21
+
comments: ProfileComment[];
22
+
nextCursor: Cursor | null;
23
+
}) => {
24
+
const getKey = (
25
+
pageIndex: number,
26
+
previousPageData: {
27
+
comments: ProfileComment[];
28
+
nextCursor: Cursor | null;
29
+
} | null,
30
+
) => {
31
+
// Reached the end
32
+
if (previousPageData && !previousPageData.nextCursor) return null;
33
+
34
+
// First page, we don't have previousPageData
35
+
if (pageIndex === 0) return ["profile-comments", props.did, null] as const;
36
+
37
+
// Add the cursor to the key
38
+
return [
39
+
"profile-comments",
40
+
props.did,
41
+
previousPageData?.nextCursor,
42
+
] as const;
43
+
};
44
+
45
+
const { data, size, setSize, isValidating } = useSWRInfinite(
46
+
getKey,
47
+
([_, did, cursor]) => getProfileComments(did, cursor),
48
+
{
49
+
fallbackData: [
50
+
{ comments: props.comments, nextCursor: props.nextCursor },
51
+
],
52
+
revalidateFirstPage: false,
53
+
},
54
+
);
55
+
56
+
const loadMoreRef = useRef<HTMLDivElement>(null);
57
+
58
+
// Set up intersection observer to load more when trigger element is visible
59
+
useEffect(() => {
60
+
const observer = new IntersectionObserver(
61
+
(entries) => {
62
+
if (entries[0].isIntersecting && !isValidating) {
63
+
const hasMore = data && data[data.length - 1]?.nextCursor;
64
+
if (hasMore) {
65
+
setSize(size + 1);
66
+
}
67
+
}
68
+
},
69
+
{ threshold: 0.1 },
70
+
);
71
+
72
+
if (loadMoreRef.current) {
73
+
observer.observe(loadMoreRef.current);
74
+
}
75
+
76
+
return () => observer.disconnect();
77
+
}, [data, size, setSize, isValidating]);
78
+
79
+
const allComments = data ? data.flatMap((page) => page.comments) : [];
80
+
81
+
if (allComments.length === 0 && !isValidating) {
82
+
return (
83
+
<div className="text-tertiary text-center py-4">No comments yet</div>
84
+
);
85
+
}
86
+
87
+
return (
88
+
<div className="flex flex-col gap-2 text-left relative">
89
+
{allComments.map((comment) => (
90
+
<CommentItem key={comment.uri} comment={comment} />
91
+
))}
92
+
{/* Trigger element for loading more comments */}
93
+
<div
94
+
ref={loadMoreRef}
95
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
96
+
aria-hidden="true"
97
+
/>
98
+
{isValidating && (
99
+
<div className="text-center text-tertiary py-4">
100
+
Loading more comments...
101
+
</div>
102
+
)}
103
+
</div>
104
+
);
105
+
};
106
+
107
+
const CommentItem = ({ comment }: { comment: ProfileComment }) => {
108
+
const record = comment.record as PubLeafletComment.Record;
109
+
const profile = comment.bsky_profiles?.record as
110
+
| AppBskyActorProfile.Record
111
+
| undefined;
112
+
const displayName =
113
+
profile?.displayName || comment.bsky_profiles?.handle || "Unknown";
114
+
115
+
// Get commenter DID from comment URI
116
+
const commenterDid = new AtUri(comment.uri).host;
117
+
118
+
const isReply = !!record.reply;
119
+
120
+
// Get document title
121
+
const docData = comment.document?.data as
122
+
| PubLeafletDocument.Record
123
+
| undefined;
124
+
const postTitle = docData?.title || "Untitled";
125
+
126
+
// Get parent comment info for replies
127
+
const parentRecord = comment.parentComment?.record as
128
+
| PubLeafletComment.Record
129
+
| undefined;
130
+
const parentProfile = comment.parentComment?.bsky_profiles?.record as
131
+
| AppBskyActorProfile.Record
132
+
| undefined;
133
+
const parentDisplayName =
134
+
parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle;
135
+
136
+
// Build direct link to the comment
137
+
const commentLink = useMemo(() => {
138
+
if (!comment.document) return null;
139
+
const docUri = new AtUri(comment.document.uri);
140
+
141
+
// Get base URL using getPublicationURL if publication exists, otherwise build path
142
+
let baseUrl: string;
143
+
if (comment.publication) {
144
+
baseUrl = getPublicationURL(comment.publication);
145
+
const pubUri = new AtUri(comment.publication.uri);
146
+
// If getPublicationURL returns a relative path, append the document rkey
147
+
if (baseUrl.startsWith("/")) {
148
+
baseUrl = `${baseUrl}/${docUri.rkey}`;
149
+
} else {
150
+
// For custom domains, append the document rkey
151
+
baseUrl = `${baseUrl}/${docUri.rkey}`;
152
+
}
153
+
} else {
154
+
baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`;
155
+
}
156
+
157
+
// Build query parameters
158
+
const params = new URLSearchParams();
159
+
params.set("interactionDrawer", "comments");
160
+
if (record.onPage) {
161
+
params.set("page", record.onPage);
162
+
}
163
+
164
+
// Use comment URI as hash for direct reference
165
+
const commentId = encodeURIComponent(comment.uri);
166
+
167
+
return `${baseUrl}?${params.toString()}#${commentId}`;
168
+
}, [comment.document, comment.publication, comment.uri, record.onPage]);
169
+
170
+
// Get avatar source
171
+
const avatarSrc = profile?.avatar?.ref
172
+
? blobRefToSrc(profile.avatar.ref, commenterDid)
173
+
: undefined;
174
+
175
+
return (
176
+
<div id={comment.uri} className="w-full flex flex-col text-left mb-8">
177
+
<div className="flex gap-2 w-full">
178
+
<Avatar src={avatarSrc} displayName={displayName} />
179
+
<div className="flex flex-col w-full min-w-0 grow">
180
+
<div className="flex flex-row gap-2">
181
+
<div className="text-tertiary text-sm truncate">
182
+
<span className="font-bold text-secondary">{displayName}</span>{" "}
183
+
{isReply ? "replied" : "commented"} on{" "}
184
+
{commentLink ? (
185
+
<a
186
+
href={commentLink}
187
+
className="italic text-accent-contrast hover:underline"
188
+
>
189
+
{postTitle}
190
+
</a>
191
+
) : (
192
+
<span className="italic text-accent-contrast">{postTitle}</span>
193
+
)}
194
+
</div>
195
+
</div>
196
+
{isReply && parentRecord && (
197
+
<div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center">
198
+
<ReplyTiny className="shrink-0 scale-75" />
199
+
{parentDisplayName && (
200
+
<div className="font-bold shrink-0">{parentDisplayName}</div>
201
+
)}
202
+
<div className="grow truncate">{parentRecord.plaintext}</div>
203
+
</div>
204
+
)}
205
+
<pre
206
+
style={{ wordBreak: "break-word" }}
207
+
className="whitespace-pre-wrap text-secondary"
208
+
>
209
+
<BaseTextBlock
210
+
index={[]}
211
+
plaintext={record.plaintext}
212
+
facets={record.facets}
213
+
/>
214
+
</pre>
215
+
</div>
216
+
</div>
217
+
</div>
218
+
);
219
+
};
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
···
1
+
"use server";
2
+
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { Json } from "supabase/database.types";
5
+
import { PubLeafletComment } from "lexicons/api";
6
+
7
+
export type Cursor = {
8
+
indexed_at: string;
9
+
uri: string;
10
+
};
11
+
12
+
export type ProfileComment = {
13
+
uri: string;
14
+
record: Json;
15
+
indexed_at: string;
16
+
bsky_profiles: { record: Json; handle: string | null } | null;
17
+
document: {
18
+
uri: string;
19
+
data: Json;
20
+
} | null;
21
+
publication: {
22
+
uri: string;
23
+
record: Json;
24
+
} | null;
25
+
// For replies, include the parent comment info
26
+
parentComment: {
27
+
uri: string;
28
+
record: Json;
29
+
bsky_profiles: { record: Json; handle: string | null } | null;
30
+
} | null;
31
+
};
32
+
33
+
export async function getProfileComments(
34
+
did: string,
35
+
cursor?: Cursor | null,
36
+
): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> {
37
+
const limit = 20;
38
+
39
+
let query = supabaseServerClient
40
+
.from("comments_on_documents")
41
+
.select(
42
+
`*,
43
+
bsky_profiles(record, handle),
44
+
documents(uri, data, documents_in_publications(publications(*)))`,
45
+
)
46
+
.eq("profile", did)
47
+
.order("indexed_at", { ascending: false })
48
+
.order("uri", { ascending: false })
49
+
.limit(limit);
50
+
51
+
if (cursor) {
52
+
query = query.or(
53
+
`indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`,
54
+
);
55
+
}
56
+
57
+
const { data: rawComments } = await query;
58
+
59
+
if (!rawComments || rawComments.length === 0) {
60
+
return { comments: [], nextCursor: null };
61
+
}
62
+
63
+
// Collect parent comment URIs for replies
64
+
const parentUris = rawComments
65
+
.map((c) => (c.record as PubLeafletComment.Record).reply?.parent)
66
+
.filter((uri): uri is string => !!uri);
67
+
68
+
// Fetch parent comments if there are any replies
69
+
let parentCommentsMap = new Map<
70
+
string,
71
+
{
72
+
uri: string;
73
+
record: Json;
74
+
bsky_profiles: { record: Json; handle: string | null } | null;
75
+
}
76
+
>();
77
+
78
+
if (parentUris.length > 0) {
79
+
const { data: parentComments } = await supabaseServerClient
80
+
.from("comments_on_documents")
81
+
.select(`uri, record, bsky_profiles(record, handle)`)
82
+
.in("uri", parentUris);
83
+
84
+
if (parentComments) {
85
+
for (const pc of parentComments) {
86
+
parentCommentsMap.set(pc.uri, {
87
+
uri: pc.uri,
88
+
record: pc.record,
89
+
bsky_profiles: pc.bsky_profiles,
90
+
});
91
+
}
92
+
}
93
+
}
94
+
95
+
// Transform to ProfileComment format
96
+
const comments: ProfileComment[] = rawComments.map((comment) => {
97
+
const record = comment.record as PubLeafletComment.Record;
98
+
const doc = comment.documents;
99
+
const pub = doc?.documents_in_publications?.[0]?.publications;
100
+
101
+
return {
102
+
uri: comment.uri,
103
+
record: comment.record,
104
+
indexed_at: comment.indexed_at,
105
+
bsky_profiles: comment.bsky_profiles,
106
+
document: doc
107
+
? {
108
+
uri: doc.uri,
109
+
data: doc.data,
110
+
}
111
+
: null,
112
+
publication: pub
113
+
? {
114
+
uri: pub.uri,
115
+
record: pub.record,
116
+
}
117
+
: null,
118
+
parentComment: record.reply?.parent
119
+
? parentCommentsMap.get(record.reply.parent) || null
120
+
: null,
121
+
};
122
+
});
123
+
124
+
const nextCursor =
125
+
comments.length === limit
126
+
? {
127
+
indexed_at: comments[comments.length - 1].indexed_at,
128
+
uri: comments[comments.length - 1].uri,
129
+
}
130
+
: null;
131
+
132
+
return { comments, nextCursor };
133
+
}
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { getProfileComments } from "./getProfileComments";
3
+
import { ProfileCommentsContent } from "./CommentsContent";
4
+
5
+
export default async function ProfileCommentsPage(props: {
6
+
params: Promise<{ didOrHandle: string }>;
7
+
}) {
8
+
let params = await props.params;
9
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
10
+
11
+
// Resolve handle to DID if necessary
12
+
let did = didOrHandle;
13
+
if (!didOrHandle.startsWith("did:")) {
14
+
let resolved = await idResolver.handle.resolve(didOrHandle);
15
+
if (!resolved) return null;
16
+
did = resolved;
17
+
}
18
+
19
+
const { comments, nextCursor } = await getProfileComments(did);
20
+
21
+
return (
22
+
<ProfileCommentsContent
23
+
did={did}
24
+
comments={comments}
25
+
nextCursor={nextCursor}
26
+
/>
27
+
);
28
+
}
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
···
1
+
"use server";
2
+
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
5
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
6
+
7
+
export type Cursor = {
8
+
indexed_at: string;
9
+
uri: string;
10
+
};
11
+
12
+
export async function getProfilePosts(
13
+
did: string,
14
+
cursor?: Cursor | null,
15
+
): Promise<{ posts: Post[]; nextCursor: Cursor | null }> {
16
+
const limit = 20;
17
+
18
+
let query = supabaseServerClient
19
+
.from("documents")
20
+
.select(
21
+
`*,
22
+
comments_on_documents(count),
23
+
document_mentions_in_bsky(count),
24
+
documents_in_publications(publications(*))`,
25
+
)
26
+
.like("uri", `at://${did}/%`)
27
+
.order("indexed_at", { ascending: false })
28
+
.order("uri", { ascending: false })
29
+
.limit(limit);
30
+
31
+
if (cursor) {
32
+
query = query.or(
33
+
`indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`,
34
+
);
35
+
}
36
+
37
+
let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([
38
+
query,
39
+
supabaseServerClient
40
+
.from("publications")
41
+
.select("*")
42
+
.eq("identity_did", did),
43
+
supabaseServerClient
44
+
.from("bsky_profiles")
45
+
.select("handle")
46
+
.eq("did", did)
47
+
.single(),
48
+
]);
49
+
50
+
// Build a map of publications for quick lookup
51
+
let pubMap = new Map<string, NonNullable<typeof pubs>[number]>();
52
+
for (let pub of pubs || []) {
53
+
pubMap.set(pub.uri, pub);
54
+
}
55
+
56
+
// Transform data to Post[] format
57
+
let handle = profile?.handle ? `@${profile.handle}` : null;
58
+
let posts: Post[] = [];
59
+
60
+
for (let doc of docs || []) {
61
+
let pubFromDoc = doc.documents_in_publications?.[0]?.publications;
62
+
let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null;
63
+
64
+
let post: Post = {
65
+
author: handle,
66
+
documents: {
67
+
data: doc.data,
68
+
uri: doc.uri,
69
+
indexed_at: doc.indexed_at,
70
+
comments_on_documents: doc.comments_on_documents,
71
+
document_mentions_in_bsky: doc.document_mentions_in_bsky,
72
+
},
73
+
};
74
+
75
+
if (pub) {
76
+
post.publication = {
77
+
href: getPublicationURL(pub),
78
+
pubRecord: pub.record,
79
+
uri: pub.uri,
80
+
};
81
+
}
82
+
83
+
posts.push(post);
84
+
}
85
+
86
+
const nextCursor =
87
+
posts.length === limit
88
+
? {
89
+
indexed_at: posts[posts.length - 1].documents.indexed_at,
90
+
uri: posts[posts.length - 1].documents.uri,
91
+
}
92
+
: null;
93
+
94
+
return { posts, nextCursor };
95
+
}
+77
app/(home-pages)/p/[didOrHandle]/layout.tsx
+77
app/(home-pages)/p/[didOrHandle]/layout.tsx
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
+
import { Json } from "supabase/database.types";
5
+
import { ProfileHeader } from "./ProfileHeader";
6
+
import { ProfileTabs } from "./ProfileTabs";
7
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
8
+
import { ProfileLayout } from "./ProfileLayout";
9
+
import { Agent } from "@atproto/api";
10
+
import { get_profile_data } from "app/api/rpc/[command]/get_profile_data";
11
+
12
+
export default async function ProfilePageLayout(props: {
13
+
params: Promise<{ didOrHandle: string }>;
14
+
children: React.ReactNode;
15
+
}) {
16
+
let params = await props.params;
17
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
18
+
19
+
// Resolve handle to DID if necessary
20
+
let did = didOrHandle;
21
+
22
+
if (!didOrHandle.startsWith("did:")) {
23
+
let resolved = await idResolver.handle.resolve(didOrHandle);
24
+
if (!resolved) {
25
+
return (
26
+
<NotFoundLayout>
27
+
<p className="font-bold">Sorry, can't resolve handle!</p>
28
+
<p>
29
+
This may be a glitch on our end. If the issue persists please{" "}
30
+
<a href="mailto:contact@leaflet.pub">send us a note</a>.
31
+
</p>
32
+
</NotFoundLayout>
33
+
);
34
+
}
35
+
did = resolved;
36
+
}
37
+
let profileData = await get_profile_data.handler(
38
+
{ didOrHandle: did },
39
+
{ supabase: supabaseServerClient },
40
+
);
41
+
let { publications, profile } = profileData.result;
42
+
43
+
if (!profile) return null;
44
+
45
+
return (
46
+
<DashboardLayout
47
+
id="profile"
48
+
defaultTab="default"
49
+
currentPage="profile"
50
+
actions={null}
51
+
tabs={{
52
+
default: {
53
+
controls: null,
54
+
content: (
55
+
<ProfileLayout>
56
+
<ProfileHeader
57
+
profile={profile}
58
+
publications={publications || []}
59
+
/>
60
+
<ProfileTabs didOrHandle={params.didOrHandle} />
61
+
<div className="h-full pt-3 pb-4 px-3 sm:px-4 flex flex-col">
62
+
{props.children}
63
+
</div>
64
+
</ProfileLayout>
65
+
),
66
+
},
67
+
}}
68
+
/>
69
+
);
70
+
}
71
+
72
+
export type ProfileData = {
73
+
did: string;
74
+
handle: string | null;
75
+
indexed_at: string;
76
+
record: Json;
77
+
};
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { getProfilePosts } from "./getProfilePosts";
3
+
import { ProfilePostsContent } from "./PostsContent";
4
+
5
+
export default async function ProfilePostsPage(props: {
6
+
params: Promise<{ didOrHandle: string }>;
7
+
}) {
8
+
let params = await props.params;
9
+
let didOrHandle = decodeURIComponent(params.didOrHandle);
10
+
11
+
// Resolve handle to DID if necessary
12
+
let did = didOrHandle;
13
+
if (!didOrHandle.startsWith("did:")) {
14
+
let resolved = await idResolver.handle.resolve(didOrHandle);
15
+
if (!resolved) return null;
16
+
did = resolved;
17
+
}
18
+
19
+
const { posts, nextCursor } = await getProfilePosts(did);
20
+
21
+
return (
22
+
<ProfilePostsContent did={did} posts={posts} nextCursor={nextCursor} />
23
+
);
24
+
}
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
···
1
+
"use client";
2
+
3
+
import { useEffect, useRef } from "react";
4
+
import useSWRInfinite from "swr/infinite";
5
+
import { PubListing } from "app/(home-pages)/discover/PubListing";
6
+
import {
7
+
getSubscriptions,
8
+
type PublicationSubscription,
9
+
} from "app/(home-pages)/reader/getSubscriptions";
10
+
import { Cursor } from "app/(home-pages)/reader/getReaderFeed";
11
+
12
+
export const ProfileSubscriptionsContent = (props: {
13
+
did: string;
14
+
subscriptions: PublicationSubscription[];
15
+
nextCursor: Cursor | null;
16
+
}) => {
17
+
const getKey = (
18
+
pageIndex: number,
19
+
previousPageData: {
20
+
subscriptions: PublicationSubscription[];
21
+
nextCursor: Cursor | null;
22
+
} | null,
23
+
) => {
24
+
// Reached the end
25
+
if (previousPageData && !previousPageData.nextCursor) return null;
26
+
27
+
// First page, we don't have previousPageData
28
+
if (pageIndex === 0)
29
+
return ["profile-subscriptions", props.did, null] as const;
30
+
31
+
// Add the cursor to the key
32
+
return [
33
+
"profile-subscriptions",
34
+
props.did,
35
+
previousPageData?.nextCursor,
36
+
] as const;
37
+
};
38
+
39
+
const { data, size, setSize, isValidating } = useSWRInfinite(
40
+
getKey,
41
+
([_, did, cursor]) => getSubscriptions(did, cursor),
42
+
{
43
+
fallbackData: [
44
+
{ subscriptions: props.subscriptions, nextCursor: props.nextCursor },
45
+
],
46
+
revalidateFirstPage: false,
47
+
},
48
+
);
49
+
50
+
const loadMoreRef = useRef<HTMLDivElement>(null);
51
+
52
+
// Set up intersection observer to load more when trigger element is visible
53
+
useEffect(() => {
54
+
const observer = new IntersectionObserver(
55
+
(entries) => {
56
+
if (entries[0].isIntersecting && !isValidating) {
57
+
const hasMore = data && data[data.length - 1]?.nextCursor;
58
+
if (hasMore) {
59
+
setSize(size + 1);
60
+
}
61
+
}
62
+
},
63
+
{ threshold: 0.1 },
64
+
);
65
+
66
+
if (loadMoreRef.current) {
67
+
observer.observe(loadMoreRef.current);
68
+
}
69
+
70
+
return () => observer.disconnect();
71
+
}, [data, size, setSize, isValidating]);
72
+
73
+
const allSubscriptions = data
74
+
? data.flatMap((page) => page.subscriptions)
75
+
: [];
76
+
77
+
if (allSubscriptions.length === 0 && !isValidating) {
78
+
return (
79
+
<div className="text-tertiary text-center py-4">No subscriptions yet</div>
80
+
);
81
+
}
82
+
83
+
return (
84
+
<div className="relative">
85
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3">
86
+
{allSubscriptions.map((sub) => (
87
+
<PubListing key={sub.uri} {...sub} />
88
+
))}
89
+
</div>
90
+
{/* Trigger element for loading more subscriptions */}
91
+
<div
92
+
ref={loadMoreRef}
93
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
94
+
aria-hidden="true"
95
+
/>
96
+
{isValidating && (
97
+
<div className="text-center text-tertiary py-4">
98
+
Loading more subscriptions...
99
+
</div>
100
+
)}
101
+
</div>
102
+
);
103
+
};
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
···
1
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
2
+
import { getSubscriptions } from "app/(home-pages)/reader/getSubscriptions";
3
+
import { ProfileSubscriptionsContent } from "./SubscriptionsContent";
4
+
5
+
export default async function ProfileSubscriptionsPage(props: {
6
+
params: Promise<{ didOrHandle: string }>;
7
+
}) {
8
+
const params = await props.params;
9
+
const didOrHandle = decodeURIComponent(params.didOrHandle);
10
+
11
+
// Resolve handle to DID if necessary
12
+
let did = didOrHandle;
13
+
if (!didOrHandle.startsWith("did:")) {
14
+
const resolved = await idResolver.handle.resolve(didOrHandle);
15
+
if (!resolved) return null;
16
+
did = resolved;
17
+
}
18
+
19
+
const { subscriptions, nextCursor } = await getSubscriptions(did);
20
+
21
+
return (
22
+
<ProfileSubscriptionsContent
23
+
did={did}
24
+
subscriptions={subscriptions}
25
+
nextCursor={nextCursor}
26
+
/>
27
+
);
28
+
}
+1
-1
app/(home-pages)/reader/SubscriptionsContent.tsx
+1
-1
app/(home-pages)/reader/SubscriptionsContent.tsx
···
32
32
33
33
const { data, error, size, setSize, isValidating } = useSWRInfinite(
34
34
getKey,
35
-
([_, cursor]) => getSubscriptions(cursor),
35
+
([_, cursor]) => getSubscriptions(null, cursor),
36
36
{
37
37
fallbackData: [
38
38
{ subscriptions: props.publications, nextCursor: props.nextCursor },
+1
-1
app/(home-pages)/reader/getReaderFeed.ts
+1
-1
app/(home-pages)/reader/getReaderFeed.ts
+13
-4
app/(home-pages)/reader/getSubscriptions.ts
+13
-4
app/(home-pages)/reader/getSubscriptions.ts
···
8
8
import { idResolver } from "./idResolver";
9
9
import { Cursor } from "./getReaderFeed";
10
10
11
-
export async function getSubscriptions(cursor?: Cursor | null): Promise<{
11
+
export async function getSubscriptions(
12
+
did?: string | null,
13
+
cursor?: Cursor | null,
14
+
): Promise<{
12
15
nextCursor: null | Cursor;
13
16
subscriptions: PublicationSubscription[];
14
17
}> {
15
-
let auth_res = await getIdentityData();
16
-
if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
18
+
// If no DID provided, use logged-in user's DID
19
+
let identity = did;
20
+
if (!identity) {
21
+
const auth_res = await getIdentityData();
22
+
if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null };
23
+
identity = auth_res.atp_did;
24
+
}
25
+
17
26
let query = supabaseServerClient
18
27
.from("publication_subscriptions")
19
28
.select(`*, publications(*, documents_in_publications(*, documents(*)))`)
···
25
34
})
26
35
.limit(1, { referencedTable: "publications.documents_in_publications" })
27
36
.limit(25)
28
-
.eq("identity", auth_res.atp_did);
37
+
.eq("identity", identity);
29
38
30
39
if (cursor) {
31
40
query = query.or(
-1
app/(home-pages)/reader/page.tsx
-1
app/(home-pages)/reader/page.tsx
+4
-1
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
+4
-1
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
···
10
10
export async function getDocumentsByTag(
11
11
tag: string,
12
12
): Promise<{ posts: Post[] }> {
13
+
// Normalize tag to lowercase for case-insensitive search
14
+
const normalizedTag = tag.toLowerCase();
15
+
13
16
// Query documents that have this tag
14
17
const { data: documents, error } = await supabaseServerClient
15
18
.from("documents")
···
19
22
document_mentions_in_bsky(count),
20
23
documents_in_publications(publications(*))`,
21
24
)
22
-
.contains("data->tags", `["${tag}"]`)
25
+
.contains("data->tags", `["${normalizedTag}"]`)
23
26
.order("indexed_at", { ascending: false })
24
27
.limit(50);
25
28
-1
app/(home-pages)/tag/[tag]/page.tsx
-1
app/(home-pages)/tag/[tag]/page.tsx
+69
app/api/rpc/[command]/get_profile_data.ts
+69
app/api/rpc/[command]/get_profile_data.ts
···
1
+
import { z } from "zod";
2
+
import { makeRoute } from "../lib";
3
+
import type { Env } from "./route";
4
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
5
+
import { supabaseServerClient } from "supabase/serverClient";
6
+
import { Agent } from "@atproto/api";
7
+
import { getIdentityData } from "actions/getIdentityData";
8
+
import { createOauthClient } from "src/atproto-oauth";
9
+
10
+
export type GetProfileDataReturnType = Awaited<
11
+
ReturnType<(typeof get_profile_data)["handler"]>
12
+
>;
13
+
14
+
export const get_profile_data = makeRoute({
15
+
route: "get_profile_data",
16
+
input: z.object({
17
+
didOrHandle: z.string(),
18
+
}),
19
+
handler: async ({ didOrHandle }, { supabase }: Pick<Env, "supabase">) => {
20
+
// Resolve handle to DID if necessary
21
+
let did = didOrHandle;
22
+
23
+
if (!didOrHandle.startsWith("did:")) {
24
+
const resolved = await idResolver.handle.resolve(didOrHandle);
25
+
if (!resolved) {
26
+
throw new Error("Could not resolve handle to DID");
27
+
}
28
+
did = resolved;
29
+
}
30
+
let agent;
31
+
let authed_identity = await getIdentityData();
32
+
if (authed_identity?.atp_did) {
33
+
try {
34
+
const oauthClient = await createOauthClient();
35
+
let credentialSession = await oauthClient.restore(
36
+
authed_identity.atp_did,
37
+
);
38
+
agent = new Agent(credentialSession);
39
+
} catch (e) {
40
+
agent = new Agent({
41
+
service: "https://public.api.bsky.app",
42
+
});
43
+
}
44
+
} else {
45
+
agent = new Agent({
46
+
service: "https://public.api.bsky.app",
47
+
});
48
+
}
49
+
50
+
let profileReq = agent.app.bsky.actor.getProfile({ actor: did });
51
+
52
+
let publicationsReq = supabase
53
+
.from("publications")
54
+
.select("*")
55
+
.eq("identity_did", did);
56
+
57
+
let [{ data: profile }, { data: publications }] = await Promise.all([
58
+
profileReq,
59
+
publicationsReq,
60
+
]);
61
+
62
+
return {
63
+
result: {
64
+
profile,
65
+
publications: publications || [],
66
+
},
67
+
};
68
+
},
69
+
});
+2
app/api/rpc/[command]/route.ts
+2
app/api/rpc/[command]/route.ts
···
13
13
import { get_publication_data } from "./get_publication_data";
14
14
import { search_publication_names } from "./search_publication_names";
15
15
import { search_publication_documents } from "./search_publication_documents";
16
+
import { get_profile_data } from "./get_profile_data";
16
17
17
18
let supabase = createClient<Database>(
18
19
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
39
40
get_publication_data,
40
41
search_publication_names,
41
42
search_publication_documents,
43
+
get_profile_data,
42
44
];
43
45
export async function POST(
44
46
req: Request,
+22
-4
app/globals.css
+22
-4
app/globals.css
···
296
296
@apply py-[1.5px];
297
297
}
298
298
299
-
/* Underline mention nodes when selected in ProseMirror */
299
+
.ProseMirror:focus-within .selection-highlight {
300
+
background-color: transparent;
301
+
}
302
+
300
303
.ProseMirror .atMention.ProseMirror-selectednode,
301
304
.ProseMirror .didMention.ProseMirror-selectednode {
302
-
text-decoration: underline;
305
+
@apply text-accent-contrast;
306
+
@apply px-0.5;
307
+
@apply -mx-[3px]; /* extra px to account for the border*/
308
+
@apply -my-px; /*to account for the border*/
309
+
@apply rounded-[4px];
310
+
@apply box-decoration-clone;
311
+
background-color: rgba(var(--accent-contrast), 0.2);
312
+
border: 1px solid rgba(var(--accent-contrast), 1);
303
313
}
304
314
305
-
.ProseMirror:focus-within .selection-highlight {
306
-
background-color: transparent;
315
+
.mention {
316
+
@apply cursor-pointer;
317
+
@apply text-accent-contrast;
318
+
@apply px-0.5;
319
+
@apply -mx-[3px];
320
+
@apply -my-px; /*to account for the border*/
321
+
@apply rounded-[4px];
322
+
@apply box-decoration-clone;
323
+
background-color: rgba(var(--accent-contrast), 0.2);
324
+
border: 1px solid transparent;
307
325
}
308
326
309
327
.multiselected:focus-within .selection-highlight {
+10
-13
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
+10
-13
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
···
2
2
import { PubLeafletRichtextFacet } from "lexicons/api";
3
3
import { didToBlueskyUrl } from "src/utils/mentionUtils";
4
4
import { AtMentionLink } from "components/AtMentionLink";
5
+
import { ProfilePopover } from "components/ProfilePopover";
5
6
6
7
type Facet = PubLeafletRichtextFacet.Main;
7
8
export function BaseTextBlock(props: {
···
27
28
let isDidMention = segment.facet?.find(
28
29
PubLeafletRichtextFacet.isDidMention,
29
30
);
30
-
let isAtMention = segment.facet?.find(
31
-
PubLeafletRichtextFacet.isAtMention,
32
-
);
31
+
let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention);
33
32
let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline);
34
33
let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic);
35
34
let isHighlighted = segment.facet?.find(
···
45
44
${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " ");
46
45
47
46
// Split text by newlines and insert <br> tags
48
-
const textParts = segment.text.split('\n');
47
+
const textParts = segment.text.split("\n");
49
48
const renderedText = textParts.flatMap((part, i) =>
50
-
i < textParts.length - 1 ? [part, <br key={`br-${counter}-${i}`} />] : [part]
49
+
i < textParts.length - 1
50
+
? [part, <br key={`br-${counter}-${i}`} />]
51
+
: [part],
51
52
);
52
53
53
54
if (isCode) {
···
58
59
);
59
60
} else if (isDidMention) {
60
61
children.push(
61
-
<a
62
+
<ProfilePopover
62
63
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>,
64
+
didOrHandle={isDidMention.did}
65
+
trigger={<span className="mention">{renderedText}</span>}
66
+
/>,
70
67
);
71
68
} else if (isAtMention) {
72
69
children.push(
-1
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
-1
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
+5
-2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
+5
-2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
···
1
-
import { UnicodeString } from "@atproto/api";
1
+
import { AtUri, UnicodeString } from "@atproto/api";
2
2
import { autolink } from "components/Blocks/TextBlock/autolink-plugin";
3
3
import { multiBlockSchema } from "components/Blocks/TextBlock/schema";
4
4
import { PubLeafletRichtextFacet } from "lexicons/api";
···
196
196
{
197
197
record: comment.record,
198
198
uri: comment.uri,
199
-
bsky_profiles: { record: comment.profile as Json },
199
+
bsky_profiles: {
200
+
record: comment.profile as Json,
201
+
did: new AtUri(comment.uri).host,
202
+
},
200
203
},
201
204
],
202
205
}));
+15
-99
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
+15
-99
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
···
18
18
import { QuoteContent } from "../Quotes";
19
19
import { timeAgo } from "src/utils/timeAgo";
20
20
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
21
+
import { ProfilePopover } from "components/ProfilePopover";
21
22
22
23
export type Comment = {
23
24
record: Json;
24
25
uri: string;
25
-
bsky_profiles: { record: Json } | null;
26
+
bsky_profiles: { record: Json; did: string } | null;
26
27
};
27
28
export function Comments(props: {
28
29
document_uri: string;
···
109
110
document: string;
110
111
comment: Comment;
111
112
comments: Comment[];
112
-
profile?: AppBskyActorProfile.Record;
113
+
profile: AppBskyActorProfile.Record;
113
114
record: PubLeafletComment.Record;
114
115
pageId?: string;
115
116
}) => {
117
+
const did = props.comment.bsky_profiles?.did;
118
+
116
119
return (
117
-
<div className="comment">
120
+
<div id={props.comment.uri} className="comment">
118
121
<div className="flex gap-2">
119
-
{props.profile && (
120
-
<ProfilePopover profile={props.profile} comment={props.comment.uri} />
122
+
{did && (
123
+
<ProfilePopover
124
+
didOrHandle={did}
125
+
trigger={
126
+
<div className="text-sm text-tertiary font-bold hover:underline">
127
+
{props.profile.displayName}
128
+
</div>
129
+
}
130
+
/>
121
131
)}
122
-
<DatePopover date={props.record.createdAt} />
123
132
</div>
124
133
{props.record.attachment &&
125
134
PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && (
···
291
300
</Popover>
292
301
);
293
302
};
294
-
295
-
const ProfilePopover = (props: {
296
-
profile: AppBskyActorProfile.Record;
297
-
comment: string;
298
-
}) => {
299
-
let commenterId = new AtUri(props.comment).host;
300
-
301
-
return (
302
-
<>
303
-
<a
304
-
className="font-bold text-tertiary text-sm hover:underline"
305
-
href={`https://bsky.app/profile/${commenterId}`}
306
-
>
307
-
{props.profile.displayName}
308
-
</a>
309
-
{/*<Media mobile={false}>
310
-
<Popover
311
-
align="start"
312
-
trigger={
313
-
<div
314
-
onMouseOver={() => {
315
-
setHovering(true);
316
-
hoverTimeout.current = window.setTimeout(() => {
317
-
setLoadProfile(true);
318
-
}, 500);
319
-
}}
320
-
onMouseOut={() => {
321
-
setHovering(false);
322
-
clearTimeout(hoverTimeout.current);
323
-
}}
324
-
className="font-bold text-tertiary text-sm hover:underline"
325
-
>
326
-
{props.profile.displayName}
327
-
</div>
328
-
}
329
-
className="max-w-sm"
330
-
>
331
-
{profile && (
332
-
<>
333
-
<div className="profilePopover text-sm flex gap-2">
334
-
<div className="w-5 h-5 bg-test rounded-full shrink-0 mt-[2px]" />
335
-
<div className="flex flex-col">
336
-
<div className="flex justify-between">
337
-
<div className="profileHeader flex gap-2 items-center">
338
-
<div className="font-bold">celine</div>
339
-
<a className="text-tertiary" href="/">
340
-
@{profile.handle}
341
-
</a>
342
-
</div>
343
-
</div>
344
-
345
-
<div className="profileBio text-secondary ">
346
-
{profile.description}
347
-
</div>
348
-
<div className="flex flex-row gap-2 items-center pt-2 font-bold">
349
-
{!profile.viewer?.following ? (
350
-
<div className="text-tertiary bg-border-light rounded-md px-1 py-0">
351
-
Following
352
-
</div>
353
-
) : (
354
-
<ButtonPrimary compact className="text-sm">
355
-
Follow <BlueskyTiny />
356
-
</ButtonPrimary>
357
-
)}
358
-
{profile.viewer?.followedBy && (
359
-
<div className="text-tertiary">Follows You</div>
360
-
)}
361
-
</div>
362
-
</div>
363
-
</div>
364
-
365
-
<hr className="my-2 border-border-light" />
366
-
<div className="flex gap-2 leading-tight items-center text-tertiary text-sm">
367
-
<div className="flex flex-col w-6 justify-center">
368
-
{profile.viewer?.knownFollowers?.followers.map((follower) => {
369
-
return (
370
-
<div
371
-
className="w-[18px] h-[18px] bg-test rounded-full border-2 border-bg-page"
372
-
key={follower.did}
373
-
/>
374
-
);
375
-
})}
376
-
<div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" />
377
-
<div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" />
378
-
</div>
379
-
</div>
380
-
</>
381
-
)}
382
-
</Popover>
383
-
</Media>*/}
384
-
</>
385
-
);
386
-
};
+4
-1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
+4
-1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
···
58
58
export const useDrawerOpen = (uri: string) => {
59
59
let params = useSearchParams();
60
60
let interactionDrawerSearchParam = params.get("interactionDrawer");
61
+
let pageParam = params.get("page");
61
62
let { drawerOpen: open, drawer, pageId } = useInteractionState(uri);
62
63
if (open === false || (open === undefined && !interactionDrawerSearchParam))
63
64
return null;
64
65
drawer =
65
66
drawer || (interactionDrawerSearchParam as InteractionState["drawer"]);
66
-
return { drawer, pageId };
67
+
// Use pageId from state, or fall back to page search param
68
+
const resolvedPageId = pageId ?? pageParam ?? undefined;
69
+
return { drawer, pageId: resolvedPageId };
67
70
};
-1
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
-1
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
+1
-1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
+1
-1
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
···
324
324
return (
325
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
326
<blockquote
327
-
className={`blockquote py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`}
327
+
className={`blockquoteBlock py-0! mb-2! ${className} ${PubLeafletBlocksBlockquote.isMain(previousBlock?.block) ? "-mt-2! pt-3!" : "mt-1!"}`}
328
328
{...blockProps}
329
329
>
330
330
<TextBlock
+2
-2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
+2
-2
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
···
119
119
{props.postTitle ? props.postTitle : "Untitled"}
120
120
</h2>
121
121
{props.postDescription ? (
122
-
<p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
122
+
<div className="postDescription italic text-secondary outline-hidden bg-transparent pt-1">
123
123
{props.postDescription}
124
-
</p>
124
+
</div>
125
125
) : null}
126
126
<div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between">
127
127
{props.postInfo}
+27
-10
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
+27
-10
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
19
19
import { Fragment, useEffect } from "react";
20
20
import { flushSync } from "react-dom";
21
21
import { scrollIntoView } from "src/utils/scrollIntoView";
22
-
import { useParams } from "next/navigation";
22
+
import { useParams, useSearchParams } from "next/navigation";
23
23
import { decodeQuotePosition } from "./quotePosition";
24
24
import { PollData } from "./fetchPollData";
25
25
import { LinearDocumentPage } from "./LinearDocumentPage";
···
32
32
33
33
export const useOpenPages = () => {
34
34
const { quote } = useParams();
35
+
const searchParams = useSearchParams();
36
+
const pageParam = searchParams.get("page");
35
37
const state = usePostPageUIState((s) => s);
36
38
37
-
if (!state.initialized && quote) {
38
-
const decodedQuote = decodeQuotePosition(quote as string);
39
-
if (decodedQuote?.pageId) {
40
-
return [decodedQuote.pageId];
39
+
if (!state.initialized) {
40
+
// Check for page search param first (for comment links)
41
+
if (pageParam) {
42
+
return [pageParam];
43
+
}
44
+
// Then check for quote param
45
+
if (quote) {
46
+
const decodedQuote = decodeQuotePosition(quote as string);
47
+
if (decodedQuote?.pageId) {
48
+
return [decodedQuote.pageId];
49
+
}
41
50
}
42
51
}
43
52
···
46
55
47
56
export const useInitializeOpenPages = () => {
48
57
const { quote } = useParams();
58
+
const searchParams = useSearchParams();
59
+
const pageParam = searchParams.get("page");
49
60
50
61
useEffect(() => {
51
62
const state = usePostPageUIState.getState();
52
63
if (!state.initialized) {
64
+
// Check for page search param first (for comment links)
65
+
if (pageParam) {
66
+
usePostPageUIState.setState({
67
+
pages: [pageParam],
68
+
initialized: true,
69
+
});
70
+
return;
71
+
}
72
+
// Then check for quote param
53
73
if (quote) {
54
74
const decodedQuote = decodeQuotePosition(quote as string);
55
75
if (decodedQuote?.pageId) {
···
63
83
// Mark as initialized even if no pageId found
64
84
usePostPageUIState.setState({ initialized: true });
65
85
}
66
-
}, [quote]);
86
+
}, [quote, pageParam]);
67
87
};
68
88
69
89
export const openPage = (
···
290
310
absolute sm:-right-[20px] right-3 sm:top-3 top-0
291
311
flex sm:flex-col flex-row-reverse gap-1 items-start`}
292
312
>
293
-
<PageOptionButton
294
-
cardBorderHidden={!props.hasPageBackground}
295
-
onClick={props.onClick}
296
-
>
313
+
<PageOptionButton onClick={props.onClick}>
297
314
<CloseTiny />
298
315
</PageOptionButton>
299
316
</div>
-1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
-1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
+9
-7
app/p/[didOrHandle]/[rkey]/page.tsx
+9
-7
app/p/[didOrHandle]/[rkey]/page.tsx
···
5
5
import { Metadata } from "next";
6
6
import { idResolver } from "app/(home-pages)/reader/idResolver";
7
7
import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
8
+
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
8
9
9
10
export async function generateMetadata(props: {
10
11
params: Promise<{ didOrHandle: string; rkey: string }>;
···
34
35
let docRecord = document.data as PubLeafletDocument.Record;
35
36
36
37
// For documents in publications, include publication name
37
-
let publicationName = document.documents_in_publications[0]?.publications?.name;
38
+
let publicationName =
39
+
document.documents_in_publications[0]?.publications?.name;
38
40
39
41
return {
40
42
icons: {
···
63
65
let resolved = await idResolver.handle.resolve(didOrHandle);
64
66
if (!resolved) {
65
67
return (
66
-
<div className="p-4 text-lg text-center flex flex-col gap-4">
67
-
<p>Sorry, can't resolve handle.</p>
68
+
<NotFoundLayout>
69
+
<p className="font-bold">Sorry, we can't find this handle!</p>
68
70
<p>
69
71
This may be a glitch on our end. If the issue persists please{" "}
70
72
<a href="mailto:contact@leaflet.pub">send us a note</a>.
71
73
</p>
72
-
</div>
74
+
</NotFoundLayout>
73
75
);
74
76
}
75
77
did = resolved;
76
78
} catch (e) {
77
79
return (
78
-
<div className="p-4 text-lg text-center flex flex-col gap-4">
79
-
<p>Sorry, can't resolve handle.</p>
80
+
<NotFoundLayout>
81
+
<p className="font-bold">Sorry, we can't find this leaflet!</p>
80
82
<p>
81
83
This may be a glitch on our end. If the issue persists please{" "}
82
84
<a href="mailto:contact@leaflet.pub">send us a note</a>.
83
85
</p>
84
-
</div>
86
+
</NotFoundLayout>
85
87
);
86
88
}
87
89
}
+1
-1
components/ActionBar/Publications.tsx
+1
-1
components/ActionBar/Publications.tsx
···
193
193
194
194
return props.record.icon ? (
195
195
<div
196
-
className={`${iconSizeClassName} ${props.className} relative overflow-hidden`}
196
+
className={`${iconSizeClassName} ${props.className} relative overflow-hidden shrink-0`}
197
197
>
198
198
<img
199
199
src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`}
+2
-2
components/AtMentionLink.tsx
+2
-2
components/AtMentionLink.tsx
···
24
24
isPublication || isDocument ? (
25
25
<img
26
26
src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
27
-
className="inline-block w-5 h-5 rounded-full mr-1 align-text-top"
27
+
className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top"
28
28
alt=""
29
29
width="20"
30
30
height="20"
···
37
37
href={atUriToUrl(atURI)}
38
38
target="_blank"
39
39
rel="noopener noreferrer"
40
-
className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
40
+
className={`mention ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
41
41
>
42
42
{icon}
43
43
{children}
+4
-1
components/Avatar.tsx
+4
-1
components/Avatar.tsx
···
3
3
export const Avatar = (props: {
4
4
src: string | undefined;
5
5
displayName: string | undefined;
6
+
className?: string;
6
7
tiny?: boolean;
8
+
large?: boolean;
9
+
giant?: boolean;
7
10
}) => {
8
11
if (props.src)
9
12
return (
10
13
<img
11
-
className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`}
14
+
className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`}
12
15
src={props.src}
13
16
alt={
14
17
props.displayName
+14
-5
components/Blocks/TextBlock/RenderYJSFragment.tsx
+14
-5
components/Blocks/TextBlock/RenderYJSFragment.tsx
···
6
6
import { didToBlueskyUrl } from "src/utils/mentionUtils";
7
7
import { AtMentionLink } from "components/AtMentionLink";
8
8
import { Delta } from "src/utils/yjsFragmentToString";
9
+
import { ProfilePopover } from "components/ProfilePopover";
9
10
10
11
type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p";
11
12
export function RenderYJSFragment({
···
63
64
);
64
65
}
65
66
66
-
if (node.constructor === XmlElement && node.nodeName === "hard_break") {
67
+
if (
68
+
node.constructor === XmlElement &&
69
+
node.nodeName === "hard_break"
70
+
) {
67
71
return <br key={index} />;
68
72
}
69
73
70
74
// Handle didMention inline nodes
71
-
if (node.constructor === XmlElement && node.nodeName === "didMention") {
75
+
if (
76
+
node.constructor === XmlElement &&
77
+
node.nodeName === "didMention"
78
+
) {
72
79
const did = node.getAttribute("did") || "";
73
80
const text = node.getAttribute("text") || "";
74
81
return (
···
77
84
target="_blank"
78
85
rel="noopener noreferrer"
79
86
key={index}
80
-
className="text-accent-contrast hover:underline cursor-pointer"
87
+
className="mention"
81
88
>
82
89
{text}
83
90
</a>
···
85
92
}
86
93
87
94
// Handle atMention inline nodes
88
-
if (node.constructor === XmlElement && node.nodeName === "atMention") {
95
+
if (
96
+
node.constructor === XmlElement &&
97
+
node.nodeName === "atMention"
98
+
) {
89
99
const atURI = node.getAttribute("atURI") || "";
90
100
const text = node.getAttribute("text") || "";
91
101
return (
···
161
171
162
172
return props;
163
173
}
164
-
+4
-3
components/Blocks/TextBlock/schema.ts
+4
-3
components/Blocks/TextBlock/schema.ts
···
147
147
toDOM(node) {
148
148
// NOTE: This rendering should match the AtMentionLink component in
149
149
// components/AtMentionLink.tsx. If you update one, update the other.
150
-
let className = "atMention text-accent-contrast";
150
+
let className = "atMention mention";
151
151
let aturi = new AtUri(node.attrs.atURI);
152
152
if (aturi.collection === "pub.leaflet.publication")
153
153
className += " font-bold";
···
168
168
"img",
169
169
{
170
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",
171
+
class:
172
+
"inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top",
172
173
alt: "",
173
174
width: "16",
174
175
height: "16",
···
214
215
return [
215
216
"span",
216
217
{
217
-
class: "didMention text-accent-contrast",
218
+
class: "didMention mention",
218
219
"data-did": node.attrs.did,
219
220
},
220
221
node.attrs.text,
+1
components/Icons/ReplyTiny.tsx
+1
components/Icons/ReplyTiny.tsx
+7
-8
components/PageHeader.tsx
+7
-8
components/PageHeader.tsx
···
1
1
"use client";
2
2
import { useState, useEffect } from "react";
3
+
import { useCardBorderHidden } from "./Pages/useCardBorderHidden";
3
4
4
-
export const Header = (props: {
5
-
children: React.ReactNode;
6
-
cardBorderHidden: boolean;
7
-
}) => {
5
+
export const Header = (props: { children: React.ReactNode }) => {
6
+
let cardBorderHidden = useCardBorderHidden();
8
7
let [scrollPos, setScrollPos] = useState(0);
9
8
10
9
useEffect(() => {
···
22
21
}
23
22
}, []);
24
23
25
-
let headerBGColor = props.cardBorderHidden
24
+
let headerBGColor = !cardBorderHidden
26
25
? "var(--bg-leaflet)"
27
26
: "var(--bg-page)";
28
27
···
54
53
style={
55
54
scrollPos < 20
56
55
? {
57
-
backgroundColor: props.cardBorderHidden
56
+
backgroundColor: !cardBorderHidden
58
57
? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})`
59
58
: `rgba(${headerBGColor}, ${scrollPos / 20})`,
60
-
paddingLeft: props.cardBorderHidden
59
+
paddingLeft: !cardBorderHidden
61
60
? "4px"
62
61
: `calc(${scrollPos / 20}*4px)`,
63
-
paddingRight: props.cardBorderHidden
62
+
paddingRight: !cardBorderHidden
64
63
? "8px"
65
64
: `calc(${scrollPos / 20}*8px)`,
66
65
}
+3
-23
components/PageLayouts/DashboardLayout.tsx
+3
-23
components/PageLayouts/DashboardLayout.tsx
···
25
25
import Link from "next/link";
26
26
import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny";
27
27
import { usePreserveScroll } from "src/hooks/usePreserveScroll";
28
+
import { Tab } from "components/Tab";
28
29
29
30
export type DashboardState = {
30
31
display?: "grid" | "list";
···
133
134
},
134
135
>(props: {
135
136
id: string;
136
-
cardBorderHidden: boolean;
137
137
tabs: T;
138
138
defaultTab: keyof T;
139
139
currentPage: navPages;
···
186
186
>
187
187
{Object.keys(props.tabs).length <= 1 && !controls ? null : (
188
188
<>
189
-
<Header cardBorderHidden={props.cardBorderHidden}>
189
+
<Header>
190
190
{headerState === "default" ? (
191
191
<>
192
192
{Object.keys(props.tabs).length > 1 && (
···
355
355
);
356
356
};
357
357
358
-
function Tab(props: {
359
-
name: string;
360
-
selected: boolean;
361
-
onSelect: () => void;
362
-
href?: string;
363
-
}) {
364
-
return (
365
-
<div
366
-
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`}
367
-
onClick={() => props.onSelect()}
368
-
>
369
-
{props.name}
370
-
{props.href && <ExternalLinkTiny />}
371
-
</div>
372
-
);
373
-
}
374
-
375
-
const FilterOptions = (props: {
376
-
hasPubs: boolean;
377
-
hasArchived: boolean;
378
-
}) => {
358
+
const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => {
379
359
let { filter } = useDashboardState();
380
360
let setState = useSetDashboardState();
381
361
let filterCount = Object.values(filter).filter(Boolean).length;
+3
-5
components/Pages/Page.tsx
+3
-5
components/Pages/Page.tsx
···
34
34
return focusedPageID === props.entityID;
35
35
});
36
36
let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc";
37
-
let cardBorderHidden = useCardBorderHidden(props.entityID);
38
37
39
38
let drawerOpen = useDrawerOpen(props.entityID);
40
39
return (
···
49
48
}}
50
49
id={elementId.page(props.entityID).container}
51
50
drawerOpen={!!drawerOpen}
52
-
cardBorderHidden={!!cardBorderHidden}
53
51
isFocused={isFocused}
54
52
fullPageScroll={props.fullPageScroll}
55
53
pageType={pageType}
···
77
75
id: string;
78
76
children: React.ReactNode;
79
77
pageOptions?: React.ReactNode;
80
-
cardBorderHidden: boolean;
81
78
fullPageScroll: boolean;
82
79
isFocused?: boolean;
83
80
onClickAction?: (e: React.MouseEvent) => void;
84
81
pageType: "canvas" | "doc";
85
82
drawerOpen: boolean | undefined;
86
83
}) => {
84
+
const cardBorderHidden = useCardBorderHidden();
87
85
let { ref } = usePreserveScroll<HTMLDivElement>(props.id);
88
86
return (
89
87
// this div wraps the contents AND the page options.
···
106
104
shrink-0 snap-center
107
105
overflow-y-scroll
108
106
${
109
-
!props.cardBorderHidden &&
107
+
!cardBorderHidden &&
110
108
`h-full border
111
109
bg-[rgba(var(--bg-page),var(--bg-page-alpha))]
112
110
${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"}
113
111
${props.isFocused ? "shadow-md border-border" : "border-border-light"}`
114
112
}
115
-
${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"}
113
+
${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"}
116
114
${props.fullPageScroll && "max-w-full "}
117
115
${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"}
118
116
${
+7
-29
components/Pages/PageOptions.tsx
+7
-29
components/Pages/PageOptions.tsx
···
21
21
export const PageOptionButton = ({
22
22
children,
23
23
secondary,
24
-
cardBorderHidden,
25
24
className,
26
25
disabled,
27
26
...props
28
27
}: {
29
28
children: React.ReactNode;
30
29
secondary?: boolean;
31
-
cardBorderHidden: boolean | undefined;
32
30
className?: string;
33
31
disabled?: boolean;
34
32
} & Omit<JSX.IntrinsicElements["button"], "content">) => {
33
+
const cardBorderHidden = useCardBorderHidden();
35
34
return (
36
35
<button
37
36
className={`
···
58
57
first: boolean | undefined;
59
58
isFocused: boolean;
60
59
}) => {
61
-
let cardBorderHidden = useCardBorderHidden(props.entityID);
62
-
63
60
return (
64
61
<div
65
62
className={`pageOptions w-fit z-10
···
69
66
>
70
67
{!props.first && (
71
68
<PageOptionButton
72
-
cardBorderHidden={cardBorderHidden}
73
69
secondary
74
70
onClick={() => {
75
71
useUIState.getState().closePage(props.entityID);
···
78
74
<CloseTiny />
79
75
</PageOptionButton>
80
76
)}
81
-
<OptionsMenu
82
-
entityID={props.entityID}
83
-
first={!!props.first}
84
-
cardBorderHidden={cardBorderHidden}
85
-
/>
86
-
<UndoButtons cardBorderHidden={cardBorderHidden} />
77
+
<OptionsMenu entityID={props.entityID} first={!!props.first} />
78
+
<UndoButtons />
87
79
</div>
88
80
);
89
81
};
90
82
91
-
export const UndoButtons = (props: {
92
-
cardBorderHidden: boolean | undefined;
93
-
}) => {
83
+
export const UndoButtons = () => {
94
84
let undoState = useUndoState();
95
85
let { undoManager } = useReplicache();
96
86
return (
97
87
<Media mobile>
98
88
{undoState.canUndo && (
99
89
<div className="gap-1 flex sm:flex-col">
100
-
<PageOptionButton
101
-
secondary
102
-
cardBorderHidden={props.cardBorderHidden}
103
-
onClick={() => undoManager.undo()}
104
-
>
90
+
<PageOptionButton secondary onClick={() => undoManager.undo()}>
105
91
<UndoTiny />
106
92
</PageOptionButton>
107
93
108
94
<PageOptionButton
109
95
secondary
110
-
cardBorderHidden={props.cardBorderHidden}
111
96
onClick={() => undoManager.undo()}
112
97
disabled={!undoState.canRedo}
113
98
>
···
119
104
);
120
105
};
121
106
122
-
export const OptionsMenu = (props: {
123
-
entityID: string;
124
-
first: boolean;
125
-
cardBorderHidden: boolean | undefined;
126
-
}) => {
107
+
export const OptionsMenu = (props: { entityID: string; first: boolean }) => {
127
108
let [state, setState] = useState<"normal" | "theme" | "share">("normal");
128
109
let { permissions } = useEntitySetContext();
129
110
if (!permissions.write) return null;
···
138
119
if (!open) setState("normal");
139
120
}}
140
121
trigger={
141
-
<PageOptionButton
142
-
cardBorderHidden={props.cardBorderHidden}
143
-
className="!w-8 !h-5 sm:!w-5 sm:!h-8"
144
-
>
122
+
<PageOptionButton className="!w-8 !h-5 sm:!w-5 sm:!h-8">
145
123
<MoreOptionsTiny className="sm:rotate-90" />
146
124
</PageOptionButton>
147
125
}
+3
-18
components/Pages/useCardBorderHidden.ts
+3
-18
components/Pages/useCardBorderHidden.ts
···
1
-
import { useLeafletPublicationData } from "components/PageSWRDataProvider";
2
-
import { PubLeafletPublication } from "lexicons/api";
3
-
import { useEntity, useReplicache } from "src/replicache";
1
+
import { useCardBorderHiddenContext } from "components/ThemeManager/ThemeProvider";
4
2
5
-
export function useCardBorderHidden(entityID: string | null) {
6
-
let { rootEntity } = useReplicache();
7
-
let { data: pub } = useLeafletPublicationData();
8
-
let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden");
9
-
10
-
let cardBorderHidden =
11
-
useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden;
12
-
if (!cardBorderHidden && !rootCardBorderHidden) {
13
-
if (pub?.publications?.record) {
14
-
let record = pub.publications.record as PubLeafletPublication.Record;
15
-
return !record.theme?.showPageBackground;
16
-
}
17
-
return false;
18
-
}
19
-
return (cardBorderHidden || rootCardBorderHidden)?.data.value;
3
+
export function useCardBorderHidden(entityID?: string | null) {
4
+
return useCardBorderHiddenContext();
20
5
}
+31
-22
components/PostListing.tsx
+31
-22
components/PostListing.tsx
···
15
15
import { InteractionPreview } from "./InteractionsPreview";
16
16
17
17
export const PostListing = (props: Post) => {
18
-
let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record;
18
+
let pubRecord = props.publication?.pubRecord as
19
+
| PubLeafletPublication.Record
20
+
| undefined;
19
21
20
22
let postRecord = props.documents.data as PubLeafletDocument.Record;
21
23
let postUri = new AtUri(props.documents.uri);
22
24
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;
25
+
let theme = usePubTheme(pubRecord?.theme);
26
+
let backgroundImage =
27
+
pubRecord?.theme?.backgroundImage?.image?.ref && props.publication
28
+
? blobRefToSrc(
29
+
pubRecord.theme.backgroundImage.image.ref,
30
+
new AtUri(props.publication.uri).host,
31
+
)
32
+
: null;
30
33
31
34
let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat;
32
35
let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500;
33
36
34
-
let showPageBackground = pubRecord.theme?.showPageBackground;
37
+
let showPageBackground = pubRecord?.theme?.showPageBackground;
35
38
36
39
let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0;
37
40
let comments =
38
-
pubRecord.preferences?.showComments === false
41
+
pubRecord?.preferences?.showComments === false
39
42
? 0
40
43
: props.documents.comments_on_documents?.[0]?.count || 0;
41
44
let tags = (postRecord?.tags as string[] | undefined) || [];
42
45
46
+
// For standalone posts, link directly to the document
47
+
let postHref = props.publication
48
+
? `${props.publication.href}/${postUri.rkey}`
49
+
: `/doc/${postUri.host}/${postUri.rkey}`;
50
+
43
51
return (
44
52
<BaseThemeProvider {...theme} local>
45
53
<div
46
54
style={{
47
-
backgroundImage: `url(${backgroundImage})`,
55
+
backgroundImage: backgroundImage
56
+
? `url(${backgroundImage})`
57
+
: undefined,
48
58
backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat",
49
59
backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`,
50
60
}}
···
55
65
hover:outline-accent-contrast hover:border-accent-contrast
56
66
`}
57
67
>
58
-
<Link
59
-
className="h-full w-full absolute top-0 left-0"
60
-
href={`${props.publication.href}/${postUri.rkey}`}
61
-
/>
68
+
<Link className="h-full w-full absolute top-0 left-0" href={postHref} />
62
69
<div
63
70
className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`}
64
71
style={{
···
71
78
72
79
<p className="text-secondary italic">{postRecord.description}</p>
73
80
<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
-
/>
81
+
{props.publication && pubRecord && (
82
+
<PubInfo
83
+
href={props.publication.href}
84
+
pubRecord={pubRecord}
85
+
uri={props.publication.uri}
86
+
/>
87
+
)}
79
88
<div className="flex flex-row justify-between gap-2 items-center w-full">
80
89
<PostInfo publishedAt={postRecord.publishedAt} />
81
90
<InteractionPreview
82
-
postUrl={`${props.publication.href}/${postUri.rkey}`}
91
+
postUrl={postHref}
83
92
quotesCount={quotes}
84
93
commentsCount={comments}
85
94
tags={tags}
86
-
showComments={pubRecord.preferences?.showComments}
95
+
showComments={pubRecord?.preferences?.showComments}
87
96
share
88
97
/>
89
98
</div>
+98
components/ProfilePopover.tsx
+98
components/ProfilePopover.tsx
···
1
+
"use client";
2
+
import { Popover } from "./Popover";
3
+
import useSWR from "swr";
4
+
import { callRPC } from "app/api/rpc/client";
5
+
import { useRef, useState } from "react";
6
+
import { ProfileHeader } from "app/(home-pages)/p/[didOrHandle]/ProfileHeader";
7
+
import { SpeedyLink } from "./SpeedyLink";
8
+
import { Tooltip } from "./Tooltip";
9
+
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
10
+
11
+
export const ProfilePopover = (props: {
12
+
trigger: React.ReactNode;
13
+
didOrHandle: string;
14
+
}) => {
15
+
const [isOpen, setIsOpen] = useState(false);
16
+
let [isHovered, setIsHovered] = useState(false);
17
+
const hoverTimeout = useRef<null | number>(null);
18
+
19
+
const { data, isLoading } = useSWR(
20
+
isHovered ? ["profile-data", props.didOrHandle] : null,
21
+
async () => {
22
+
const response = await callRPC("get_profile_data", {
23
+
didOrHandle: props.didOrHandle,
24
+
});
25
+
return response.result;
26
+
},
27
+
);
28
+
29
+
return (
30
+
<Tooltip
31
+
className="max-w-sm p-0! text-center"
32
+
asChild
33
+
trigger={
34
+
<a
35
+
className="no-underline"
36
+
href={`https://leaflet.pub/p/${props.didOrHandle}`}
37
+
target="_blank"
38
+
onPointerEnter={(e) => {
39
+
if (hoverTimeout.current) {
40
+
window.clearTimeout(hoverTimeout.current);
41
+
}
42
+
hoverTimeout.current = window.setTimeout(async () => {
43
+
setIsHovered(true);
44
+
}, 150);
45
+
}}
46
+
onPointerLeave={() => {
47
+
if (isHovered) return;
48
+
if (hoverTimeout.current) {
49
+
window.clearTimeout(hoverTimeout.current);
50
+
hoverTimeout.current = null;
51
+
}
52
+
setIsHovered(false);
53
+
}}
54
+
>
55
+
{props.trigger}
56
+
</a>
57
+
}
58
+
onOpenChange={setIsOpen}
59
+
>
60
+
{isLoading ? (
61
+
<div className="text-secondary p-4">Loading...</div>
62
+
) : data ? (
63
+
<div>
64
+
<ProfileHeader
65
+
profile={data.profile}
66
+
publications={data.publications}
67
+
popover
68
+
/>
69
+
<KnownFollowers viewer={data.profile.viewer} did={data.profile.did} />
70
+
</div>
71
+
) : (
72
+
<div className="text-secondary py-2 px-4">Profile not found</div>
73
+
)}
74
+
</Tooltip>
75
+
);
76
+
};
77
+
78
+
let KnownFollowers = (props: {
79
+
viewer: ProfileViewDetailed["viewer"];
80
+
did: string;
81
+
}) => {
82
+
if (!props.viewer?.knownFollowers) return null;
83
+
let count = props.viewer.knownFollowers.count;
84
+
return (
85
+
<>
86
+
<hr className="border-border" />
87
+
Followed by{" "}
88
+
<a
89
+
className="hover:underline"
90
+
href={`https://bsky.social/profile/${props.did}/known-followers`}
91
+
target="_blank"
92
+
>
93
+
{props.viewer?.knownFollowers?.followers[0]?.displayName}{" "}
94
+
{count > 1 ? `and ${count - 1} other${count > 2 ? "s" : ""}` : ""}
95
+
</a>
96
+
</>
97
+
);
98
+
};
+18
components/Tab.tsx
+18
components/Tab.tsx
···
1
+
import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny";
2
+
3
+
export const Tab = (props: {
4
+
name: string;
5
+
selected: boolean;
6
+
onSelect: () => void;
7
+
href?: string;
8
+
}) => {
9
+
return (
10
+
<div
11
+
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`}
12
+
onClick={() => props.onSelect()}
13
+
>
14
+
{props.name}
15
+
{props.href && <ExternalLinkTiny />}
16
+
</div>
17
+
);
18
+
};
+7
-4
components/ThemeManager/PublicationThemeProvider.tsx
+7
-4
components/ThemeManager/PublicationThemeProvider.tsx
···
4
4
import { useEntity } from "src/replicache";
5
5
import { getColorContrast } from "./themeUtils";
6
6
import { useColorAttribute, colorToString } from "./useColorAttribute";
7
-
import { BaseThemeProvider } from "./ThemeProvider";
7
+
import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider";
8
8
import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
9
9
import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
10
10
import { blobRefToSrc } from "src/utils/blobRefToSrc";
···
103
103
isStandalone?: boolean;
104
104
}) {
105
105
let colors = usePubTheme(props.theme, props.isStandalone);
106
+
let cardBorderHidden = !colors.showPageBackground;
106
107
return (
107
-
<BaseThemeProvider local={props.local} {...colors}>
108
-
{props.children}
109
-
</BaseThemeProvider>
108
+
<CardBorderHiddenContext.Provider value={cardBorderHidden}>
109
+
<BaseThemeProvider local={props.local} {...colors}>
110
+
{props.children}
111
+
</BaseThemeProvider>
112
+
</CardBorderHiddenContext.Provider>
110
113
);
111
114
}
112
115
+26
-22
components/ThemeManager/ThemeProvider.tsx
+26
-22
components/ThemeManager/ThemeProvider.tsx
···
1
1
"use client";
2
2
3
-
import {
4
-
createContext,
5
-
CSSProperties,
6
-
useContext,
7
-
useEffect,
8
-
} from "react";
3
+
import { createContext, CSSProperties, useContext, useEffect } from "react";
4
+
5
+
// Context for cardBorderHidden
6
+
export const CardBorderHiddenContext = createContext<boolean>(false);
7
+
8
+
export function useCardBorderHiddenContext() {
9
+
return useContext(CardBorderHiddenContext);
10
+
}
9
11
import {
10
12
colorToString,
11
13
useColorAttribute,
···
58
60
}) {
59
61
let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background");
60
62
let bgPage = useColorAttribute(props.entityID, "theme/card-background");
61
-
let showPageBackground = !useEntity(
63
+
let cardBorderHiddenValue = useEntity(
62
64
props.entityID,
63
65
"theme/card-border-hidden",
64
66
)?.data.value;
67
+
let showPageBackground = !cardBorderHiddenValue;
65
68
let primary = useColorAttribute(props.entityID, "theme/primary");
66
69
67
70
let highlight1 = useEntity(props.entityID, "theme/highlight-1");
···
72
75
let accent2 = useColorAttribute(props.entityID, "theme/accent-text");
73
76
74
77
return (
75
-
<BaseThemeProvider
76
-
local={props.local}
77
-
bgLeaflet={bgLeaflet}
78
-
bgPage={bgPage}
79
-
primary={primary}
80
-
highlight2={highlight2}
81
-
highlight3={highlight3}
82
-
highlight1={highlight1?.data.value}
83
-
accent1={accent1}
84
-
accent2={accent2}
85
-
showPageBackground={showPageBackground}
86
-
>
87
-
{props.children}
88
-
</BaseThemeProvider>
78
+
<CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}>
79
+
<BaseThemeProvider
80
+
local={props.local}
81
+
bgLeaflet={bgLeaflet}
82
+
bgPage={bgPage}
83
+
primary={primary}
84
+
highlight2={highlight2}
85
+
highlight3={highlight3}
86
+
highlight1={highlight1?.data.value}
87
+
accent1={accent1}
88
+
accent2={accent2}
89
+
showPageBackground={showPageBackground}
90
+
>
91
+
{props.children}
92
+
</BaseThemeProvider>
93
+
</CardBorderHiddenContext.Provider>
89
94
);
90
95
}
91
96
···
337
342
</div>
338
343
);
339
344
};
340
-
+1
-1
components/Tooltip.tsx
+1
-1
components/Tooltip.tsx
···
26
26
props.skipDelayDuration ? props.skipDelayDuration : 300
27
27
}
28
28
>
29
-
<RadixTooltip.Root>
29
+
<RadixTooltip.Root onOpenChange={props.onOpenChange} open={props.open}>
30
30
<RadixTooltip.Trigger disabled={props.disabled} asChild={props.asChild}>
31
31
{props.trigger}
32
32
</RadixTooltip.Trigger>