tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
28
pulls
pipelines
just like a bunch of stuff sorry
cozylittle.house
1 month ago
75b0ea62
ae852ba6
+374
-323
12 changed files
expand all
collapse all
unified
split
app
(home-pages)
notifications
FollowNotification.tsx
p
[didOrHandle]
ProfileHeader.tsx
lish
[did]
[publication]
[rkey]
BlueskyQuotesPage.tsx
BskyPostContent.tsx
Interactions
Comments
index.tsx
InteractionDrawer.tsx
Quotes.tsx
PostLinks.tsx
ThreadPage.tsx
components
Avatar.tsx
Blocks
BlueskyPostBlock
BlueskyEmbed.tsx
src
utils
timeAgo.ts
+1
-1
app/(home-pages)/notifications/FollowNotification.tsx
···
23
23
<Notification
24
24
timestamp={props.created_at}
25
25
href={pubRecord ? pubRecord.url : "#"}
26
26
-
icon={<Avatar src={avatarSrc} displayName={displayName} tiny />}
26
26
+
icon={<Avatar src={avatarSrc} displayName={displayName} size="tiny" />}
27
27
actionText={
28
28
<>
29
29
{displayName} subscribed to {pubRecord?.name}!
+5
-2
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
···
23
23
src={profileRecord.avatar}
24
24
displayName={profileRecord.displayName}
25
25
className="profileAvatar mx-auto mt-3 sm:mt-4"
26
26
-
giant
26
26
+
size="giant"
27
27
/>
28
28
);
29
29
···
100
100
</div>
101
101
);
102
102
};
103
103
-
const PublicationCard = (props: { record: NormalizedPublication; uri: string }) => {
103
103
+
const PublicationCard = (props: {
104
104
+
record: NormalizedPublication;
105
105
+
uri: string;
106
106
+
}) => {
104
107
const { record, uri } = props;
105
108
const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme);
106
109
+11
-13
app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx
···
7
7
import { QuoteTiny } from "components/Icons/QuoteTiny";
8
8
import { openPage } from "./PostPages";
9
9
import { BskyPostContent } from "./BskyPostContent";
10
10
-
import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks";
10
10
+
import {
11
11
+
QuotesLink,
12
12
+
getQuotesKey,
13
13
+
fetchQuotes,
14
14
+
prefetchQuotes,
15
15
+
} from "./PostLinks";
11
16
12
17
// Re-export for backwards compatibility
13
18
export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes };
···
27
32
data: quotesData,
28
33
isLoading,
29
34
error,
30
30
-
} = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri));
35
35
+
} = useSWR(postUri ? getQuotesKey(postUri) : null, () =>
36
36
+
fetchQuotes(postUri),
37
37
+
);
31
38
32
39
return (
33
40
<PageWrapper
···
69
76
return (
70
77
<div className="flex flex-col gap-0">
71
78
{posts.map((post) => (
72
72
-
<QuotePost
73
73
-
key={post.uri}
74
74
-
post={post}
75
75
-
quotesUri={postUri}
76
76
-
/>
79
79
+
<QuotePost key={post.uri} post={post} quotesUri={postUri} />
77
80
))}
78
81
</div>
79
82
);
80
83
}
81
84
82
82
-
function QuotePost(props: {
83
83
-
post: PostView;
84
84
-
quotesUri: string;
85
85
-
}) {
85
85
+
function QuotePost(props: { post: PostView; quotesUri: string }) {
86
86
const { post, quotesUri } = props;
87
87
const parent = { type: "quotes" as const, uri: quotesUri };
88
88
···
94
94
<BskyPostContent
95
95
post={post}
96
96
parent={parent}
97
97
-
linksEnabled={true}
98
97
showEmbed={true}
99
98
showBlueskyLink={true}
100
100
-
onLinkClick={(e) => e.stopPropagation()}
101
99
onEmbedClick={(e) => e.stopPropagation()}
102
100
/>
103
101
</div>
+83
-77
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
···
1
1
"use client";
2
2
import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
3
3
-
import {
4
4
-
BlueskyEmbed,
5
5
-
} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
3
3
+
import { BlueskyEmbed } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
6
4
import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText";
7
5
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
8
6
import { CommentTiny } from "components/Icons/CommentTiny";
···
12
10
import { useHasPageLoaded } from "components/InitialPageLoadProvider";
13
11
import { OpenPage } from "./PostPages";
14
12
import { ThreadLink, QuotesLink } from "./PostLinks";
13
13
+
import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny";
14
14
+
import { Avatar } from "components/Avatar";
15
15
+
import { timeAgo } from "src/utils/timeAgo";
16
16
+
import { ProfilePopover } from "components/ProfilePopover";
15
17
16
18
type PostView = AppBskyFeedDefs.PostView;
17
19
18
20
export function BskyPostContent(props: {
19
21
post: PostView;
20
22
parent?: OpenPage;
21
21
-
linksEnabled?: boolean;
22
22
-
avatarSize?: "sm" | "md";
23
23
+
avatarSize?: "tiny" | "medium" | "large" | "giant";
24
24
+
className?: string;
23
25
showEmbed?: boolean;
24
26
showBlueskyLink?: boolean;
25
27
onEmbedClick?: (e: React.MouseEvent) => void;
26
26
-
onLinkClick?: (e: React.MouseEvent) => void;
28
28
+
quoteCountOnClick?: (e: React.MouseEvent) => void;
29
29
+
replyCountOnClick?: (e: React.MouseEvent) => void;
27
30
}) {
28
31
const {
29
32
post,
30
33
parent,
31
31
-
linksEnabled = true,
32
34
avatarSize = "md",
33
35
showEmbed = true,
34
36
showBlueskyLink = true,
35
37
onEmbedClick,
36
36
-
onLinkClick,
38
38
+
quoteCountOnClick,
39
39
+
replyCountOnClick,
37
40
} = props;
38
41
39
42
const record = post.record as AppBskyFeedPost.Record;
40
43
const postId = post.uri.split("/")[4];
41
44
const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
42
45
43
43
-
const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10";
44
44
-
45
46
return (
46
47
<>
47
47
-
<div className="flex flex-col items-center shrink-0">
48
48
-
{post.author.avatar ? (
49
49
-
<img
50
50
-
src={post.author.avatar}
51
51
-
alt={`${post.author.displayName}'s avatar`}
52
52
-
className={`${avatarClass} rounded-full border border-border-light`}
53
53
-
/>
54
54
-
) : (
55
55
-
<div className={`${avatarClass} rounded-full border border-border-light bg-border`} />
56
56
-
)}
57
57
-
</div>
48
48
+
<Avatar
49
49
+
src={post.author.avatar}
50
50
+
displayName={post.author.displayName}
51
51
+
size={props.avatarSize ? props.avatarSize : "medium"}
52
52
+
/>
58
53
59
59
-
<div className="flex flex-col grow min-w-0">
60
60
-
<div className={`flex items-center gap-2 leading-tight ${avatarSize === "sm" ? "text-sm" : ""}`}>
61
61
-
<div className="font-bold text-secondary">
62
62
-
{post.author.displayName}
54
54
+
<div className={`flex flex-col grow min-w-0 ${props.className}`}>
55
55
+
<div
56
56
+
className={`flex justify-between items-center gap-2 leading-tight `}
57
57
+
>
58
58
+
<div className="flex gap-2 items-center">
59
59
+
<div className="font-bold text-secondary">
60
60
+
{post.author.displayName}
61
61
+
</div>
62
62
+
<ProfilePopover
63
63
+
trigger={
64
64
+
<div className="text-sm text-tertiary hover:underline">
65
65
+
@{post.author.handle}
66
66
+
</div>
67
67
+
}
68
68
+
didOrHandle={post.author.handle}
69
69
+
/>
63
70
</div>
64
64
-
<a
65
65
-
className="text-xs text-tertiary hover:underline"
66
66
-
target="_blank"
67
67
-
href={`https://bsky.app/profile/${post.author.handle}`}
68
68
-
onClick={onLinkClick}
69
69
-
>
70
70
-
@{post.author.handle}
71
71
-
</a>
71
71
+
<div className="text-sm text-tertiary">
72
72
+
{timeAgo(record.createdAt, { compact: true })}
73
73
+
</div>
72
74
</div>
73
75
74
74
-
<div className={`flex flex-col gap-2 ${avatarSize === "sm" ? "mt-0.5" : "mt-1"}`}>
76
76
+
<div
77
77
+
className={`flex flex-col gap-2 ${avatarSize === "large" ? "mt-0.5" : "mt-1"}`}
78
78
+
>
75
79
<div className="text-sm text-secondary">
76
80
<BlueskyRichText record={record} />
77
81
</div>
78
82
{showEmbed && post.embed && (
79
83
<div onClick={onEmbedClick}>
80
80
-
<BlueskyEmbed embed={post.embed} postUrl={url} />
84
84
+
<BlueskyEmbed
85
85
+
embed={post.embed}
86
86
+
postUrl={url}
87
87
+
className="text-sm"
88
88
+
/>
81
89
</div>
82
90
)}
83
91
</div>
84
92
85
85
-
<div className={`flex gap-2 items-center ${avatarSize === "sm" ? "mt-1" : "mt-2"}`}>
86
86
-
<ClientDate date={record.createdAt} />
93
93
+
<div className={`flex gap-2 items-center justify-between mt-2`}>
87
94
<PostCounts
88
95
post={post}
89
96
parent={parent}
90
90
-
linksEnabled={linksEnabled}
97
97
+
replyCountOnClick={replyCountOnClick}
98
98
+
quoteCountOnClick={quoteCountOnClick}
91
99
showBlueskyLink={showBlueskyLink}
92
100
url={url}
93
93
-
onLinkClick={onLinkClick}
94
101
/>
102
102
+
<div className="flex gap-3 items-center">
103
103
+
{showBlueskyLink && (
104
104
+
<>
105
105
+
<a className="text-tertiary" target="_blank" href={url}>
106
106
+
<BlueskyLinkTiny />
107
107
+
</a>
108
108
+
</>
109
109
+
)}
110
110
+
</div>
95
111
</div>
96
112
</div>
97
113
</>
···
101
117
function PostCounts(props: {
102
118
post: PostView;
103
119
parent?: OpenPage;
104
104
-
linksEnabled: boolean;
120
120
+
quoteCountOnClick?: (e: React.MouseEvent) => void;
121
121
+
replyCountOnClick?: (e: React.MouseEvent) => void;
105
122
showBlueskyLink: boolean;
106
123
url: string;
107
107
-
onLinkClick?: (e: React.MouseEvent) => void;
108
124
}) {
109
109
-
const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props;
110
110
-
111
125
return (
112
112
-
<div className="flex gap-2 items-center">
113
113
-
{post.replyCount != null && post.replyCount > 0 && (
126
126
+
<div className="postCounts flex gap-2 items-center">
127
127
+
{props.post.replyCount != null && props.post.replyCount > 0 && (
114
128
<>
115
115
-
<Separator classname="h-3" />
116
116
-
{linksEnabled ? (
129
129
+
{props.replyCountOnClick ? (
117
130
<ThreadLink
118
118
-
threadUri={post.uri}
131
131
+
threadUri={props.post.uri}
119
132
parent={parent}
120
120
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
121
121
-
onClick={onLinkClick}
133
133
+
className="relative postRepliesLink flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
134
134
+
onClick={props.replyCountOnClick}
122
135
>
123
123
-
{post.replyCount}
124
136
<CommentTiny />
137
137
+
{props.post.replyCount}
125
138
</ThreadLink>
126
139
) : (
127
127
-
<div className="flex items-center gap-1 text-tertiary text-xs">
128
128
-
{post.replyCount}
140
140
+
<div className="postRepliesCount flex items-center gap-1 text-tertiary text-xs">
129
141
<CommentTiny />
142
142
+
{props.post.replyCount}
130
143
</div>
131
144
)}
132
145
</>
133
146
)}
134
134
-
{post.quoteCount != null && post.quoteCount > 0 && (
135
135
-
<>
136
136
-
<Separator classname="h-3" />
137
137
-
<QuotesLink
138
138
-
postUri={post.uri}
139
139
-
parent={parent}
140
140
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
141
141
-
onClick={onLinkClick}
142
142
-
>
143
143
-
{post.quoteCount}
144
144
-
<QuoteTiny />
145
145
-
</QuotesLink>
146
146
-
</>
147
147
-
)}
148
148
-
{showBlueskyLink && (
147
147
+
{props.post.quoteCount != null && props.post.quoteCount > 0 && (
149
148
<>
150
150
-
<Separator classname="h-3" />
151
151
-
<a
152
152
-
className="text-tertiary"
153
153
-
target="_blank"
154
154
-
href={url}
155
155
-
onClick={onLinkClick}
156
156
-
>
157
157
-
<BlueskyTiny />
158
158
-
</a>
149
149
+
{props.quoteCountOnClick ? (
150
150
+
<QuotesLink
151
151
+
postUri={props.post.uri}
152
152
+
parent={parent}
153
153
+
className="relative flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
154
154
+
onClick={props.quoteCountOnClick}
155
155
+
>
156
156
+
<QuoteTiny />
157
157
+
{props.post.quoteCount}
158
158
+
</QuotesLink>
159
159
+
) : (
160
160
+
<div className="postQuoteCount flex items-center gap-1 text-tertiary text-xs">
161
161
+
<QuoteTiny />
162
162
+
{props.post.quoteCount}
163
163
+
</div>
164
164
+
)}
159
165
</>
160
166
)}
161
167
</div>
+1
-1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
···
25
25
uri: string;
26
26
bsky_profiles: { record: Json; did: string } | null;
27
27
};
28
28
-
export function Comments(props: {
28
28
+
export function CommentsDrawerContent(props: {
29
29
document_uri: string;
30
30
comments: Comment[];
31
31
pageId?: string;
+7
-4
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
···
1
1
"use client";
2
2
import { Media } from "components/Media";
3
3
-
import { Quotes } from "./Quotes";
3
3
+
import { MentionsDrawerContent } from "./Quotes";
4
4
import { InteractionState, useInteractionState } from "./Interactions";
5
5
import { Json } from "supabase/database.types";
6
6
-
import { Comment, Comments } from "./Comments";
6
6
+
import { Comment, CommentsDrawerContent } from "./Comments";
7
7
import { useSearchParams } from "next/navigation";
8
8
import { SandwichSpacer } from "components/LeafletLayout";
9
9
import { decodeQuotePosition } from "../quotePosition";
···
42
42
className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`}
43
43
>
44
44
{drawer.drawer === "quotes" ? (
45
45
-
<Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
45
45
+
<MentionsDrawerContent
46
46
+
{...props}
47
47
+
quotesAndMentions={filteredQuotesAndMentions}
48
48
+
/>
46
49
) : (
47
47
-
<Comments
50
50
+
<CommentsDrawerContent
48
51
document_uri={props.document_uri}
49
52
comments={filteredComments}
50
53
pageId={props.pageId}
+98
-128
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
···
24
24
import { CommentTiny } from "components/Icons/CommentTiny";
25
25
import { QuoteTiny } from "components/Icons/QuoteTiny";
26
26
import { ThreadLink, QuotesLink } from "../PostLinks";
27
27
+
import { BskyPostContent } from "../BskyPostContent";
27
28
28
29
// Helper to get SWR key for quotes
29
30
export function getQuotesSWRKey(uris: string[]) {
···
61
62
}
62
63
}
63
64
64
64
-
export const Quotes = (props: {
65
65
+
export const MentionsDrawerContent = (props: {
65
66
quotesAndMentions: { uri: string; link?: string }[];
66
67
did: string;
67
68
}) => {
···
85
86
});
86
87
87
88
return (
88
88
-
<div className="flex flex-col gap-2">
89
89
-
<div className="w-full flex justify-between text-secondary font-bold">
90
90
-
Quotes
91
91
-
<button
92
92
-
className="text-tertiary"
93
93
-
onClick={() =>
94
94
-
setInteractionState(document_uri, { drawerOpen: false })
95
95
-
}
96
96
-
>
97
97
-
<CloseTiny />
98
98
-
</button>
99
99
-
</div>
89
89
+
<div className="relative w-full flex justify-between ">
90
90
+
<button
91
91
+
className="text-tertiary absolute top-0 right-0"
92
92
+
onClick={() => setInteractionState(document_uri, { drawerOpen: false })}
93
93
+
>
94
94
+
<CloseTiny />
95
95
+
</button>
100
96
{props.quotesAndMentions.length === 0 ? (
101
97
<div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center">
102
98
<div className="font-bold">no quotes yet!</div>
···
108
104
<DotLoader />
109
105
</div>
110
106
) : (
111
111
-
<div className="quotes flex flex-col gap-8">
112
112
-
{/* Quotes with links (quoted content) */}
113
113
-
{quotesWithLinks.map((q, index) => {
114
114
-
const pv = postViewMap.get(q.uri);
115
115
-
if (!pv || !q.link) return null;
116
116
-
const url = new URL(q.link);
117
117
-
const quoteParam = url.pathname.split("/l-quote/")[1];
118
118
-
if (!quoteParam) return null;
119
119
-
const quotePosition = decodeQuotePosition(quoteParam);
120
120
-
if (!quotePosition) return null;
121
121
-
return (
122
122
-
<div key={`quote-${index}`} className="flex flex-col ">
123
123
-
<QuoteContent
124
124
-
index={index}
125
125
-
did={props.did}
126
126
-
position={quotePosition}
127
127
-
/>
128
128
-
129
129
-
<div className="h-5 w-1 ml-5 border-l border-border-light" />
130
130
-
<BskyPost
131
131
-
uri={pv.uri}
132
132
-
rkey={new AtUri(pv.uri).rkey}
133
133
-
content={pv.record.text as string}
134
134
-
user={pv.author.displayName || pv.author.handle}
135
135
-
profile={pv.author}
136
136
-
handle={pv.author.handle}
137
137
-
replyCount={pv.replyCount}
138
138
-
quoteCount={pv.quoteCount}
139
139
-
/>
140
140
-
</div>
141
141
-
);
142
142
-
})}
143
143
-
107
107
+
<div className="flex flex-col gap-8">
108
108
+
{quotesWithLinks.length > 0 && (
109
109
+
<div className="flex flex-col gap-4">
110
110
+
Quotes
111
111
+
{/* Quotes with links (quoted content) */}
112
112
+
{quotesWithLinks.map((q, index) => {
113
113
+
return (
114
114
+
<div className="flex gap-2">
115
115
+
<Quote
116
116
+
q={q}
117
117
+
index={index}
118
118
+
did={props.did}
119
119
+
postViewMap={postViewMap}
120
120
+
/>
121
121
+
</div>
122
122
+
);
123
123
+
})}
124
124
+
</div>
125
125
+
)}
144
126
{/* Direct post mentions (without quoted content) */}
145
127
{directMentions.length > 0 && (
146
128
<div className="flex flex-col gap-4">
147
147
-
<div className="text-secondary font-bold">Post Mentions</div>
129
129
+
<div className="text-secondary font-bold">
130
130
+
Mentions on Bluesky
131
131
+
</div>
148
132
<div className="flex flex-col gap-8">
149
133
{directMentions.map((q, index) => {
150
150
-
const pv = postViewMap.get(q.uri);
151
151
-
if (!pv) return null;
134
134
+
const post = postViewMap.get(q.uri);
135
135
+
if (!post) return null;
136
136
+
137
137
+
const parent = { type: "thread" as const, uri: q.uri };
152
138
return (
153
153
-
<BskyPost
154
154
-
key={`mention-${index}`}
155
155
-
uri={pv.uri}
156
156
-
rkey={new AtUri(pv.uri).rkey}
157
157
-
content={pv.record.text as string}
158
158
-
user={pv.author.displayName || pv.author.handle}
159
159
-
profile={pv.author}
160
160
-
handle={pv.author.handle}
161
161
-
replyCount={pv.replyCount}
162
162
-
quoteCount={pv.quoteCount}
163
163
-
/>
139
139
+
<button
140
140
+
className="flex gap-2 text-left"
141
141
+
onClick={() => {
142
142
+
openPage(undefined, { type: "thread", uri: q.uri });
143
143
+
}}
144
144
+
>
145
145
+
<BskyPostContent
146
146
+
key={`mention-${index}`}
147
147
+
post={post}
148
148
+
parent={parent}
149
149
+
showBlueskyLink={true}
150
150
+
showEmbed={true}
151
151
+
avatarSize="large"
152
152
+
quoteCountOnClick={(e) => {
153
153
+
e.stopPropagation();
154
154
+
e.preventDefault();
155
155
+
openPage(undefined, { type: "quotes", uri: q.uri });
156
156
+
}}
157
157
+
/>
158
158
+
</button>
164
159
);
165
160
})}
166
161
</div>
···
172
167
);
173
168
};
174
169
170
170
+
const Quote = (props: {
171
171
+
q: {
172
172
+
uri: string;
173
173
+
link?: string;
174
174
+
};
175
175
+
index: number;
176
176
+
did: string;
177
177
+
postViewMap: Map<string, PostView>;
178
178
+
}) => {
179
179
+
const post = props.postViewMap.get(props.q.uri);
180
180
+
if (!post || !props.q.link) return null;
181
181
+
const parent = { type: "thread" as const, uri: props.q.uri };
182
182
+
const url = new URL(props.q.link);
183
183
+
const quoteParam = url.pathname.split("/l-quote/")[1];
184
184
+
if (!quoteParam) return null;
185
185
+
const quotePosition = decodeQuotePosition(quoteParam);
186
186
+
if (!quotePosition) return null;
187
187
+
188
188
+
return (
189
189
+
<div key={`quote-${props.index}`} className="flex flex-col ">
190
190
+
<QuoteContent
191
191
+
index={props.index}
192
192
+
did={props.did}
193
193
+
position={quotePosition}
194
194
+
/>
195
195
+
196
196
+
<div className="h-5 w-1 ml-5 border-l border-border-light" />
197
197
+
<BskyPostContent
198
198
+
post={post}
199
199
+
parent={parent}
200
200
+
showBlueskyLink={true}
201
201
+
showEmbed={true}
202
202
+
avatarSize="large"
203
203
+
/>
204
204
+
</div>
205
205
+
);
206
206
+
};
207
207
+
175
208
export const QuoteContent = (props: {
176
209
position: QuotePosition;
177
210
index: number;
···
206
239
className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer"
207
240
onClick={(e) => {
208
241
if (props.position.pageId)
209
209
-
flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! }));
242
242
+
flushSync(() =>
243
243
+
openPage(undefined, { type: "doc", id: props.position.pageId! }),
244
244
+
);
210
245
let scrollMargin = isMobile
211
246
? 16
212
247
: e.currentTarget.getBoundingClientRect().top;
213
248
let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`;
214
214
-
let scrollContainer = window.document.getElementById(scrollContainerId);
249
249
+
let scrollContainer =
250
250
+
window.document.getElementById(scrollContainerId);
215
251
let el = window.document.getElementById(
216
252
props.position.start.block.join("."),
217
253
);
···
238
274
preview
239
275
className="py-0!"
240
276
/>
241
241
-
</div>
242
242
-
</div>
243
243
-
</div>
244
244
-
);
245
245
-
};
246
246
-
247
247
-
export const BskyPost = (props: {
248
248
-
uri: string;
249
249
-
rkey: string;
250
250
-
content: string;
251
251
-
user: string;
252
252
-
handle: string;
253
253
-
profile: ProfileViewBasic;
254
254
-
replyCount?: number;
255
255
-
quoteCount?: number;
256
256
-
}) => {
257
257
-
const handleOpenThread = () => {
258
258
-
openPage(undefined, { type: "thread", uri: props.uri });
259
259
-
};
260
260
-
261
261
-
return (
262
262
-
<div
263
263
-
onClick={handleOpenThread}
264
264
-
className="quoteSectionBskyItem px-2 flex gap-[6px] hover:no-underline font-normal cursor-pointer hover:bg-bg-page rounded"
265
265
-
>
266
266
-
{props.profile.avatar && (
267
267
-
<img
268
268
-
className="rounded-full w-6 h-6 shrink-0"
269
269
-
src={props.profile.avatar}
270
270
-
alt={props.profile.displayName}
271
271
-
/>
272
272
-
)}
273
273
-
<div className="flex flex-col min-w-0">
274
274
-
<div className="flex items-center gap-2 flex-wrap">
275
275
-
<div className="font-bold">{props.user}</div>
276
276
-
<a
277
277
-
className="text-tertiary hover:underline"
278
278
-
href={`https://bsky.app/profile/${props.handle}`}
279
279
-
target="_blank"
280
280
-
onClick={(e) => e.stopPropagation()}
281
281
-
>
282
282
-
@{props.handle}
283
283
-
</a>
284
284
-
</div>
285
285
-
<div className="text-primary">{props.content}</div>
286
286
-
<div className="flex gap-2 items-center mt-1">
287
287
-
{props.replyCount != null && props.replyCount > 0 && (
288
288
-
<ThreadLink
289
289
-
threadUri={props.uri}
290
290
-
onClick={(e) => e.stopPropagation()}
291
291
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
292
292
-
>
293
293
-
<CommentTiny />
294
294
-
{props.replyCount} {props.replyCount === 1 ? "reply" : "replies"}
295
295
-
</ThreadLink>
296
296
-
)}
297
297
-
{props.quoteCount != null && props.quoteCount > 0 && (
298
298
-
<QuotesLink
299
299
-
postUri={props.uri}
300
300
-
onClick={(e) => e.stopPropagation()}
301
301
-
className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast"
302
302
-
>
303
303
-
<QuoteTiny />
304
304
-
{props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"}
305
305
-
</QuotesLink>
306
306
-
)}
307
277
</div>
308
278
</div>
309
279
</div>
-1
app/lish/[did]/[publication]/[rkey]/PostLinks.tsx
···
104
104
const handlePrefetch = () => {
105
105
prefetchQuotes(postUri);
106
106
};
107
107
-
108
107
return (
109
108
<button
110
109
className={className}
+116
-86
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
···
106
106
}
107
107
108
108
return (
109
109
-
<div className="flex flex-col gap-0">
110
110
-
{/* Parent posts */}
109
109
+
<div className="threadContent flex flex-col gap-0">
110
110
+
{/* grandparent posts, if any */}
111
111
{parents.map((parent, index) => (
112
112
<div key={parent.post.uri} className="flex flex-col">
113
113
<ThreadPost
···
131
131
132
132
{/* Replies */}
133
133
{thread.replies && thread.replies.length > 0 && (
134
134
-
<div className="flex flex-col mt-2 pt-2 border-t border-border-light">
135
135
-
<div className="text-tertiary text-xs font-bold mb-2 px-2">
136
136
-
Replies
137
137
-
</div>
134
134
+
<div className="threadReplies flex flex-col mt-2 pt-2 border-t border-border-light">
138
135
<Replies
139
136
replies={thread.replies as any[]}
140
137
threadUri={threadUri}
···
158
155
const parent = { type: "thread" as const, uri: threadUri };
159
156
160
157
return (
161
161
-
<div className="flex gap-2 relative">
158
158
+
<div className="threadPost flex gap-2 relative">
162
159
{/* Reply line connector */}
163
160
{showReplyLine && (
164
161
<div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" />
165
162
)}
166
166
-
167
163
<BskyPostContent
168
164
post={postView}
169
165
parent={parent}
170
170
-
linksEnabled={!isMainPost}
171
166
showBlueskyLink={true}
172
167
showEmbed={true}
168
168
+
quoteCountOnClick={(e) => {
169
169
+
e.stopPropagation();
170
170
+
e.preventDefault();
171
171
+
openPage(parent, { type: "quotes", uri: postView.uri });
172
172
+
}}
173
173
+
replyCountOnClick={(e) => {
174
174
+
e.stopPropagation();
175
175
+
e.preventDefault();
176
176
+
}}
173
177
/>
174
178
</div>
175
179
);
···
180
184
threadUri: string;
181
185
depth: number;
182
186
parentAuthorDid?: string;
187
187
+
parentUri?: string;
183
188
}) {
184
184
-
const { replies, threadUri, depth, parentAuthorDid } = props;
189
189
+
const { replies, threadUri, depth, parentAuthorDid, parentUri } = props;
185
190
const collapsedThreads = useThreadState((s) => s.collapsedThreads);
186
191
const toggleCollapsed = useThreadState((s) => s.toggleCollapsed);
187
192
···
201
206
: replies;
202
207
203
208
return (
204
204
-
<div className="flex flex-col gap-0">
209
209
+
<div className="threadPageReplies flex flex-col gap-0">
205
210
{sortedReplies.map((reply, index) => {
206
211
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
207
212
return (
208
213
<div
209
214
key={`not-found-${index}`}
210
210
-
className="text-tertiary italic text-xs py-2 px-2"
215
215
+
className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2"
211
216
>
212
217
Post not found
213
218
</div>
···
218
223
return (
219
224
<div
220
225
key={`blocked-${index}`}
221
221
-
className="text-tertiary italic text-xs py-2 px-2"
226
226
+
className="text-tertiary italic text-sm px-t py-6 opaque-container text-center justify-center my-2"
222
227
>
223
228
Post blocked
224
229
</div>
···
231
236
232
237
const hasReplies = reply.replies && reply.replies.length > 0;
233
238
const isCollapsed = collapsedThreads.has(reply.post.uri);
234
234
-
const replyCount = reply.replies?.length ?? 0;
235
239
236
240
return (
237
237
-
<div key={reply.post.uri} className="flex flex-col">
238
238
-
<ReplyPost
239
239
-
post={reply}
240
240
-
showReplyLine={hasReplies || index < replies.length - 1}
241
241
-
isLast={index === replies.length - 1 && !hasReplies}
242
242
-
threadUri={threadUri}
243
243
-
/>
244
244
-
{hasReplies && depth < 3 && (
245
245
-
<div className="ml-2 flex">
246
246
-
{/* Clickable collapse line - w-8 matches avatar width, centered line aligns with avatar center */}
247
247
-
<button
248
248
-
onClick={(e) => {
249
249
-
e.stopPropagation();
250
250
-
toggleCollapsed(reply.post.uri);
251
251
-
}}
252
252
-
className="group w-8 flex justify-center cursor-pointer shrink-0"
253
253
-
aria-label={
254
254
-
isCollapsed ? "Expand replies" : "Collapse replies"
255
255
-
}
256
256
-
>
257
257
-
<div className="w-0.5 h-full bg-border-light group-hover:bg-accent-contrast group-hover:w-1 transition-all" />
258
258
-
</button>
259
259
-
{isCollapsed ? (
260
260
-
<button
261
261
-
onClick={(e) => {
262
262
-
e.stopPropagation();
263
263
-
toggleCollapsed(reply.post.uri);
264
264
-
}}
265
265
-
className="text-xs text-accent-contrast hover:underline py-1 pl-1"
266
266
-
>
267
267
-
Show {replyCount} {replyCount === 1 ? "reply" : "replies"}
268
268
-
</button>
269
269
-
) : (
270
270
-
<div className="grow">
271
271
-
<Replies
272
272
-
replies={reply.replies as any[]}
273
273
-
threadUri={threadUri}
274
274
-
depth={depth + 1}
275
275
-
parentAuthorDid={reply.post.author.did}
276
276
-
/>
277
277
-
</div>
278
278
-
)}
279
279
-
</div>
280
280
-
)}
281
281
-
{hasReplies && depth >= 3 && (
282
282
-
<ThreadLink
283
283
-
threadUri={reply.post.uri}
284
284
-
parent={{ type: "thread", uri: threadUri }}
285
285
-
className="ml-12 text-xs text-accent-contrast hover:underline py-1"
286
286
-
>
287
287
-
View more replies
288
288
-
</ThreadLink>
289
289
-
)}
290
290
-
</div>
241
241
+
<ReplyPost
242
242
+
post={reply}
243
243
+
isLast={index === replies.length - 1 && !hasReplies}
244
244
+
threadUri={threadUri}
245
245
+
toggleCollapsed={(e) => {
246
246
+
e.stopPropagation();
247
247
+
e.preventDefault();
248
248
+
if (parentUri) toggleCollapsed(parentUri);
249
249
+
console.log("collapse?");
250
250
+
}}
251
251
+
isCollapsed={isCollapsed}
252
252
+
depth={props.depth}
253
253
+
/>
291
254
);
292
255
})}
256
256
+
{parentUri && depth > 0 && replies.length > 3 && (
257
257
+
<ThreadLink
258
258
+
threadUri={parentUri}
259
259
+
parent={{ type: "thread", uri: threadUri }}
260
260
+
className="flex justify-start text-sm text-accent-contrast h-fit hover:underline"
261
261
+
>
262
262
+
<div className="mx-[19px] w-0.5 h-[24px] bg-border-light" />
263
263
+
View {replies.length - 3} more{" "}
264
264
+
{replies.length === 4 ? "reply" : "replies"}
265
265
+
</ThreadLink>
266
266
+
)}
293
267
</div>
294
268
);
295
269
}
296
270
297
297
-
function ReplyPost(props: {
271
271
+
const ReplyPost = (props: {
298
272
post: ThreadViewPost;
299
299
-
showReplyLine: boolean;
300
273
isLast: boolean;
301
274
threadUri: string;
302
302
-
}) {
275
275
+
toggleCollapsed: (e: React.MouseEvent) => void;
276
276
+
isCollapsed: boolean;
277
277
+
depth: number;
278
278
+
}) => {
303
279
const { post, threadUri } = props;
304
280
const postView = post.post;
305
281
const parent = { type: "thread" as const, uri: threadUri };
306
282
283
283
+
const hasReplies = props.post.replies && props.post.replies.length > 0;
284
284
+
285
285
+
// was in the middle of trying to get the right set of comments to close when this line is clicked
286
286
+
// then i really need to style the parent and grandparent threads, hide some of the content unless its the main post
287
287
+
// the thread line on them is also weird
307
288
return (
308
308
-
<div
309
309
-
className="flex gap-2 relative py-2 px-2 hover:bg-bg-page rounded cursor-pointer"
310
310
-
onClick={() => openPage(parent, { type: "thread", uri: postView.uri })}
311
311
-
>
312
312
-
<BskyPostContent
313
313
-
post={postView}
314
314
-
parent={parent}
315
315
-
linksEnabled={true}
316
316
-
avatarSize="sm"
317
317
-
showEmbed={false}
318
318
-
showBlueskyLink={false}
319
319
-
onLinkClick={(e) => e.stopPropagation()}
320
320
-
onEmbedClick={(e) => e.stopPropagation()}
321
321
-
/>
289
289
+
<div className="threadReply relative flex flex-col">
290
290
+
{props.depth > 0 && (
291
291
+
<button
292
292
+
onClick={(e) => {
293
293
+
props.toggleCollapsed(e);
294
294
+
}}
295
295
+
className="replyThreadLine absolute top-0 bottom-0 left-1 z-0 cursor-pointer shrink-0 "
296
296
+
aria-label={"Toggle replies"}
297
297
+
>
298
298
+
<div className="mx-[15px] w-0.5 h-full bg-border-light" />
299
299
+
</button>
300
300
+
)}
301
301
+
302
302
+
<button
303
303
+
className="replyThreadPost flex gap-2 text-left relative py-2 px-2 rounded cursor-pointer"
304
304
+
onClick={() => {
305
305
+
openPage(parent, { type: "thread", uri: postView.uri });
306
306
+
}}
307
307
+
>
308
308
+
<BskyPostContent
309
309
+
post={postView}
310
310
+
parent={parent}
311
311
+
showEmbed={false}
312
312
+
showBlueskyLink={false}
313
313
+
quoteCountOnClick={(e) => {
314
314
+
e.preventDefault();
315
315
+
e.stopPropagation();
316
316
+
openPage(parent, { type: "quotes", uri: postView.uri });
317
317
+
}}
318
318
+
replyCountOnClick={(e) => {
319
319
+
e.preventDefault();
320
320
+
e.stopPropagation();
321
321
+
props.toggleCollapsed();
322
322
+
}}
323
323
+
onEmbedClick={(e) => e.stopPropagation()}
324
324
+
className="text-sm z-10"
325
325
+
/>
326
326
+
</button>
327
327
+
{hasReplies && props.depth < 3 && (
328
328
+
<div className="ml-[28px] flex">
329
329
+
{!props.isCollapsed && (
330
330
+
<div className="grow">
331
331
+
<Replies
332
332
+
parentUri={postView.uri}
333
333
+
replies={props.post.replies as any[]}
334
334
+
threadUri={threadUri}
335
335
+
depth={props.depth + 1}
336
336
+
parentAuthorDid={props.post.post.author.did}
337
337
+
/>
338
338
+
</div>
339
339
+
)}
340
340
+
</div>
341
341
+
)}
342
342
+
343
343
+
{hasReplies && props.depth >= 3 && (
344
344
+
<ThreadLink
345
345
+
threadUri={props.post.post.uri}
346
346
+
parent={{ type: "thread", uri: threadUri }}
347
347
+
className="text-left ml-10 text-sm text-accent-contrast hover:underline"
348
348
+
>
349
349
+
View more replies
350
350
+
</ThreadLink>
351
351
+
)}
322
352
</div>
323
353
);
324
324
-
}
354
354
+
};
+25
-6
components/Avatar.tsx
···
4
4
src: string | undefined;
5
5
displayName: string | undefined;
6
6
className?: string;
7
7
-
tiny?: boolean;
8
8
-
large?: boolean;
9
9
-
giant?: boolean;
7
7
+
size?: "tiny" | "small" | "medium" | "large" | "giant";
10
8
}) => {
9
9
+
let sizeClassName =
10
10
+
props.size === "tiny"
11
11
+
? "w-4 h-4"
12
12
+
: props.size === "small"
13
13
+
? "w-5 h-5"
14
14
+
: props.size === "medium"
15
15
+
? "h-6 w-6"
16
16
+
: props.size === "large"
17
17
+
? "w-8 h-8"
18
18
+
: props.size === "giant"
19
19
+
? "h-16 w-16"
20
20
+
: "w-6 h-6";
21
21
+
11
22
if (props.src)
12
23
return (
13
24
<img
14
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}`}
25
25
+
className={`${sizeClassName} relative rounded-full shrink-0 border border-border-light ${props.className}`}
15
26
src={props.src}
16
27
alt={
17
28
props.displayName
···
23
34
else
24
35
return (
25
36
<div
26
26
-
className={`bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${props.tiny ? "w-4 h-4" : "w-5 h-5"}`}
37
37
+
className={` relative bg-[var(--accent-light)] flex rounded-full shrink-0 border border-border-light place-items-center justify-center text-accent-1 ${sizeClassName}`}
27
38
>
28
28
-
<AccountTiny className={props.tiny ? "scale-80" : "scale-90"} />
39
39
+
<AccountTiny
40
40
+
className={
41
41
+
props.size === "tiny"
42
42
+
? "scale-80"
43
43
+
: props.size === "small"
44
44
+
? "scale-90"
45
45
+
: ""
46
46
+
}
47
47
+
/>
29
48
</div>
30
49
);
31
50
};
+4
-3
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
···
14
14
export const BlueskyEmbed = (props: {
15
15
embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>;
16
16
postUrl?: string;
17
17
+
className?: string;
17
18
}) => {
18
19
// check this file from bluesky for ref
19
20
// https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx
···
81
82
<a
82
83
href={externalEmbed.external.uri}
83
84
target="_blank"
84
84
-
className="group flex flex-col border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border"
85
85
+
className={`blueskyPostEmbed group flex flex-col border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border ${props.className}`}
85
86
>
86
87
{externalEmbed.external.thumb === undefined ? null : (
87
88
<>
···
116
117
: 16 / 9;
117
118
return (
118
119
<div
119
119
-
className="rounded-md overflow-hidden relative w-full"
120
120
+
className={`rounded-md overflow-hidden relative w-full ${props.className}`}
120
121
style={{ aspectRatio: String(videoAspectRatio) }}
121
122
>
122
123
<img
···
207
208
case AppBskyEmbedRecordWithMedia.isView(props.embed) &&
208
209
AppBskyEmbedRecord.isViewRecord(props.embed.record.record):
209
210
return (
210
210
-
<div className={`flex flex-col gap-2`}>
211
211
+
<div className={`bskyEmbed flex flex-col gap-2`}>
211
212
<BlueskyEmbed embed={props.embed.media} />
212
213
<BlueskyEmbed
213
214
embed={{
+23
-1
src/utils/timeAgo.ts
···
1
1
-
export function timeAgo(timestamp: string): string {
1
1
+
export function timeAgo(
2
2
+
timestamp: string,
3
3
+
options?: { compact?: boolean },
4
4
+
): string {
5
5
+
const { compact } = options ?? {};
2
6
const now = new Date();
3
7
const date = new Date(timestamp);
4
8
const diffMs = now.getTime() - date.getTime();
···
9
13
const diffWeeks = Math.floor(diffDays / 7);
10
14
const diffMonths = Math.floor(diffDays / 30);
11
15
const diffYears = Math.floor(diffDays / 365);
16
16
+
17
17
+
if (compact) {
18
18
+
if (diffYears > 0) {
19
19
+
return `${diffYears}y`;
20
20
+
} else if (diffMonths > 0) {
21
21
+
return `${diffMonths}mo`;
22
22
+
} else if (diffWeeks > 0) {
23
23
+
return `${diffWeeks}w`;
24
24
+
} else if (diffDays > 0) {
25
25
+
return `${diffDays}d`;
26
26
+
} else if (diffHours > 0) {
27
27
+
return `${diffHours}h`;
28
28
+
} else if (diffMinutes > 0) {
29
29
+
return `${diffMinutes}m`;
30
30
+
} else {
31
31
+
return "now";
32
32
+
}
33
33
+
}
12
34
13
35
if (diffYears > 0) {
14
36
return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`;