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
styling the grandparents in the threadviewer
cozylittle.house
1 month ago
f95827cd
37729c71
+188
-69
3 changed files
expand all
collapse all
unified
split
app
lish
[did]
[publication]
[rkey]
BskyPostContent.tsx
ThreadPage.tsx
components
Blocks
BlueskyPostBlock
BlueskyEmbed.tsx
+103
-7
app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
···
20
20
export function BskyPostContent(props: {
21
21
post: PostView;
22
22
parent: OpenPage;
23
23
-
avatarSize?: "tiny" | "medium" | "large" | "giant";
23
23
+
avatarSize?: "tiny" | "small" | "medium" | "large" | "giant";
24
24
className?: string;
25
25
showEmbed?: boolean;
26
26
+
compactEmbed?: boolean;
26
27
showBlueskyLink?: boolean;
27
28
onEmbedClick?: (e: React.MouseEvent) => void;
28
29
quoteEnabled?: boolean;
···
35
36
const {
36
37
post,
37
38
parent,
38
38
-
avatarSize = "md",
39
39
+
avatarSize = "medium",
39
40
showEmbed = true,
41
41
+
compactEmbed = false,
40
42
showBlueskyLink = true,
41
43
onEmbedClick,
42
44
quoteEnabled,
···
67
69
<Avatar
68
70
src={post.author.avatar}
69
71
displayName={post.author.displayName}
70
70
-
size={props.avatarSize ? props.avatarSize : "medium"}
72
72
+
size={avatarSize ? avatarSize : "medium"}
71
73
/>
72
74
{replyLine && (
73
75
<button
···
82
84
)}
83
85
</div>
84
86
<div
85
85
-
className={`flex flex-col w-full z-0 ${props.replyLine ? "mt-2" : ""}`}
87
87
+
className={`flex flex-col min-w-0 w-full z-0 ${props.replyLine ? "mt-2" : ""}`}
86
88
>
87
89
<button
88
88
-
className={`bskyPostTextContent flex flex-col grow min-w-0 mt-1 text-left`}
90
90
+
className="bskyPostTextContent flex flex-col grow mt-1 text-left"
89
91
onClick={() => {
90
92
openPage(parent, { type: "thread", uri: post.uri });
91
93
}}
···
93
95
<div
94
96
className={`postInfo flex justify-between items-center gap-2 leading-tight `}
95
97
>
96
96
-
<div className="flex gap-2 items-center">
98
98
+
<div className={`flex gap-2 items-center `}>
97
99
<div className="font-bold text-secondary">
98
100
{post.author.displayName}
99
101
</div>
···
112
114
</div>
113
115
114
116
<div className={`postContent flex flex-col gap-2 mt-0.5`}>
115
115
-
<div className="text-sm text-secondary">
117
117
+
<div className="text-secondary text-sm">
116
118
<BlueskyRichText record={record} />
117
119
</div>
118
120
{showEmbed && post.embed && (
119
121
<div onClick={onEmbedClick}>
120
122
<BlueskyEmbed
121
123
embed={post.embed}
124
124
+
compact={compactEmbed}
122
125
postUrl={url}
123
126
className="text-sm"
124
127
/>
···
151
154
</>
152
155
)}
153
156
</div>
157
157
+
</div>
158
158
+
) : null}
159
159
+
</div>
160
160
+
</div>
161
161
+
</div>
162
162
+
);
163
163
+
}
164
164
+
165
165
+
export function CompactBskyPostContent(props: {
166
166
+
post: PostView;
167
167
+
parent: OpenPage;
168
168
+
className?: string;
169
169
+
quoteEnabled?: boolean;
170
170
+
replyEnabled?: boolean;
171
171
+
replyOnClick?: (e: React.MouseEvent) => void;
172
172
+
replyLine?: {
173
173
+
onToggle: (e: React.MouseEvent) => void;
174
174
+
};
175
175
+
}) {
176
176
+
const { post, parent, quoteEnabled, replyEnabled, replyOnClick, replyLine } =
177
177
+
props;
178
178
+
179
179
+
const record = post.record as AppBskyFeedPost.Record;
180
180
+
const postId = post.uri.split("/")[4];
181
181
+
const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`;
182
182
+
183
183
+
return (
184
184
+
<div className="bskyPost relative flex flex-col w-full">
185
185
+
<div className={`flex gap-2 text-left w-full ${props.className}`}>
186
186
+
<div className="flex flex-col items-start shrink-0 w-fit">
187
187
+
<Avatar
188
188
+
src={post.author.avatar}
189
189
+
displayName={post.author.displayName}
190
190
+
size="small"
191
191
+
/>
192
192
+
{replyLine && (
193
193
+
<button
194
194
+
onClick={(e) => {
195
195
+
replyLine.onToggle(e);
196
196
+
}}
197
197
+
className="relative w-full grow flex"
198
198
+
aria-label="Toggle replies"
199
199
+
>
200
200
+
<div className="w-0.5 h-full bg-border-light mx-auto" />
201
201
+
</button>
202
202
+
)}
203
203
+
</div>
204
204
+
<div
205
205
+
className={`flex flex-col min-w-0 w-full z-0 ${replyLine ? "mb-2" : ""}`}
206
206
+
>
207
207
+
<button
208
208
+
className="bskyPostTextContent flex flex-col grow mt-0.5 text-left text-xs text-tertiary"
209
209
+
onClick={() => {
210
210
+
openPage(parent, { type: "thread", uri: post.uri });
211
211
+
}}
212
212
+
>
213
213
+
<div className="postInfo flex justify-between items-center gap-2 leading-tight">
214
214
+
<div className="flex gap-2 items-center">
215
215
+
<div className="font-bold text-secondary">
216
216
+
{post.author.displayName}
217
217
+
</div>
218
218
+
<ProfilePopover
219
219
+
trigger={
220
220
+
<div className="text-xs text-tertiary hover:underline">
221
221
+
@{post.author.handle}
222
222
+
</div>
223
223
+
}
224
224
+
didOrHandle={post.author.handle}
225
225
+
/>
226
226
+
</div>
227
227
+
<div className="text-xs text-tertiary">
228
228
+
{timeAgo(record.createdAt, { compact: true })}
229
229
+
</div>
230
230
+
</div>
231
231
+
232
232
+
<div className="postContent flex flex-col gap-2 mt-0.5">
233
233
+
<div className="line-clamp-3 text-tertiary text-xs">
234
234
+
<BlueskyRichText record={record} />
235
235
+
</div>
236
236
+
</div>
237
237
+
</button>
238
238
+
{(post.quoteCount && post.quoteCount > 0) ||
239
239
+
(post.replyCount && post.replyCount > 0) ? (
240
240
+
<div className="postCountsAndLink flex gap-2 items-center justify-between mt-2">
241
241
+
<PostCounts
242
242
+
post={post}
243
243
+
parent={parent}
244
244
+
replyEnabled={replyEnabled}
245
245
+
replyOnClick={replyOnClick}
246
246
+
quoteEnabled={quoteEnabled}
247
247
+
showBlueskyLink={false}
248
248
+
url={url}
249
249
+
/>
154
250
</div>
155
251
) : null}
156
252
</div>
+66
-49
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
···
6
6
import { useDrawerOpen } from "./Interactions/InteractionDrawer";
7
7
import { DotLoader } from "components/utils/DotLoader";
8
8
import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed";
9
9
-
import { openPage } from "./PostPages";
10
9
import { useThreadState } from "src/useThreadState";
11
11
-
import { BskyPostContent, ClientDate } from "./BskyPostContent";
10
10
+
import {
11
11
+
BskyPostContent,
12
12
+
CompactBskyPostContent,
13
13
+
ClientDate,
14
14
+
} from "./BskyPostContent";
12
15
import {
13
16
ThreadLink,
14
17
getThreadKey,
···
30
33
pageOptions?: React.ReactNode;
31
34
hasPageBackground: boolean;
32
35
}) {
33
33
-
const { parentUri: parentUri, pageId, pageOptions } = props;
36
36
+
const { parentUri, pageId, pageOptions } = props;
34
37
const drawer = useDrawerOpen(parentUri);
35
38
36
39
const {
···
49
52
drawerOpen={!!drawer}
50
53
pageOptions={pageOptions}
51
54
>
52
52
-
<div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4">
55
55
+
<div className="flex flex-col sm:px-4 px-3 sm:pt-3 pt-2 pb-1 sm:pb-4 w-full">
53
56
{isLoading ? (
54
57
<div className="flex items-center justify-center gap-1 text-tertiary italic text-sm py-8">
55
58
<span>loading thread</span>
···
106
109
}
107
110
108
111
return (
109
109
-
<div className="threadContent flex flex-col gap-0">
112
112
+
<div
113
113
+
className={`threadContent flex flex-col gap-0 w-full ${parents.length !== 0 && "pt-1"}`}
114
114
+
>
110
115
{/* grandparent posts, if any */}
111
111
-
{parents.map((parent, index) => (
112
112
-
<div key={parent.post.uri} className="flex flex-col">
113
113
-
<ThreadPost
114
114
-
post={parent}
115
115
-
isMainPost={false}
116
116
-
showReplyLine={index < parents.length - 1 || true}
117
117
-
parentUri={parentUri}
118
118
-
/>
119
119
-
</div>
116
116
+
{parents.map((parentPost, index) => (
117
117
+
<ThreadPost
118
118
+
key={parentPost.post.uri}
119
119
+
post={parentPost}
120
120
+
isMainPost={false}
121
121
+
pageUri={parentUri}
122
122
+
/>
120
123
))}
121
124
122
125
{/* Main post */}
123
126
<div ref={mainPostRef}>
124
124
-
<ThreadPost
125
125
-
post={post}
126
126
-
isMainPost={true}
127
127
-
showReplyLine={false}
128
128
-
parentUri={parentUri}
129
129
-
/>
127
127
+
<ThreadPost post={post} isMainPost={true} pageUri={parentUri} />
130
128
</div>
131
129
132
130
{/* Replies */}
133
131
{post.replies && post.replies.length > 0 && (
134
134
-
<div className="threadReplies flex flex-col mt-2 pt-2 border-t border-border-light">
132
132
+
<div className="threadReplies flex flex-col mt-4 pt-3 border-t border-border-light w-full">
135
133
<Replies
136
134
replies={post.replies as any[]}
137
137
-
parentUri={post.post.uri}
135
135
+
pageUri={post.post.uri}
136
136
+
parentPostUri={post.post.uri}
138
137
depth={0}
139
138
parentAuthorDid={post.post.author.did}
140
139
/>
···
147
146
function ThreadPost(props: {
148
147
post: ThreadViewPost;
149
148
isMainPost: boolean;
150
150
-
showReplyLine: boolean;
151
151
-
parentUri: string;
149
149
+
pageUri: string;
152
150
}) {
153
153
-
const { post, isMainPost, showReplyLine, parentUri } = props;
151
151
+
const { post, isMainPost, pageUri } = props;
154
152
const postView = post.post;
155
155
-
const parent = { type: "thread" as const, uri: parentUri };
153
153
+
const page = { type: "thread" as const, uri: pageUri };
154
154
+
155
155
+
if (isMainPost) {
156
156
+
return (
157
157
+
<div className="threadPost flex gap-2 relative w-full">
158
158
+
<BskyPostContent
159
159
+
post={postView}
160
160
+
parent={page}
161
161
+
avatarSize="large"
162
162
+
showBlueskyLink={true}
163
163
+
showEmbed={true}
164
164
+
compactEmbed
165
165
+
quoteEnabled
166
166
+
/>
167
167
+
</div>
168
168
+
);
169
169
+
}
156
170
157
171
return (
158
158
-
<div className="threadPost flex gap-2 relative">
159
159
-
{/* Reply line connector */}
160
160
-
{showReplyLine && (
161
161
-
<div className="absolute left-[19px] top-10 bottom-0 w-0.5 bg-border-light" />
162
162
-
)}
163
163
-
<BskyPostContent
172
172
+
<div className="threadPost flex gap-2 relative w-full pl-1">
173
173
+
<CompactBskyPostContent
164
174
post={postView}
165
165
-
parent={parent}
166
166
-
showBlueskyLink={true}
167
167
-
showEmbed={true}
175
175
+
parent={page}
168
176
quoteEnabled
169
169
-
replyEnabled={!isMainPost}
177
177
+
replyEnabled
178
178
+
replyLine={{
179
179
+
onToggle: () => {},
180
180
+
}}
170
181
/>
171
182
</div>
172
183
);
···
176
187
replies: (ThreadViewPost | NotFoundPost | BlockedPost)[];
177
188
depth: number;
178
189
parentAuthorDid?: string;
179
179
-
parentUri: string;
190
190
+
pageUri: string;
191
191
+
parentPostUri: string;
180
192
}) {
181
181
-
const { replies, depth, parentAuthorDid, parentUri } = props;
193
193
+
const { replies, depth, parentAuthorDid, pageUri, parentPostUri } = props;
182
194
const collapsedThreads = useThreadState((s) => s.collapsedThreads);
183
195
const toggleCollapsed = useThreadState((s) => s.toggleCollapsed);
184
196
···
198
210
: replies;
199
211
200
212
return (
201
201
-
<div className="threadPageReplies flex flex-col gap-0">
213
213
+
<div className="threadPageReplies flex flex-col gap-0 pt-1 pb-2">
202
214
{sortedReplies.map((reply, index) => {
203
215
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
204
216
return (
···
231
243
232
244
return (
233
245
<ReplyPost
246
246
+
key={reply.post.uri}
234
247
post={reply}
235
248
isLast={index === replies.length - 1 && !hasReplies}
236
236
-
parentUri={parentUri}
249
249
+
pageUri={pageUri}
250
250
+
parentPostUri={parentPostUri}
237
251
toggleCollapsed={(uri) => toggleCollapsed(uri)}
238
252
isCollapsed={isCollapsed}
239
253
depth={props.depth}
240
254
/>
241
255
);
242
256
})}
243
243
-
{parentUri && depth > 0 && replies.length > 3 && (
257
257
+
{pageUri && depth > 0 && replies.length > 3 && (
244
258
<ThreadLink
245
245
-
postUri={parentUri}
246
246
-
parent={{ type: "thread", uri: parentUri }}
259
259
+
postUri={pageUri}
260
260
+
parent={{ type: "thread", uri: pageUri }}
247
261
className="flex justify-start text-sm text-accent-contrast h-fit hover:underline"
248
262
>
249
263
<div className="mx-[19px] w-0.5 h-[24px] bg-border-light" />
···
258
272
const ReplyPost = (props: {
259
273
post: ThreadViewPost;
260
274
isLast: boolean;
261
261
-
parentUri: string;
275
275
+
pageUri: string;
276
276
+
parentPostUri: string;
262
277
toggleCollapsed: (uri: string) => void;
263
278
isCollapsed: boolean;
264
279
depth: number;
265
280
}) => {
266
266
-
const { post, parentUri } = props;
281
281
+
const { post, pageUri, parentPostUri } = props;
267
282
const postView = post.post;
268
283
269
284
const hasReplies = props.post.replies && props.post.replies.length > 0;
···
274
289
>
275
290
<BskyPostContent
276
291
post={postView}
277
277
-
parent={{ type: "thread", uri: parentUri }}
292
292
+
parent={{ type: "thread", uri: pageUri }}
278
293
showEmbed={false}
279
294
showBlueskyLink={false}
280
295
replyLine={
281
296
props.depth > 0
282
297
? {
283
298
onToggle: () => {
284
284
-
props.toggleCollapsed(props.parentUri);
299
299
+
props.toggleCollapsed(parentPostUri);
285
300
},
286
301
}
287
302
: undefined
···
291
306
replyOnClick={(e) => {
292
307
e.preventDefault();
293
308
props.toggleCollapsed(post.post.uri);
309
309
+
console.log(post.post.uri);
294
310
}}
295
311
onEmbedClick={(e) => e.stopPropagation()}
296
312
className="text-sm z-10"
···
300
316
{!props.isCollapsed && (
301
317
<div className="grow">
302
318
<Replies
303
303
-
parentUri={parentUri}
319
319
+
pageUri={pageUri}
320
320
+
parentPostUri={post.post.uri}
304
321
replies={props.post.replies as any[]}
305
322
depth={props.depth + 1}
306
323
parentAuthorDid={props.post.post.author.did}
···
313
330
{hasReplies && props.depth >= 3 && (
314
331
<ThreadLink
315
332
postUri={props.post.post.uri}
316
316
-
parent={{ type: "thread", uri: parentUri }}
333
333
+
parent={{ type: "thread", uri: pageUri }}
317
334
className="text-left ml-10 text-sm text-accent-contrast hover:underline"
318
335
>
319
336
View more replies
+19
-13
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
···
15
15
embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>;
16
16
postUrl?: string;
17
17
className?: string;
18
18
+
compact?: boolean;
18
19
}) => {
19
20
// check this file from bluesky for ref
20
21
// https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx
···
22
23
case AppBskyEmbedImages.isView(props.embed):
23
24
let imageEmbed = props.embed;
24
25
return (
25
25
-
<div className="flex flex-wrap rounded-md w-full overflow-hidden">
26
26
+
<div className="imageEmbed flex flex-wrap rounded-md w-full overflow-hidden">
26
27
{imageEmbed.images.map(
27
28
(
28
29
image: {
···
69
70
let isGif = externalEmbed.external.uri.includes(".gif");
70
71
if (isGif) {
71
72
return (
72
72
-
<div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video">
73
73
+
<div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video w-full ">
73
74
<img
74
75
src={externalEmbed.external.uri}
75
76
alt={externalEmbed.external.title}
···
82
83
<a
83
84
href={externalEmbed.external.uri}
84
85
target="_blank"
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}`}
86
86
+
className={`externalLinkEmbed group border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border w-full ${props.compact ? "flex" : "flex flex-col"}
87
87
+
${props.className}`}
86
88
>
87
89
{externalEmbed.external.thumb === undefined ? null : (
88
90
<>
89
89
-
<div className="w-full aspect-[1.91/1] overflow-hidden">
91
91
+
<div
92
92
+
className={` overflow-hidden shrink-0 ${props.compact ? "aspect-square h-[113px] hidden sm:block" : "aspect-[1.91/1] w-full "}`}
93
93
+
>
90
94
<img
91
95
src={externalEmbed.external.thumb}
92
96
alt={externalEmbed.external.title}
93
93
-
className="w-full h-full object-cover"
97
97
+
className={`object-cover ${props.compact ? "h-full" : "w-full h-full"}`}
94
98
/>
95
99
</div>
96
96
-
<hr className="border-border-light" />
100
100
+
{!props.compact && <hr className="border-border-light" />}
97
101
</>
98
102
)}
99
99
-
<div className="p-2 flex flex-col gap-1">
100
100
-
<div className="flex flex-col">
101
101
-
<h4>{externalEmbed.external.title}</h4>
102
102
-
<p className="text-secondary">
103
103
+
<div
104
104
+
className={`p-2 flex flex-col gap-1 w-full min-w-0 ${props.compact && "sm:pl-3 py-1"}`}
105
105
+
>
106
106
+
<div className="flex flex-col shrink-0">
107
107
+
<h4 className="truncate">{externalEmbed.external.title} </h4>
108
108
+
<p className="text-secondary line-clamp-2 grow">
103
109
{externalEmbed.external.description}
104
110
</p>
105
111
</div>
106
112
<hr className="border-border-light mt-1" />
107
107
-
<div className="text-tertiary text-xs sm:group-hover:text-accent-contrast">
113
113
+
<div className="text-tertiary text-xs shrink-0 sm:group-hover:text-accent-contrast truncate">
108
114
{externalEmbed.external.uri}
109
115
</div>
110
116
</div>
···
117
123
: 16 / 9;
118
124
return (
119
125
<div
120
120
-
className={`rounded-md overflow-hidden relative w-full ${props.className}`}
126
126
+
className={`videoEmbed rounded-md overflow-hidden relative w-full ${props.className}`}
121
127
style={{ aspectRatio: String(videoAspectRatio) }}
122
128
>
123
129
<img
···
149
155
}
150
156
return (
151
157
<div
152
152
-
className={`flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`}
158
158
+
className={`bskyPostEmbed flex flex-col gap-0.5 relative w-full overflow-hidden p-2! text-xs block-border`}
153
159
>
154
160
<div className="bskyAuthor w-full flex items-center ">
155
161
{record.author.avatar && (