tangled
alpha
login
or
join now
vt3e.cat
/
bbell
12
fork
atom
wip bsky client for the web & android
bbell.vt3e.cat
12
fork
atom
overview
issues
pulls
pipelines
feat: uhh adjust how embeds are displayed and other things idk
vt3e.cat
1 week ago
f2af6a5f
c0c057cb
verified
This commit was signed with the committer's
known signature
.
vt3e.cat
SSH Key Fingerprint:
SHA256:MaVgF6bXxDdD131G4rXizPh+sttp3IVsdPrj48HV0X0=
+494
-274
5 changed files
expand all
collapse all
unified
split
src
assets
main.css
components
Feed
Embeds
ImageEmbed.vue
FeedItem.vue
stores
posts.ts
utils
identity.ts
+18
src/assets/main.css
···
8
8
font-display: swap;
9
9
}
10
10
11
11
+
::-webkit-scrollbar {
12
12
+
width: 0.5rem;
13
13
+
height: 6px;
14
14
+
background: transparent;
15
15
+
}
16
16
+
17
17
+
::-webkit-scrollbar-thumb {
18
18
+
background: hsla(var(--overlay1) / 1);
19
19
+
border-radius: 3px;
20
20
+
}
21
21
+
22
22
+
::-webkit-scrollbar-track {
23
23
+
background: transparent;
24
24
+
}
25
25
+
11
26
*,
12
27
*::before,
13
28
*::after {
···
15
30
margin: 0;
16
31
padding: 0;
17
32
font-weight: normal;
33
33
+
34
34
+
scrollbar-width: thin;
35
35
+
scrollbar-color: hsla(var(--overlay1) / 1) transparent;
18
36
19
37
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
20
38
-webkit-tap-highlight-color: transparent;
+86
-78
src/components/Feed/Embeds/ImageEmbed.vue
···
8
8
9
9
const imageCount = computed(() => props.embed.images.length)
10
10
11
11
-
// TODO)) lightbox.
12
11
const lightboxOpen = ref(false)
13
12
const activeImageIndex = ref(0)
14
13
···
16
15
activeImageIndex.value = index
17
16
lightboxOpen.value = true
18
17
}
18
18
+
19
19
+
const getImageStyle = (image: AppBskyEmbedImages.ViewImage) => {
20
20
+
if (image.aspectRatio) {
21
21
+
return {
22
22
+
aspectRatio: `${image.aspectRatio.width} / ${image.aspectRatio.height}`,
23
23
+
}
24
24
+
}
25
25
+
return {}
26
26
+
}
19
27
</script>
20
28
21
29
<template>
22
22
-
<div class="image-embed" :class="`count-${imageCount}`">
23
23
-
<div
24
24
-
v-for="(image, index) in embed.images"
25
25
-
:key="index"
26
26
-
class="image-container"
27
27
-
@click.stop="openLightbox(index)"
28
28
-
>
29
29
-
<img :src="image.thumb" :alt="image.alt" loading="lazy" />
30
30
+
<div class="image-embed__wrapper" :class="{ single: imageCount === 1, multi: imageCount > 1 }">
31
31
+
<div ref="scrollRow" class="image-embed__row">
32
32
+
<div
33
33
+
v-for="(image, index) in embed.images"
34
34
+
:key="index"
35
35
+
class="image-container"
36
36
+
@click.stop="openLightbox(index)"
37
37
+
>
38
38
+
<img
39
39
+
:src="image.thumb"
40
40
+
:alt="image.alt"
41
41
+
loading="lazy"
42
42
+
:draggable="false"
43
43
+
:style="getImageStyle(image)"
44
44
+
/>
45
45
+
</div>
30
46
</div>
31
47
</div>
32
48
</template>
33
49
34
50
<style lang="scss" scoped>
35
35
-
.image-embed {
36
36
-
display: grid;
37
37
-
gap: 2px;
38
38
-
overflow: hidden;
39
39
-
margin-top: 0.5rem;
51
51
+
.image-embed__row {
52
52
+
max-width: 100%;
53
53
+
display: flex;
54
54
+
gap: 0.5rem;
40
55
width: 100%;
41
41
-
aspect-ratio: 16 / 9;
56
56
+
transition: none !important;
42
57
43
43
-
.image-container {
44
44
-
position: relative;
45
45
-
width: 100%;
46
46
-
height: 100%;
47
47
-
overflow: hidden;
48
48
-
cursor: zoom-in;
49
49
-
border: 1px solid hsla(var(--surface2) / 0.3);
58
58
+
scroll-snap-type: x mandatory;
59
59
+
scroll-padding: var(--gutter-width);
50
60
51
51
-
img {
52
52
-
width: 100%;
53
53
-
height: 100%;
54
54
-
object-fit: contain;
55
55
-
object-fit: cover;
56
56
-
display: block;
57
57
-
}
61
61
+
&::before,
62
62
+
&::after {
63
63
+
content: '';
64
64
+
flex-shrink: 0;
65
65
+
min-width: calc(-0.5rem + var(--gutter-width));
66
66
+
}
67
67
+
&::after {
68
68
+
min-width: calc(-0.5rem + var(--gutter-width));
58
69
}
59
70
}
60
71
61
61
-
.count-1 {
62
62
-
display: block;
63
63
-
aspect-ratio: auto;
64
64
-
max-height: 600px;
72
72
+
.image-container {
73
73
+
overflow: hidden;
74
74
+
cursor: zoom-in;
75
75
+
border-radius: 0.5rem;
76
76
+
background: hsl(var(--surface1));
77
77
+
display: flex;
78
78
+
align-items: center;
79
79
+
justify-content: center;
65
80
81
81
+
img {
82
82
+
display: block;
83
83
+
}
84
84
+
}
85
85
+
86
86
+
.image-embed__wrapper.single {
66
87
.image-container {
67
67
-
height: auto;
68
68
-
max-height: 600px;
69
69
-
border-radius: var(--radius-md);
88
88
+
width: fit-content;
89
89
+
max-width: 100%;
90
90
+
max-height: 60vh;
70
91
71
92
img {
93
93
+
object-fit: contain;
94
94
+
width: auto;
72
95
height: auto;
73
73
-
max-height: 600px;
74
74
-
object-fit: cover;
96
96
+
max-width: 100%;
97
97
+
max-height: 60vh;
75
98
}
76
99
}
77
100
}
78
101
79
79
-
.count-2 {
80
80
-
grid-template-columns: 1fr 1fr;
81
81
-
aspect-ratio: 2 / 1;
82
82
-
83
83
-
.image-container:nth-child(1) {
84
84
-
border-radius: var(--radius-md) var(--radius-xsm) var(--radius-xsm) var(--radius-md);
85
85
-
}
86
86
-
.image-container:nth-child(2) {
87
87
-
border-radius: var(--radius-xsm) var(--radius-md) var(--radius-md) var(--radius-xsm);
88
88
-
}
89
89
-
}
102
102
+
.image-embed__wrapper.multi {
103
103
+
.image-embed__row {
104
104
+
overflow-x: auto;
105
105
+
-webkit-overflow-scrolling: touch;
106
106
+
scrollbar-width: none;
107
107
+
cursor: grab;
90
108
91
91
-
.count-3 {
92
92
-
grid-template-columns: 1fr 1fr;
93
93
-
grid-template-rows: 1fr 1fr;
94
94
-
aspect-ratio: 4 / 3;
109
109
+
&:active {
110
110
+
cursor: grabbing;
111
111
+
}
95
112
96
96
-
.image-container:nth-child(1) {
97
97
-
grid-row: 1 / -1;
98
98
-
border-radius: var(--radius-md) var(--radius-xsm) var(--radius-xsm) var(--radius-md);
113
113
+
&::-webkit-scrollbar {
114
114
+
display: none;
115
115
+
}
99
116
}
100
100
-
.image-container:nth-child(2) {
101
101
-
border-radius: var(--radius-xsm) var(--radius-md) var(--radius-xsm) var(--radius-xsm);
102
102
-
}
103
103
-
.image-container:nth-child(3) {
104
104
-
border-radius: var(--radius-xsm) var(--radius-xsm) var(--radius-md) var(--radius-xsm);
105
105
-
}
106
106
-
}
107
117
108
108
-
.count-4 {
109
109
-
grid-template-columns: 1fr 1fr;
110
110
-
grid-template-rows: 1fr 1fr;
111
111
-
aspect-ratio: 4 / 3;
118
118
+
.image-container {
119
119
+
flex: 0 0 auto;
120
120
+
scroll-snap-align: start;
121
121
+
height: 16rem;
122
122
+
max-width: 85vw;
123
123
+
min-width: 4rem;
112
124
113
113
-
.image-container:nth-child(1) {
114
114
-
border-radius: var(--radius-md) var(--radius-xsm) var(--radius-xsm) var(--radius-xsm);
115
115
-
}
116
116
-
.image-container:nth-child(2) {
117
117
-
border-radius: var(--radius-xsm) var(--radius-md) var(--radius-xsm) var(--radius-xsm);
118
118
-
}
119
119
-
.image-container:nth-child(3) {
120
120
-
border-radius: var(--radius-xsm) var(--radius-xsm) var(--radius-xsm) var(--radius-md);
121
121
-
}
122
122
-
.image-container:nth-child(4) {
123
123
-
border-radius: var(--radius-xsm) var(--radius-xsm) var(--radius-md) var(--radius-xsm);
125
125
+
img {
126
126
+
object-fit: contain;
127
127
+
width: auto;
128
128
+
height: 100%;
129
129
+
max-width: 100%;
130
130
+
pointer-events: none;
131
131
+
}
124
132
}
125
133
}
126
134
</style>
+321
-193
src/components/Feed/FeedItem.vue
···
8
8
IconFavoriteOutlineRounded,
9
9
IconFavoriteRounded,
10
10
IconMoreVert,
11
11
-
IconSendRounded,
12
11
IconBookmarkRounded,
13
12
IconBookmarkAddedRounded,
14
13
IconFormatQuoteRounded,
14
14
+
IconContentCopyRounded,
15
15
+
IconLinkRounded,
16
16
+
IconOpenInNewRounded,
17
17
+
IconForwardRounded,
15
18
} from '@iconify-prerendered/vue-material-symbols'
16
19
17
20
import { useNavigationStore } from '@/stores/navigation'
···
108
111
}
109
112
}
110
113
114
114
+
const handleShare = () => {
115
115
+
if (displayPost.value) {
116
116
+
const url = `${window.location.origin}/profile/${displayPost.value.author.handle}/post/${rkey.value}`
117
117
+
if (navigator.share) {
118
118
+
navigator
119
119
+
.share({
120
120
+
title: 'Check out this post on Bluesky',
121
121
+
url,
122
122
+
})
123
123
+
.then(() => tap())
124
124
+
.catch((error) => console.error('Error sharing', error))
125
125
+
return
126
126
+
}
127
127
+
tap()
128
128
+
}
129
129
+
}
130
130
+
131
131
+
const share = {
132
132
+
systemShare: () => {
133
133
+
if (displayPost.value) {
134
134
+
const url = `${window.location.origin}/profile/${displayPost.value.author.handle}/post/${rkey.value}`
135
135
+
if (navigator.share) {
136
136
+
navigator
137
137
+
.share({
138
138
+
title: 'Check out this post on Bluesky',
139
139
+
url,
140
140
+
})
141
141
+
.then(() => tap())
142
142
+
.catch((error) => console.error('Error sharing', error))
143
143
+
}
144
144
+
}
145
145
+
},
146
146
+
copyLink: () => {
147
147
+
if (displayPost.value) {
148
148
+
const url = `${window.location.origin}/profile/${displayPost.value.author.handle}/post/${rkey.value}`
149
149
+
navigator.clipboard.writeText(url)
150
150
+
tap()
151
151
+
}
152
152
+
},
153
153
+
copyBlueskyLink: () => {
154
154
+
if (displayPost.value) {
155
155
+
const bskyUrl = `https://bsky.app/profile/${displayPost.value.author.did}/post/${rkey.value}`
156
156
+
navigator.clipboard.writeText(bskyUrl)
157
157
+
tap()
158
158
+
}
159
159
+
},
160
160
+
copyATUri: () => {
161
161
+
if (displayPost.value) {
162
162
+
navigator.clipboard.writeText(displayPost.value.uri)
163
163
+
tap()
164
164
+
}
165
165
+
},
166
166
+
copyDid: () => {
167
167
+
if (displayPost.value) {
168
168
+
navigator.clipboard.writeText(displayPost.value.author.did)
169
169
+
tap()
170
170
+
}
171
171
+
},
172
172
+
openInPDSls: () => {
173
173
+
if (displayPost.value) {
174
174
+
const url = `https://pds.ls/${displayPost.value.uri}`
175
175
+
window.open(url, '_blank')
176
176
+
}
177
177
+
},
178
178
+
}
179
179
+
111
180
const handleClick = (e: MouseEvent) => {
112
181
if (window.getSelection()?.toString().length) return
113
182
if (props.rootPost) return
···
207
276
</div>
208
277
209
278
<div class="post-layout">
210
210
-
<AppLink name="user-profile" :params="{ id: displayPost.author.did }" class="post-avatar">
211
211
-
<img
212
212
-
v-if="displayPost.author.avatar"
213
213
-
:src="displayPost.author.avatar"
214
214
-
alt="avatar"
215
215
-
loading="lazy"
216
216
-
/>
217
217
-
<div v-else class="avatar-fallback"></div>
218
218
-
</AppLink>
279
279
+
<div class="post-top">
280
280
+
<AppLink name="user-profile" :params="{ id: displayPost.author.did }" class="post-avatar">
281
281
+
<img
282
282
+
v-if="displayPost.author.avatar"
283
283
+
:src="displayPost.author.avatar"
284
284
+
alt="avatar"
285
285
+
loading="lazy"
286
286
+
/>
287
287
+
<div v-else class="avatar-fallback"></div>
288
288
+
</AppLink>
219
289
220
220
-
<div class="post-content">
221
221
-
<div class="post-header">
222
222
-
<div class="post-header__part">
223
223
-
<AppLink
224
224
-
class="display-name"
225
225
-
name="user-profile"
226
226
-
:params="{ id: displayPost.author.did }"
227
227
-
>
228
228
-
{{ displayPost.author.displayName || displayPost.author.handle }}
229
229
-
</AppLink>
290
290
+
<div class="not-gutter">
291
291
+
<div class="post-header">
292
292
+
<div class="post-header__part">
293
293
+
<AppLink
294
294
+
class="display-name"
295
295
+
name="user-profile"
296
296
+
:params="{ id: displayPost.author.did }"
297
297
+
>
298
298
+
{{ displayPost.author.displayName || displayPost.author.handle }}
299
299
+
</AppLink>
230
300
231
231
-
<p v-if="displayPost.author.pronouns" class="pronouns">
232
232
-
{{ displayPost.author.pronouns }}
233
233
-
</p>
301
301
+
<p v-if="displayPost.author.pronouns" class="pronouns">
302
302
+
{{ displayPost.author.pronouns }}
303
303
+
</p>
304
304
+
</div>
305
305
+
<div class="post-header__part">
306
306
+
<p class="time" :title="new Date(displayPost.indexedAt).toLocaleString()">
307
307
+
{{ formatTime(displayPost.indexedAt) }}
308
308
+
</p>
309
309
+
</div>
234
310
</div>
235
235
-
<div class="post-header__part">
236
236
-
<p class="time" :title="new Date(displayPost.indexedAt).toLocaleString()">
237
237
-
{{ formatTime(displayPost.indexedAt) }}
238
238
-
</p>
311
311
+
312
312
+
<div class="post-content">
313
313
+
<div class="post-text" v-if="displayPost.record?.text">
314
314
+
{{ displayPost.record.text }}
315
315
+
</div>
239
316
</div>
240
317
</div>
241
241
-
242
242
-
<div class="post-text" v-if="displayPost.record?.text">
243
243
-
{{ displayPost.record.text }}
244
244
-
</div>
318
318
+
</div>
245
319
246
246
-
<div class="post-embeds" v-if="embed">
247
247
-
<ImageEmbed v-if="embed.$type === 'app.bsky.embed.images#view'" :embed="embed" />
248
248
-
<ExternalEmbed
249
249
-
v-else-if="embed.$type === 'app.bsky.embed.external#view'"
250
250
-
:embed="embed.external"
320
320
+
<div class="post-embeds" v-if="embed">
321
321
+
<ImageEmbed v-if="embed.$type === 'app.bsky.embed.images#view'" :embed="embed" />
322
322
+
<ExternalEmbed
323
323
+
v-else-if="embed.$type === 'app.bsky.embed.external#view'"
324
324
+
:embed="embed.external"
325
325
+
/>
326
326
+
<VideoEmbed v-else-if="embed.$type === 'app.bsky.embed.video#view'" :embed="embed" />
327
327
+
<template v-else-if="embed.$type === 'app.bsky.embed.record#view'">
328
328
+
<EmbedRecord v-if="!embedded" :embed="embed" />
329
329
+
<div v-else class="embedded-record">Post has nested quote.</div>
330
330
+
</template>
331
331
+
<template v-else-if="embed.$type === 'app.bsky.embed.recordWithMedia#view'">
332
332
+
<ImageEmbed
333
333
+
v-if="embed.media.$type === 'app.bsky.embed.images#view'"
334
334
+
:embed="embed.media"
251
335
/>
252
252
-
<VideoEmbed v-else-if="embed.$type === 'app.bsky.embed.video#view'" :embed="embed" />
253
253
-
<template v-else-if="embed.$type === 'app.bsky.embed.record#view'">
254
254
-
<EmbedRecord v-if="!embedded" :embed="embed" />
255
255
-
<div v-else class="embedded-record">Post has nested quote.</div>
256
256
-
</template>
257
257
-
<template v-else-if="embed.$type === 'app.bsky.embed.recordWithMedia#view'">
258
258
-
<ImageEmbed
259
259
-
v-if="embed.media.$type === 'app.bsky.embed.images#view'"
260
260
-
:embed="embed.media"
261
261
-
/>
262
262
-
<EmbedRecord
263
263
-
v-if="embed.record.$type === 'app.bsky.embed.record#view'"
264
264
-
:embed="embed.record"
265
265
-
/>
266
266
-
</template>
267
267
-
</div>
336
336
+
<EmbedRecord
337
337
+
v-if="embed.record.$type === 'app.bsky.embed.record#view'"
338
338
+
:embed="embed.record"
339
339
+
/>
340
340
+
</template>
341
341
+
</div>
268
342
269
269
-
<div class="post-footer" v-if="!embedded">
270
270
-
<div class="metrics row">
271
271
-
<AppLink
272
272
-
name="post-thread"
273
273
-
:params="{ identifier: displayPost.author.handle, rkey: rkey! }"
274
274
-
class="action-button reply"
275
275
-
aria-label="Reply"
276
276
-
@click.stop
277
277
-
>
278
278
-
<div class="icon-wrapper"><IconChatBubbleOutlineRounded /></div>
279
279
-
<span class="count" v-if="displayPost.replyCount && displayPost.replyCount > 0">
280
280
-
{{ formatCount(displayPost.replyCount) }}
281
281
-
</span>
282
282
-
</AppLink>
343
343
+
<div class="post-footer" v-if="!embedded">
344
344
+
<div class="metrics row">
345
345
+
<AppLink
346
346
+
name="post-thread"
347
347
+
:params="{ identifier: displayPost.author.handle, rkey: rkey! }"
348
348
+
class="action-button reply"
349
349
+
aria-label="Reply"
350
350
+
@click.stop
351
351
+
>
352
352
+
<div class="icon-wrapper"><IconChatBubbleOutlineRounded /></div>
353
353
+
<span class="count" v-if="displayPost.replyCount && displayPost.replyCount > 0">
354
354
+
{{ formatCount(displayPost.replyCount) }}
355
355
+
</span>
356
356
+
</AppLink>
283
357
284
284
-
<BasePopover
285
285
-
:actions="[
286
286
-
{
287
287
-
label: !!displayPost.viewer?.repost ? 'Undo Repost' : 'Repost',
288
288
-
icon: IconRepeatRounded,
289
289
-
onClick: handleRepost,
290
290
-
},
291
291
-
{
292
292
-
label: 'Quote Post',
293
293
-
icon: IconFormatQuoteRounded,
294
294
-
onClick: () => {},
295
295
-
},
296
296
-
]"
297
297
-
align="right"
298
298
-
>
299
299
-
<template #trigger="{ triggerProps }">
300
300
-
<button
301
301
-
class="action-button repost more"
302
302
-
:class="{ 'is-active': !!displayPost.viewer?.repost }"
303
303
-
v-bind="triggerProps as any"
304
304
-
>
305
305
-
<div class="icon-wrapper"><IconRepeatRounded /></div>
306
306
-
<span class="count" v-if="displayPost.repostCount && displayPost.repostCount > 0">
307
307
-
{{ formatCount(displayPost.repostCount) }}
308
308
-
</span>
309
309
-
</button>
310
310
-
</template>
311
311
-
</BasePopover>
358
358
+
<BasePopover
359
359
+
:actions="[
360
360
+
{
361
361
+
label: !!displayPost.viewer?.repost ? 'Undo Repost' : 'Repost',
362
362
+
icon: IconRepeatRounded,
363
363
+
onClick: handleRepost,
364
364
+
},
365
365
+
{
366
366
+
label: 'Quote Post',
367
367
+
icon: IconFormatQuoteRounded,
368
368
+
onClick: () => {},
369
369
+
},
370
370
+
]"
371
371
+
align="right"
372
372
+
>
373
373
+
<template #trigger="{ triggerProps }">
374
374
+
<button
375
375
+
class="action-button repost more"
376
376
+
:class="{ 'is-active': !!displayPost.viewer?.repost }"
377
377
+
v-bind="triggerProps as any"
378
378
+
>
379
379
+
<div class="icon-wrapper"><IconRepeatRounded /></div>
380
380
+
<span class="count" v-if="displayPost.repostCount && displayPost.repostCount > 0">
381
381
+
{{ formatCount(displayPost.repostCount) }}
382
382
+
</span>
383
383
+
</button>
384
384
+
</template>
385
385
+
</BasePopover>
312
386
313
313
-
<button
314
314
-
class="action-button like"
315
315
-
:class="{ 'is-active': !!displayPost.viewer?.like }"
316
316
-
@click.stop="handleLike"
317
317
-
aria-label="Like"
318
318
-
>
319
319
-
<div class="icon-wrapper">
320
320
-
<IconFavoriteRounded v-if="!!displayPost.viewer?.like" />
321
321
-
<IconFavoriteOutlineRounded v-else />
322
322
-
</div>
323
323
-
<span class="count" v-if="displayPost.likeCount && displayPost.likeCount > 0">
324
324
-
{{ formatCount(displayPost.likeCount) }}
325
325
-
</span>
326
326
-
</button>
327
327
-
</div>
387
387
+
<button
388
388
+
class="action-button like"
389
389
+
:class="{ 'is-active': !!displayPost.viewer?.like }"
390
390
+
@click.stop="handleLike"
391
391
+
aria-label="Like"
392
392
+
>
393
393
+
<div class="icon-wrapper">
394
394
+
<IconFavoriteRounded v-if="!!displayPost.viewer?.like" />
395
395
+
<IconFavoriteOutlineRounded v-else />
396
396
+
</div>
397
397
+
<span class="count" v-if="displayPost.likeCount && displayPost.likeCount > 0">
398
398
+
{{ formatCount(displayPost.likeCount) }}
399
399
+
</span>
400
400
+
</button>
401
401
+
</div>
402
402
+
403
403
+
<div class="footer-content row">
404
404
+
<BasePopover
405
405
+
:actions="[
406
406
+
{
407
407
+
actions: [
408
408
+
{ label: 'System share', icon: IconForwardRounded, onClick: handleShare },
409
409
+
],
410
410
+
},
411
411
+
{
412
412
+
actions: [
413
413
+
{
414
414
+
label: 'Copy Link',
415
415
+
icon: IconLinkRounded,
416
416
+
onClick: share.copyLink,
417
417
+
},
418
418
+
{
419
419
+
label: 'Copy Bluesky link',
420
420
+
icon: IconContentCopyRounded,
421
421
+
onClick: share.copyBlueskyLink,
422
422
+
},
423
423
+
],
424
424
+
},
425
425
+
{
426
426
+
actions: [
427
427
+
{
428
428
+
label: 'Copy AT URI',
429
429
+
icon: IconContentCopyRounded,
430
430
+
onClick: share.copyBlueskyLink,
431
431
+
},
432
432
+
{
433
433
+
label: 'Copy author DID',
434
434
+
icon: IconLinkRounded,
435
435
+
onClick: share.copyDid,
436
436
+
},
437
437
+
{
438
438
+
label: 'Open in PDSls',
439
439
+
icon: IconOpenInNewRounded,
440
440
+
onClick: share.openInPDSls,
441
441
+
},
442
442
+
],
443
443
+
},
444
444
+
]"
445
445
+
align="right"
446
446
+
>
447
447
+
<template #trigger="{ triggerProps }">
448
448
+
<button class="action-button more" v-bind="triggerProps as any">
449
449
+
<div class="icon-wrapper">
450
450
+
<IconForwardRounded />
451
451
+
</div>
452
452
+
</button>
453
453
+
</template>
454
454
+
</BasePopover>
328
455
329
329
-
<div class="footer-content row">
330
330
-
<BasePopover
331
331
-
:actions="[
332
332
-
{
333
333
-
actions: [
334
334
-
{ label: 'Share', icon: IconSendRounded, onClick: () => {} },
335
335
-
{
336
336
-
label: displayPost.viewer?.bookmarked ? 'Remove Bookmark' : 'Bookmark',
337
337
-
icon: displayPost.viewer?.bookmarked
338
338
-
? IconBookmarkAddedRounded
339
339
-
: IconBookmarkRounded,
340
340
-
onClick: handleBookmark,
341
341
-
},
342
342
-
],
343
343
-
},
344
344
-
{
345
345
-
label: 'awawa',
346
346
-
actions: [
347
347
-
{ label: 'Share', icon: IconSendRounded, onClick: () => {}, variant: 'danger' },
348
348
-
{
349
349
-
label: displayPost.viewer?.bookmarked ? 'Remove Bookmark' : 'Bookmark',
350
350
-
icon: displayPost.viewer?.bookmarked
351
351
-
? IconBookmarkAddedRounded
352
352
-
: IconBookmarkRounded,
353
353
-
onClick: handleBookmark,
354
354
-
disabled: true,
355
355
-
},
356
356
-
],
357
357
-
},
358
358
-
]"
359
359
-
align="right"
360
360
-
>
361
361
-
<template #trigger="{ triggerProps }">
362
362
-
<button class="action-button more" v-bind="triggerProps as any">
363
363
-
<div class="icon-wrapper">
364
364
-
<IconMoreVert />
365
365
-
</div>
366
366
-
</button>
367
367
-
</template>
368
368
-
</BasePopover>
369
369
-
</div>
456
456
+
<BasePopover
457
457
+
:actions="[
458
458
+
{
459
459
+
actions: [
460
460
+
{
461
461
+
label: displayPost.viewer?.bookmarked ? 'Remove Bookmark' : 'Bookmark',
462
462
+
icon: displayPost.viewer?.bookmarked
463
463
+
? IconBookmarkAddedRounded
464
464
+
: IconBookmarkRounded,
465
465
+
onClick: handleBookmark,
466
466
+
},
467
467
+
],
468
468
+
},
469
469
+
]"
470
470
+
align="right"
471
471
+
>
472
472
+
<template #trigger="{ triggerProps }">
473
473
+
<button class="action-button more" v-bind="triggerProps as any">
474
474
+
<div class="icon-wrapper">
475
475
+
<IconMoreVert />
476
476
+
</div>
477
477
+
</button>
478
478
+
</template>
479
479
+
</BasePopover>
370
480
</div>
371
481
</div>
372
482
</div>
···
375
485
376
486
<style lang="scss" scoped>
377
487
.feed-item {
378
378
-
padding: 0.75rem 1rem;
379
488
display: flex;
380
489
flex-direction: column;
381
490
gap: 0.25rem;
491
491
+
padding-top: 0.5rem;
492
492
+
padding-bottom: 0.5rem;
382
493
383
494
opacity: 0;
384
495
filter: blur(4px);
···
465
576
}
466
577
467
578
.post-layout {
579
579
+
--gutter-width: calc(2.75rem + 1rem);
468
580
display: flex;
469
469
-
gap: 0.75rem;
581
581
+
flex-direction: column;
582
582
+
583
583
+
.post-top {
584
584
+
padding: 0 0.75rem;
585
585
+
display: flex;
586
586
+
gap: 0.75rem;
587
587
+
.not-gutter {
588
588
+
width: 100%;
589
589
+
}
590
590
+
}
470
591
471
592
.post-avatar {
472
593
flex-shrink: 0;
···
490
611
}
491
612
}
492
613
493
493
-
.post-content {
494
494
-
flex: 1;
495
495
-
min-width: 0;
614
614
+
.post-header {
496
615
display: flex;
497
497
-
flex-direction: column;
616
616
+
align-items: last baseline;
617
617
+
flex-direction: row;
618
618
+
justify-content: space-between;
619
619
+
620
620
+
gap: 0.5rem;
621
621
+
font-size: 1rem;
622
622
+
line-height: 1.3;
498
623
499
499
-
.post-header {
624
624
+
&__part {
500
625
display: flex;
501
501
-
align-items: last baseline;
502
626
flex-direction: row;
503
503
-
justify-content: space-between;
504
504
-
627
627
+
align-items: last baseline;
505
628
gap: 0.5rem;
506
506
-
font-size: 1rem;
507
507
-
line-height: 1.3;
508
508
-
margin-bottom: 0.125rem;
509
509
-
510
510
-
&__part {
511
511
-
display: flex;
512
512
-
flex-direction: row;
513
513
-
align-items: last baseline;
514
514
-
gap: 0.5rem;
515
515
-
}
516
516
-
517
517
-
* {
518
518
-
min-width: 0;
519
519
-
text-wrap: nowrap;
520
520
-
text-overflow: ellipsis;
521
521
-
overflow: hidden;
522
522
-
}
523
523
-
524
524
-
.display-name {
525
525
-
font-weight: 700;
526
526
-
color: hsl(var(--text));
527
527
-
}
629
629
+
}
528
630
529
529
-
p {
530
530
-
font-size: 0.85rem;
531
531
-
color: hsla(var(--overlay0) / 1);
532
532
-
font-weight: 700;
533
533
-
}
631
631
+
* {
632
632
+
min-width: 0;
633
633
+
text-wrap: nowrap;
634
634
+
text-overflow: ellipsis;
635
635
+
overflow: hidden;
534
636
}
535
637
536
536
-
.post-text {
638
638
+
.display-name {
639
639
+
font-weight: 700;
537
640
color: hsl(var(--text));
538
538
-
font-size: 0.95rem;
539
539
-
line-height: 1.4;
540
540
-
white-space: pre-wrap;
541
541
-
word-wrap: break-word;
542
641
}
543
642
643
643
+
p {
644
644
+
font-size: 0.85rem;
645
645
+
color: hsla(var(--overlay0) / 1);
646
646
+
font-weight: 700;
647
647
+
}
648
648
+
}
649
649
+
650
650
+
.post-text {
651
651
+
color: hsl(var(--text));
652
652
+
font-size: 0.95rem;
653
653
+
line-height: 1.4;
654
654
+
white-space: pre-wrap;
655
655
+
word-wrap: break-word;
656
656
+
}
657
657
+
658
658
+
.post-content {
659
659
+
flex: 1;
660
660
+
min-width: 0;
661
661
+
display: flex;
662
662
+
flex-direction: column;
663
663
+
544
664
.embedded-record {
545
665
padding: 0.5rem;
546
666
border: 1px solid hsla(var(--surface2) / 0.5);
···
553
673
}
554
674
}
555
675
676
676
+
.post-embeds {
677
677
+
&:not(:has(.image-embed__wrapper)) {
678
678
+
padding: 0 0.75rem;
679
679
+
padding-left: calc(var(--gutter-width));
680
680
+
}
681
681
+
}
682
682
+
556
683
.post-footer {
557
684
display: flex;
558
685
align-items: center;
559
686
justify-content: space-between;
560
687
margin-top: 0.5rem;
561
561
-
margin-left: -0.5rem;
688
688
+
padding: 0 0.75rem;
689
689
+
margin-left: calc(-0.75rem + var(--gutter-width));
562
690
563
691
.row {
564
692
display: flex;
-3
src/stores/posts.ts
···
96
96
const originalBookmarked = post.viewer?.bookmarked
97
97
const originalCount = post.bookmarkCount || 0
98
98
99
99
-
console.log(post.viewer)
100
100
-
console.log('Toggling bookmark', { uri, originalBookmarked, originalCount })
101
101
-
102
99
if (!post.viewer) post.viewer = {}
103
100
104
101
if (originalBookmarked) {
+69
src/utils/identity.ts
···
1
1
+
import type { DidDocument } from '@atcute/identity'
2
2
+
import {
3
3
+
CompositeDidDocumentResolver,
4
4
+
CompositeHandleResolver,
5
5
+
DohJsonHandleResolver,
6
6
+
PlcDidDocumentResolver,
7
7
+
WebDidDocumentResolver,
8
8
+
WellKnownHandleResolver,
9
9
+
} from '@atcute/identity-resolver'
10
10
+
import type { Handle } from '@atcute/lexicons'
11
11
+
import { type AtprotoDid, isDid, isHandle } from '@atcute/lexicons/syntax'
12
12
+
13
13
+
const handleResolver = new CompositeHandleResolver({
14
14
+
methods: {
15
15
+
dns: new DohJsonHandleResolver({
16
16
+
dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query',
17
17
+
}),
18
18
+
http: new WellKnownHandleResolver(),
19
19
+
},
20
20
+
})
21
21
+
22
22
+
const didResolver = new CompositeDidDocumentResolver({
23
23
+
methods: {
24
24
+
plc: new PlcDidDocumentResolver(),
25
25
+
web: new WebDidDocumentResolver(),
26
26
+
},
27
27
+
})
28
28
+
29
29
+
export async function resolveHandle(handle: Handle): Promise<AtprotoDid> {
30
30
+
return handleResolver.resolve(handle)
31
31
+
}
32
32
+
33
33
+
type Identity = {
34
34
+
doc: DidDocument
35
35
+
handle?: string
36
36
+
pds?: string
37
37
+
}
38
38
+
export async function resolveDid(did: AtprotoDid): Promise<Identity> {
39
39
+
const doc = await didResolver.resolve(did)
40
40
+
41
41
+
const serviceEntry = doc.service?.find((service) => service.type === 'AtprotoPersonalDataServer')
42
42
+
let pds: string | undefined
43
43
+
if (serviceEntry) {
44
44
+
const endpoint = serviceEntry.serviceEndpoint
45
45
+
46
46
+
if (typeof endpoint === 'string') pds = endpoint
47
47
+
else if (Array.isArray(endpoint)) {
48
48
+
const firstString = endpoint.find((item) => typeof item === 'string')
49
49
+
if (typeof firstString === 'string') pds = firstString
50
50
+
}
51
51
+
}
52
52
+
53
53
+
let handle: string | undefined
54
54
+
const alsoKnown = doc.alsoKnownAs?.find((alsoKnown) => typeof alsoKnown === 'string')
55
55
+
if (alsoKnown) handle = alsoKnown
56
56
+
57
57
+
return { doc, pds, handle }
58
58
+
}
59
59
+
60
60
+
export async function getDid(input: string): Promise<AtprotoDid> {
61
61
+
if (isDid(input)) return input as AtprotoDid
62
62
+
else if (isHandle(input)) return resolveHandle(input)
63
63
+
else throw new Error('Invalid input')
64
64
+
}
65
65
+
66
66
+
export async function getIdentity(input: string): Promise<Identity> {
67
67
+
const did = await getDid(input)
68
68
+
return resolveDid(did)
69
69
+
}