tangled
alpha
login
or
join now
vt3e.cat
/
bbell
7
fork
atom
wip bsky client for the web & android
bbell.vt3e.cat
7
fork
atom
overview
issues
pulls
pipelines
feat(profile): new profile and richtext component
vt3e.cat
1 week ago
6dfffa6e
b476a82d
verified
This commit was signed with the committer's
known signature
.
vt3e.cat
SSH Key Fingerprint:
SHA256:MaVgF6bXxDdD131G4rXizPh+sttp3IVsdPrj48HV0X0=
+654
-291
6 changed files
expand all
collapse all
unified
split
bun.lock
package.json
src
components
RichText.vue
UI
BasePopover.vue
utils
focusable.ts
views
Profile
ProfileView.vue
+22
-19
bun.lock
···
4
4
"": {
5
5
"name": "bluebell",
6
6
"dependencies": {
7
7
-
"@atcute/atproto": "^3.1.9",
8
8
-
"@atcute/bluesky": "^3.2.14",
9
9
-
"@atcute/client": "^4.1.1",
10
10
-
"@atcute/identity-resolver": "^1.2.0",
11
11
-
"@atcute/lexicons": "^1.2.5",
7
7
+
"@atcute/atproto": "^3.1.10",
8
8
+
"@atcute/bluesky": "^3.2.18",
9
9
+
"@atcute/bluesky-richtext-parser": "^2.1.1",
10
10
+
"@atcute/client": "^4.2.1",
11
11
+
"@atcute/identity-resolver": "^1.2.2",
12
12
+
"@atcute/lexicons": "^1.2.9",
12
13
"@atcute/oauth-browser-client": "^2.0.3",
13
13
-
"@capacitor/android": "^8.0.0",
14
14
-
"@capacitor/app": "^8.0.0",
14
14
+
"@capacitor/android": "^8.1.0",
15
15
+
"@capacitor/app": "^8.0.1",
15
16
"@capacitor/assets": "^3.0.5",
16
16
-
"@capacitor/core": "^8.0.0",
17
17
+
"@capacitor/core": "^8.1.0",
17
18
"@capacitor/haptics": "^8.0.0",
18
19
"@iconify-prerendered/vue-material-symbols": "^0.28.1755063979",
19
20
"add": "^2.0.6",
···
22
23
"hls.js": "^1.6.15",
23
24
"i": "^0.3.7",
24
25
"pinia": "^3.0.4",
25
25
-
"vue": "^3.5.25",
26
26
+
"vue": "^3.5.28",
26
27
},
27
28
"devDependencies": {
28
28
-
"@capacitor/cli": "^8.0.0",
29
29
+
"@capacitor/cli": "^8.1.0",
29
30
"@prettier/plugin-oxc": "^0.0.5",
30
30
-
"@tsconfig/node24": "^24.0.3",
31
31
-
"@types/node": "^25.0.3",
32
32
-
"@vitejs/plugin-vue": "^6.0.2",
31
31
+
"@tsconfig/node24": "^24.0.4",
32
32
+
"@types/node": "^25.3.0",
33
33
+
"@vitejs/plugin-vue": "^6.0.4",
33
34
"@vue/eslint-config-prettier": "^10.2.0",
34
34
-
"@vue/eslint-config-typescript": "^14.6.0",
35
35
+
"@vue/eslint-config-typescript": "^14.7.0",
35
36
"@vue/tsconfig": "^0.8.1",
36
36
-
"eslint": "^9.39.1",
37
37
+
"eslint": "^9.39.3",
37
38
"eslint-plugin-oxlint": "~1.29.0",
38
39
"eslint-plugin-vue": "~10.5.1",
39
40
"jiti": "^2.6.1",
40
41
"npm-run-all2": "^8.0.4",
41
42
"oxlint": "~1.29.0",
42
43
"prettier": "3.6.2",
43
43
-
"rollup-plugin-visualizer": "^6.0.5",
44
44
-
"sass-embedded": "^1.97.0",
44
44
+
"rollup-plugin-visualizer": "^6.0.8",
45
45
+
"sass-embedded": "^1.97.3",
45
46
"typescript": "^5.9.3",
46
47
"vite": "npm:rolldown-vite@latest",
47
47
-
"vite-plugin-vue-devtools": "^8.0.5",
48
48
-
"vue-tsc": "^3.1.5",
48
48
+
"vite-plugin-vue-devtools": "^8.0.6",
49
49
+
"vue-tsc": "^3.2.4",
49
50
},
50
51
},
51
52
},
···
53
54
"@atcute/atproto": ["@atcute/atproto@3.1.10", "", { "dependencies": { "@atcute/lexicons": "^1.2.6" } }, "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ=="],
54
55
55
56
"@atcute/bluesky": ["@atcute/bluesky@3.2.18", "", { "dependencies": { "@atcute/atproto": "^3.1.10", "@atcute/lexicons": "^1.2.9" } }, "sha512-8S4D0YMUUtvZFchBpEEkvIk7luMu0Z3l50ppUa+EGDDNqF6P5gkgm8q0qfaqpULtDyInKHR+MqJ8fMm20xWgFg=="],
57
57
+
58
58
+
"@atcute/bluesky-richtext-parser": ["@atcute/bluesky-richtext-parser@2.1.1", "", {}, "sha512-2CJiZ1oLAxQEz6BL5r1m/p+m89bb02959dFEvMvYI7CbHgIzbZsDOp3JB2XVu49DjPNtd9Mz5VnF5OBBpTgWdg=="],
56
59
57
60
"@atcute/client": ["@atcute/client@4.2.1", "", { "dependencies": { "@atcute/identity": "^1.1.3", "@atcute/lexicons": "^1.2.6" } }, "sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw=="],
58
61
+1
package.json
···
21
21
"dependencies": {
22
22
"@atcute/atproto": "^3.1.10",
23
23
"@atcute/bluesky": "^3.2.18",
24
24
+
"@atcute/bluesky-richtext-parser": "^2.1.1",
24
25
"@atcute/client": "^4.2.1",
25
26
"@atcute/identity-resolver": "^1.2.2",
26
27
"@atcute/lexicons": "^1.2.9",
+131
src/components/RichText.vue
···
1
1
+
<script lang="ts" setup>
2
2
+
import { h, type VNode } from 'vue'
3
3
+
import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'
4
4
+
5
5
+
import AppLink from '@/components/Navigation/AppLink.vue'
6
6
+
7
7
+
defineProps<{
8
8
+
text: string
9
9
+
}>()
10
10
+
11
11
+
const RenderToken = ({ token }: { token: Token }): VNode | string => {
12
12
+
switch (token.type) {
13
13
+
case 'text':
14
14
+
return h('span', { class: 'rt-text' }, token.content)
15
15
+
case 'escape':
16
16
+
return token.escaped
17
17
+
case 'code':
18
18
+
return h('code', { class: 'rt-code' }, token.content)
19
19
+
case 'autolink':
20
20
+
return h(
21
21
+
'a',
22
22
+
{ href: token.url, target: '_blank', rel: 'noopener noreferrer', class: 'rt-link' },
23
23
+
token.raw,
24
24
+
)
25
25
+
case 'mention':
26
26
+
return h(
27
27
+
AppLink,
28
28
+
{ name: 'user-profile', params: { id: token.handle }, class: 'rt-mention' },
29
29
+
{ default: () => token.raw },
30
30
+
)
31
31
+
case 'topic':
32
32
+
return h(
33
33
+
'a',
34
34
+
{ href: `/search?q=${encodeURIComponent(token.name)}`, class: 'rt-topic' },
35
35
+
token.raw,
36
36
+
)
37
37
+
case 'emote':
38
38
+
return h('span', { title: token.name, class: 'rt-emote' }, token.raw)
39
39
+
case 'link':
40
40
+
return h(
41
41
+
'a',
42
42
+
{ href: token.url, target: '_blank', rel: 'noopener noreferrer', class: 'rt-link' },
43
43
+
token.children.map((c) => RenderToken({ token: c })),
44
44
+
)
45
45
+
case 'strong':
46
46
+
return h(
47
47
+
'strong',
48
48
+
{ class: 'rt-strong' },
49
49
+
token.children.map((c) => RenderToken({ token: c })),
50
50
+
)
51
51
+
case 'emphasis':
52
52
+
return h(
53
53
+
'em',
54
54
+
{ class: 'rt-emphasis' },
55
55
+
token.children.map((c) => RenderToken({ token: c })),
56
56
+
)
57
57
+
case 'underline':
58
58
+
return h(
59
59
+
'u',
60
60
+
{ class: 'rt-underline' },
61
61
+
token.children.map((c) => RenderToken({ token: c })),
62
62
+
)
63
63
+
case 'delete':
64
64
+
return h(
65
65
+
'del',
66
66
+
{ class: 'rt-delete' },
67
67
+
token.children.map((c) => RenderToken({ token: c })),
68
68
+
)
69
69
+
default:
70
70
+
return token.raw
71
71
+
}
72
72
+
}
73
73
+
</script>
74
74
+
75
75
+
<template>
76
76
+
<div class="rt-container">
77
77
+
<RenderToken v-for="(token, i) in tokenize(text)" :key="i" :token="token" />
78
78
+
</div>
79
79
+
</template>
80
80
+
81
81
+
<style scoped>
82
82
+
.rt-container {
83
83
+
white-space: pre-wrap;
84
84
+
word-break: break-word;
85
85
+
color: hsl(var(--text));
86
86
+
line-height: 1.5;
87
87
+
}
88
88
+
89
89
+
.rt-link,
90
90
+
.rt-mention,
91
91
+
.rt-topic {
92
92
+
color: hsl(var(--accent));
93
93
+
text-decoration: none;
94
94
+
&:hover {
95
95
+
color: hsla(var(--accent) / 0.8);
96
96
+
}
97
97
+
}
98
98
+
99
99
+
.rt-code {
100
100
+
background-color: hsl(var(--surface0));
101
101
+
color: hsl(var(--subtext0));
102
102
+
padding: 0.2rem 0.4rem;
103
103
+
border-radius: 4px;
104
104
+
font-family: monospace;
105
105
+
font-size: 0.9em;
106
106
+
}
107
107
+
108
108
+
.rt-strong {
109
109
+
font-weight: 600;
110
110
+
color: hsl(var(--text));
111
111
+
}
112
112
+
113
113
+
.rt-emphasis {
114
114
+
font-style: italic;
115
115
+
}
116
116
+
117
117
+
.rt-underline {
118
118
+
text-decoration: underline;
119
119
+
text-decoration-color: hsl(var(--overlay2));
120
120
+
}
121
121
+
122
122
+
.rt-delete {
123
123
+
text-decoration: line-through;
124
124
+
color: hsl(var(--subtext0));
125
125
+
}
126
126
+
127
127
+
.rt-emote {
128
128
+
font-style: italic;
129
129
+
color: hsl(var(--subtext0));
130
130
+
}
131
131
+
</style>
+29
-2
src/components/UI/BasePopover.vue
···
6
6
import { useReposition } from '@/composables/useReposition'
7
7
8
8
import type { Component } from 'vue'
9
9
+
import { FOCUSABLE_SELECTOR, isNaturallyFocusable } from '@/utils/focusable'
9
10
10
11
interface PopoverAction {
11
12
label: string
···
36
37
(e: 'open'): void
37
38
(e: 'close'): void
38
39
}>()
40
40
+
41
41
+
const unwrapToElement = (maybeEl: unknown): HTMLElement | null => {
42
42
+
if (!maybeEl) return null
43
43
+
44
44
+
if (maybeEl instanceof HTMLElement) return maybeEl
45
45
+
if (maybeEl.$el instanceof HTMLElement) return maybeEl.$el as HTMLElement
46
46
+
if (maybeEl.$el?.value instanceof HTMLElement) return maybeEl.$el.value as HTMLElement
47
47
+
if (maybeEl.value instanceof HTMLElement) return maybeEl.value as HTMLElement
48
48
+
49
49
+
return null
50
50
+
}
39
51
40
52
const env = useEnvironmentStore()
41
53
const isMobile = computed(() => env.isMobile)
···
139
151
}
140
152
}
141
153
154
154
+
const focusTriggerElement = (container: HTMLElement | null) => {
155
155
+
if (!container) return
156
156
+
157
157
+
const descendant = container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
158
158
+
const target = descendant ?? container
159
159
+
160
160
+
if (!isNaturallyFocusable(target) && !target.hasAttribute('tabindex')) {
161
161
+
target.setAttribute('tabindex', '-1')
162
162
+
target.focus()
163
163
+
target.removeAttribute('tabindex')
164
164
+
} else {
165
165
+
;(target as HTMLElement).focus?.()
166
166
+
}
167
167
+
}
168
168
+
142
169
const close = () => {
143
170
isOpen.value = false
144
171
currentY.value = 0
145
172
backdropOpacity.value = 1
146
173
isDragging.value = false
147
174
emit('close')
148
148
-
triggerRef.value?.querySelector('button')?.focus()
175
175
+
focusTriggerElement(triggerRef.value)
149
176
}
150
177
151
178
const toggle = () => (isOpen.value ? close() : open())
···
165
192
defineExpose({ open, close, toggle })
166
193
167
194
const setTriggerRef = (el: HTMLElement | null) => {
168
168
-
triggerRef.value = el
195
195
+
triggerRef.value = unwrapToElement(el)
169
196
}
170
197
</script>
171
198
+11
src/utils/focusable.ts
···
1
1
+
export const FOCUSABLE_SELECTOR =
2
2
+
'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"]), [contenteditable]'
3
3
+
4
4
+
export const isNaturallyFocusable = (el: HTMLElement) => {
5
5
+
const node = el.nodeName.toLowerCase()
6
6
+
if (node === 'a') return (el as HTMLAnchorElement).hasAttribute('href')
7
7
+
if (['input', 'select', 'textarea', 'button', 'iframe'].includes(node)) {
8
8
+
return !(el as HTMLButtonElement).hasAttribute('disabled')
9
9
+
}
10
10
+
return false
11
11
+
}
+460
-270
src/views/Profile/ProfileView.vue
···
5
5
import {
6
6
IconAddRounded,
7
7
IconRemoveRounded,
8
8
-
IconMoreHoriz,
8
8
+
IconMoreVert,
9
9
IconGlobe,
10
10
IconCalendarMonthRounded,
11
11
+
IconBombRounded,
12
12
+
IconPets,
13
13
+
IconTouchAppRounded,
11
14
IconArrowDownwardRounded,
12
15
} from '@iconify-prerendered/vue-material-symbols'
13
16
14
17
import { useAuthStore } from '@/stores/auth'
15
18
import { usePostStore } from '@/stores/posts'
19
19
+
import { useToastStore } from '@/stores/toast'
16
20
17
21
import BackButton from '@/components/Navigation/BackButton.vue'
18
22
import PageLayout from '@/components/Navigation/PageLayout.vue'
19
23
import FeedItem from '@/components/Feed/FeedItem.vue'
20
24
import Button from '@/components/UI/BaseButton.vue'
25
25
+
import Popover from '@/components/UI/BasePopover.vue'
21
26
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
22
22
-
import SVG from '@/components/UI/SVG.vue'
27
27
+
import RichText from '@/components/RichText.vue'
23
28
24
24
-
import BluebellLogo from '@/assets/icons/bluebell.svg?raw'
25
29
import type { ActorIdentifier } from '@atcute/lexicons'
26
30
import AppLink from '@/components/Navigation/AppLink.vue'
31
31
+
import type { CollectionString } from '@/types/atproto'
27
32
28
33
const props = defineProps<{ id: string }>()
29
34
30
35
const auth = useAuthStore()
31
36
const postStore = usePostStore()
37
37
+
const toast = useToastStore()
32
38
33
39
const profile = ref<AppBskyActorDefs.ProfileViewDetailed | null>(null)
34
40
const feed = ref<AppBskyFeedDefs.FeedViewPost[]>([])
···
81
87
}
82
88
}
83
89
90
90
+
const isFollowingSelf = computed(() => {
91
91
+
if (!profile.value || !auth.session?.info.sub) return false
92
92
+
const followers = profile.value.viewer?.knownFollowers?.followers
93
93
+
if (!followers || followers.length === 0) return false
94
94
+
95
95
+
const dids = followers.map((f) => f.did)
96
96
+
return dids.includes(auth.session.info.sub)
97
97
+
})
98
98
+
84
99
const isSelf = computed(() => auth.session?.info.sub === profile.value?.did)
85
85
-
const isFollowing = computed(() => !!profile.value?.viewer?.following)
100
100
+
const isFollowing = computed(() => {
101
101
+
if (!profile.value) return false
102
102
+
if (isSelf.value) return isFollowingSelf.value
103
103
+
return Boolean(profile.value.viewer?.following)
104
104
+
})
105
105
+
const isMutual = computed(
106
106
+
() => profile.value?.viewer?.following && profile.value?.viewer?.followedBy,
107
107
+
)
86
108
87
87
-
const bannerStyle = computed(() => {
88
88
-
if (!profile.value?.banner) return {}
89
89
-
return { backgroundImage: `url(${profile.value.banner})` }
109
109
+
const followTerm = computed(() => {
110
110
+
if (!profile.value) return 'Follow'
111
111
+
const viewer = profile.value.viewer
112
112
+
113
113
+
if (isFollowingSelf.value) {
114
114
+
return 'Unfollow yourself'
115
115
+
}
116
116
+
117
117
+
if (!viewer) return 'Follow'
118
118
+
const follows = Boolean(viewer.following)
119
119
+
120
120
+
if (isMutual.value) return 'Unfollow mutual'
121
121
+
if (follows) return 'Unfollow (2)'
122
122
+
return 'Follow'
90
123
})
91
124
92
125
const heroStyle = computed(() => {
···
204
237
}
205
238
}
206
239
240
240
+
async function createRecord(
241
241
+
collection: CollectionString,
242
242
+
record: Record<string, unknown>,
243
243
+
message: { error: string; success: string } = {
244
244
+
error: 'Failed to perform action',
245
245
+
success: 'Yayyyyyy done!!',
246
246
+
},
247
247
+
) {
248
248
+
const rpc = auth.getRpc()
249
249
+
try {
250
250
+
await ok(
251
251
+
rpc.post('com.atproto.repo.createRecord', {
252
252
+
input: {
253
253
+
collection,
254
254
+
repo: auth.session!.info.sub,
255
255
+
record: { $type: collection, ...record },
256
256
+
},
257
257
+
}),
258
258
+
)
259
259
+
toast.success(message.success)
260
260
+
} catch (e) {
261
261
+
console.error(message.error, e)
262
262
+
toast.error(message.error)
263
263
+
}
264
264
+
}
265
265
+
266
266
+
const actions = {
267
267
+
bite: async () => {
268
268
+
if (!profile.value) return
269
269
+
await createRecord(
270
270
+
'net.wafrn.feed.bite',
271
271
+
{
272
272
+
$type: 'net.wafrn.feed.bite',
273
273
+
subject: `at://${profile.value.did}/app.bsky.actor.profile/self`,
274
274
+
createdAt: new Date().toISOString(),
275
275
+
},
276
276
+
{
277
277
+
error: `failed to bite ${profile.value.displayName || profile.value.handle} :c`,
278
278
+
success: `bit ${profile.value.displayName || profile.value.handle}!`,
279
279
+
},
280
280
+
)
281
281
+
},
282
282
+
explode: async () => {
283
283
+
if (!profile.value) return
284
284
+
await createRecord(
285
285
+
'net.wafrn.feed.explode',
286
286
+
{
287
287
+
$type: 'net.wafrn.feed.explode',
288
288
+
subject: `at://${profile.value.did}/app.bsky.actor.profile/self`,
289
289
+
createdAt: new Date().toISOString(),
290
290
+
},
291
291
+
{
292
292
+
error: `failed to explode ${profile.value.displayName || profile.value.handle} :c`,
293
293
+
success: `exploded ${profile.value.displayName || profile.value.handle}!`,
294
294
+
},
295
295
+
)
296
296
+
},
297
297
+
poke: async () => {
298
298
+
if (!profile.value) return
299
299
+
await createRecord(
300
300
+
'xyz.atpoke.graph.poke',
301
301
+
{
302
302
+
$type: 'xyz.atpoke.graph.poke',
303
303
+
subject: profile.value.did,
304
304
+
createdAt: new Date().toISOString(),
305
305
+
},
306
306
+
{
307
307
+
error: `failed to poke ${profile.value.displayName || profile.value.handle} :c`,
308
308
+
success: `poked ${profile.value.displayName || profile.value.handle}!`,
309
309
+
},
310
310
+
)
311
311
+
},
312
312
+
}
313
313
+
314
314
+
const randomBanner = computed(() => {
315
315
+
const randomInteger = Math.floor(Math.random() * 124)
316
316
+
return `https://randomfox.ca/images/${randomInteger}.jpg`
317
317
+
})
318
318
+
319
319
+
const handleLink = computed(() => {
320
320
+
const noLink = ['bsky.social', 'blacksky.app']
321
321
+
const handleDomain = profile.value?.handle.split('.').slice(-1)[0]
322
322
+
if (handleDomain && !noLink.includes(handleDomain)) {
323
323
+
return `https://${profile.value?.handle}`
324
324
+
}
325
325
+
return null
326
326
+
})
327
327
+
207
328
watch(
208
329
() => props.id,
209
330
() => {
···
223
344
if (pageContent.value)
224
345
pageContent.value.scrollContainer?.removeEventListener('scroll', handleScroll)
225
346
})
347
347
+
348
348
+
const scrollToTop = () => {
349
349
+
if (pageContent.value) pageContent.value.scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' })
350
350
+
}
226
351
</script>
227
352
228
353
<template>
229
354
<PageLayout :title="profile?.handle || 'Profile'" noPadding ref="pageContent">
230
355
<template #app-bar>
231
231
-
<div class="topbar-left">
356
356
+
<div class="topbar-left" @click="scrollToTop">
232
357
<BackButton />
233
358
<div class="header-fade-wrapper" :style="{ opacity: headerOpacity }">
234
359
<div class="mini-avatar" v-if="profile?.avatar">
···
240
365
</div>
241
366
</div>
242
367
</div>
368
368
+
<div class="topbar-right" :style="{ opacity: headerOpacity }">
369
369
+
<Button v-if="isSelf" variant="secondary" size="md" pill>Edit</Button>
370
370
+
<Button
371
371
+
:variant="isFollowing ? 'secondary' : 'primary'"
372
372
+
size="md"
373
373
+
pill
374
374
+
flat
375
375
+
@click="toggleFollow"
376
376
+
>
377
377
+
<component :is="isFollowing ? IconRemoveRounded : IconAddRounded" />
378
378
+
{{ isFollowing ? 'Following' : 'Follow' }}
379
379
+
</Button>
380
380
+
</div>
243
381
</template>
244
382
245
245
-
<div class="profile-container">
246
246
-
<div class="hero-wrapper">
247
247
-
<div class="hero-bg" :style="[bannerStyle, heroStyle]"></div>
248
248
-
<div class="hero-overlay"></div>
383
383
+
<div class="profile-container" v-if="profile">
384
384
+
<div class="banner">
385
385
+
<img
386
386
+
v-if="profile?.banner"
387
387
+
:src="profile.banner"
388
388
+
class="banner__image"
389
389
+
:style="heroStyle"
390
390
+
:alt="`${profile.displayName || profile.handle}'s banner`"
391
391
+
aria-role="presentation"
392
392
+
/>
393
393
+
<img
394
394
+
v-else
395
395
+
class="banner__image banner__placeholder"
396
396
+
:src="randomBanner"
397
397
+
aria-role="presentation"
398
398
+
alt="picture of a fox"
399
399
+
/>
249
400
</div>
250
250
-
251
251
-
<div class="content-stack">
252
252
-
<div class="identity-card-wrapper" v-if="profile">
253
253
-
<div class="identity-card">
254
254
-
<div class="card-top-row">
255
255
-
<div class="avatar-large">
256
256
-
<img v-if="profile.avatar" :src="profile.avatar" />
257
257
-
<div v-else class="fallback"><SVG :icon="BluebellLogo" /></div>
258
258
-
</div>
259
259
-
260
260
-
<div class="action-buttons">
261
261
-
<Button v-if="isSelf" variant="secondary" size="sm" flat>Edit Profile</Button>
262
262
-
<Button
263
263
-
:variant="isFollowing ? 'secondary' : 'primary'"
264
264
-
size="sm"
265
265
-
flat
266
266
-
@click="toggleFollow"
267
267
-
>
268
268
-
<component :is="isFollowing ? IconRemoveRounded : IconAddRounded" />
269
269
-
{{ isFollowing ? 'Following' : 'Follow' }}
401
401
+
<div class="identity">
402
402
+
<div class="avatar" v-if="profile?.avatar">
403
403
+
<img
404
404
+
:src="profile.avatar"
405
405
+
:alt="`${profile.displayName || profile.handle}'s avatar`"
406
406
+
aria-role="presentation"
407
407
+
/>
408
408
+
</div>
409
409
+
<div class="identity-row">
410
410
+
<div class="identity-row__names">
411
411
+
<span class="header-name">{{ profile?.displayName || profile.handle }}</span>
412
412
+
<a v-if="handleLink" :href="handleLink" target="_blank" class="header-handle"
413
413
+
>@{{ formatUrl(profile.handle) }}</a
414
414
+
>
415
415
+
<span class="header-handle" v-else>@{{ profile?.handle }}</span>
416
416
+
</div>
417
417
+
<div class="identity-row__actions">
418
418
+
<Button v-if="isSelf" variant="secondary" size="md" pill>Edit</Button>
419
419
+
<Button
420
420
+
v-else
421
421
+
:variant="isFollowing ? 'secondary' : 'primary'"
422
422
+
size="md"
423
423
+
pill
424
424
+
flat
425
425
+
@click="toggleFollow"
426
426
+
>
427
427
+
<component :is="isFollowing ? IconRemoveRounded : IconAddRounded" />
428
428
+
{{ isFollowing ? 'Following' : 'Follow' }}
429
429
+
</Button>
430
430
+
<Popover
431
431
+
:actions="[
432
432
+
{
433
433
+
actions: [
434
434
+
{
435
435
+
label: followTerm,
436
436
+
icon: isFollowing ? IconRemoveRounded : IconAddRounded,
437
437
+
onClick: toggleFollow,
438
438
+
},
439
439
+
],
440
440
+
},
441
441
+
{
442
442
+
label: 'Silly',
443
443
+
actions: [
444
444
+
{
445
445
+
label: 'Bite',
446
446
+
icon: IconPets,
447
447
+
onClick: actions.bite,
448
448
+
},
449
449
+
{
450
450
+
label: 'Explode',
451
451
+
icon: IconBombRounded,
452
452
+
onClick: actions.explode,
453
453
+
},
454
454
+
{
455
455
+
label: 'Poke',
456
456
+
icon: IconTouchAppRounded,
457
457
+
onClick: actions.poke,
458
458
+
},
459
459
+
],
460
460
+
},
461
461
+
]"
462
462
+
align="right"
463
463
+
>
464
464
+
<template #trigger="{ triggerProps }">
465
465
+
<Button pill variant="secondary" icon v-bind="triggerProps as any">
466
466
+
<div class="icon-wrapper">
467
467
+
<IconMoreVert />
468
468
+
</div>
270
469
</Button>
271
271
-
<Button variant="secondary" icon size="sm" flat>
272
272
-
<IconMoreHoriz />
273
273
-
</Button>
274
274
-
</div>
275
275
-
</div>
276
276
-
277
277
-
<div class="card-names">
278
278
-
<h1 class="display-name">{{ profile.displayName || profile.handle }}</h1>
279
279
-
<div class="handle-row">
280
280
-
<span class="handle">@{{ profile.handle }}</span>
281
281
-
<span class="badge" v-if="isSelf"> It's you!! </span>
282
282
-
<span class="badge" v-if="profile.viewer?.followedBy && profile.viewer?.following">
283
283
-
Mutuals
284
284
-
</span>
285
285
-
<span class="badge" v-else-if="profile.viewer?.followedBy"> Follows you </span>
286
286
-
</div>
287
287
-
</div>
288
288
-
289
289
-
<div class="stats-row">
290
290
-
<AppLink class="stat-item" name="user-followers" :params="{ id: profile.did }">
291
291
-
<span class="stat-val">{{ formatCount(profile.followersCount) }}</span>
292
292
-
<span class="stat-label">Followers</span>
293
293
-
</AppLink>
294
294
-
<AppLink class="stat-item" name="user-follows" :params="{ id: profile.did }">
295
295
-
<span class="stat-val">{{ formatCount(profile.followsCount) }}</span>
296
296
-
<span class="stat-label">Following</span>
297
297
-
</AppLink>
298
298
-
<div class="stat-item">
299
299
-
<span class="stat-val">{{ formatCount(profile.postsCount) }}</span>
300
300
-
<span class="stat-label">Posts</span>
301
301
-
</div>
302
302
-
</div>
303
303
-
304
304
-
<div class="bio-section" v-if="profile.description">
305
305
-
{{ profile.description }}
306
306
-
</div>
307
307
-
308
308
-
<div class="meta-details">
309
309
-
<div class="meta-pill" v-if="profile.indexedAt">
310
310
-
<IconCalendarMonthRounded />
311
311
-
Joined
312
312
-
{{
313
313
-
new Date(profile.indexedAt).toLocaleDateString(undefined, {
314
314
-
month: 'short',
315
315
-
year: 'numeric',
316
316
-
})
317
317
-
}}
318
318
-
</div>
319
319
-
<div class="meta-pill" v-if="profile.pronouns">
320
320
-
{{ profile.pronouns }}
321
321
-
</div>
322
322
-
<a
323
323
-
class="meta-pill"
324
324
-
v-if="profile.website"
325
325
-
:href="profile.website"
326
326
-
target="_blank"
327
327
-
rel="noopener"
328
328
-
>
329
329
-
<IconGlobe />
330
330
-
{{ formatUrl(profile.website) }}
331
331
-
</a>
332
332
-
</div>
470
470
+
</template>
471
471
+
</Popover>
333
472
</div>
334
473
</div>
335
335
-
336
336
-
<div v-else-if="loadingProfile" class="identity-card-wrapper">
337
337
-
<div class="identity-card loading">
338
338
-
<SkeletonLoader width="80px" height="80px" style="border-radius: 50%" />
339
339
-
<SkeletonLoader width="60%" height="2rem" style="margin-top: 1rem" />
340
340
-
<SkeletonLoader width="40%" height="1rem" style="margin-top: 0.5rem" />
474
474
+
</div>
475
475
+
<div class="profile">
476
476
+
<div class="stats">
477
477
+
<div class="stat alt" v-if="profile.pronouns">
478
478
+
<span class="stat-count">{{ profile.pronouns }}</span>
479
479
+
<span class="stat-label">Pronouns</span>
480
480
+
</div>
481
481
+
<div class="stat alt" v-if="isSelf">
482
482
+
<span class="stat-count"> it's you! </span>
483
483
+
</div>
484
484
+
<AppLink class="stat" name="user-followers" :params="{ id: profile.did }">
485
485
+
<span class="stat-count">{{ formatCount(profile?.followersCount) }}</span>
486
486
+
<span class="stat-label">Followers</span>
487
487
+
</AppLink>
488
488
+
<AppLink class="stat" name="user-follows" :params="{ id: profile.did }">
489
489
+
<span class="stat-count">{{ formatCount(profile?.followsCount) }}</span>
490
490
+
<span class="stat-label">Following</span>
491
491
+
</AppLink>
492
492
+
<div class="stat">
493
493
+
<span class="stat-count">{{ formatCount(profile?.postsCount) }}</span>
494
494
+
<span class="stat-label">Posts</span>
341
495
</div>
342
496
</div>
343
497
344
344
-
<div class="sticky-tabs">
345
345
-
<div class="tabs-inner">
346
346
-
<button
347
347
-
v-for="(tab, index) in tabs"
348
348
-
:key="tab.value"
349
349
-
:ref="
350
350
-
(el) => {
351
351
-
if (el) tabRefs[index] = el as HTMLElement
352
352
-
}
353
353
-
"
354
354
-
class="tab-btn"
355
355
-
:class="{ active: activeTab === tab.value }"
356
356
-
@click="activeTab = tab.value"
357
357
-
>
358
358
-
{{ tab.label }}
359
359
-
</button>
360
360
-
<div class="active-indicator" :style="indicatorStyle"></div>
498
498
+
<RichText v-if="profile.description" :text="profile.description" class="description" />
499
499
+
500
500
+
<div class="additional">
501
501
+
<div class="additional-item" v-if="profile.website">
502
502
+
<IconGlobe />
503
503
+
<a :href="profile.website" target="_blank">{{ formatUrl(profile.website) }}</a>
361
504
</div>
362
362
-
</div>
363
363
-
<div class="feed-container">
364
364
-
<div v-if="loadingFeed && feed.length === 0" class="loading-feed">
365
365
-
<SkeletonLoader
366
366
-
width="100%"
367
367
-
height="100px"
368
368
-
v-for="n in 5"
369
369
-
:key="n"
370
370
-
style="margin-bottom: 1rem; border-radius: 1rem"
371
371
-
/>
505
505
+
<div class="additional-item" v-if="profile.createdAt">
506
506
+
<IconCalendarMonthRounded />
507
507
+
<span>Joined {{ new Date(profile.createdAt).toLocaleDateString() }}</span>
372
508
</div>
509
509
+
</div>
510
510
+
</div>
511
511
+
</div>
373
512
374
374
-
<div v-else-if="feed.length === 0" class="empty-feed">
375
375
-
<IconArrowDownwardRounded class="big-icon" />
376
376
-
<p>Nothing to see here yet.</p>
377
377
-
</div>
513
513
+
<div class="sticky-tabs">
514
514
+
<div class="tabs-inner">
515
515
+
<button
516
516
+
v-for="(tab, index) in tabs"
517
517
+
:key="tab.value"
518
518
+
:ref="
519
519
+
(el) => {
520
520
+
if (el) tabRefs[index] = el as HTMLElement
521
521
+
}
522
522
+
"
523
523
+
class="tab-btn"
524
524
+
:class="{ active: activeTab === tab.value }"
525
525
+
@click="activeTab = tab.value"
526
526
+
>
527
527
+
{{ tab.label }}
528
528
+
</button>
529
529
+
<div class="active-indicator" :style="indicatorStyle"></div>
530
530
+
</div>
531
531
+
</div>
378
532
379
379
-
<div v-else class="feed-list">
380
380
-
<FeedItem v-for="item in feed" :key="item.post.uri" :item="item" />
381
381
-
<div class="load-more" v-if="cursor">
382
382
-
<Button variant="ghost" @click="fetchFeed(false)" :loading="loadingFeed">
383
383
-
Load more posts
384
384
-
</Button>
385
385
-
</div>
386
386
-
</div>
533
533
+
<div class="feed-container">
534
534
+
<div v-if="loadingFeed && feed.length === 0" class="loading-feed">
535
535
+
<SkeletonLoader
536
536
+
width="100%"
537
537
+
height="100px"
538
538
+
v-for="n in 5"
539
539
+
:key="n"
540
540
+
style="margin-bottom: 1rem; border-radius: 1rem"
541
541
+
/>
542
542
+
</div>
543
543
+
544
544
+
<div v-else-if="feed.length === 0" class="empty-feed">
545
545
+
<IconArrowDownwardRounded class="big-icon" />
546
546
+
<p>Nothing to see here yet.</p>
547
547
+
</div>
548
548
+
549
549
+
<div v-else class="feed-list">
550
550
+
<FeedItem v-for="item in feed" :key="item.post.uri" :item="item" />
551
551
+
<div class="load-more" v-if="cursor">
552
552
+
<Button variant="ghost" @click="fetchFeed(false)" :loading="loadingFeed">
553
553
+
Load more posts
554
554
+
</Button>
387
555
</div>
388
556
</div>
389
557
</div>
···
391
559
</template>
392
560
393
561
<style scoped lang="scss">
394
394
-
.topbar-left {
562
562
+
.topbar-left,
563
563
+
.topbar-right {
395
564
display: flex;
396
565
flex-direction: row;
397
566
gap: 0.5rem;
398
567
align-items: center;
568
568
+
transition: none;
399
569
}
400
570
401
571
.header-fade-wrapper {
402
572
display: flex;
573
573
+
flex-direction: row;
574
574
+
flex: 1;
575
575
+
403
576
align-items: center;
404
577
gap: 0.5rem;
405
578
transition: none;
···
434
607
}
435
608
436
609
.profile-container {
437
437
-
position: relative;
438
438
-
min-height: 100vh;
439
439
-
background-color: hsl(var(--base));
610
610
+
border-bottom: 1px solid hsl(var(--surface0) / 0.5);
440
611
}
441
612
442
442
-
.hero-wrapper {
443
443
-
position: absolute;
444
444
-
left: 0;
445
445
-
width: 100%;
446
446
-
height: 22rem;
613
613
+
.banner {
614
614
+
line-height: 0;
615
615
+
transition: none;
447
616
overflow: hidden;
448
448
-
z-index: 0;
617
617
+
border-radius: 1.5rem;
618
618
+
background: hsla(var(--base) / 1);
619
619
+
margin: 0.5rem;
620
620
+
outline: 4px solid hsla(var(--surface0) / 0.5);
621
621
+
outline-offset: -1px;
449
622
450
450
-
.hero-bg {
451
451
-
position: absolute;
452
452
-
inset: -20px;
453
453
-
background-color: hsl(var(--surface1));
454
454
-
background-size: cover;
455
455
-
background-position: center;
456
456
-
will-change: transform;
623
623
+
&__image {
624
624
+
width: 100%;
457
625
transition: none;
626
626
+
object-fit: cover;
458
627
}
459
459
-
460
460
-
.hero-overlay {
461
461
-
position: absolute;
462
462
-
inset: 0;
463
463
-
background: linear-gradient(to bottom, hsla(var(--base) / 0) 0%, hsla(var(--base) / 0.6) 100%);
464
464
-
backdrop-filter: blur(0px);
628
628
+
&__placeholder {
629
629
+
background: hsla(var(--surface1) / 0.25);
630
630
+
height: 214px;
465
631
}
466
632
}
467
633
468
468
-
.content-stack {
634
634
+
.identity {
469
635
position: relative;
470
470
-
z-index: 1;
471
471
-
padding-top: 14rem;
636
636
+
--avatar-size: 6rem;
637
637
+
padding: 0.5rem;
638
638
+
padding-bottom: 0;
472
639
display: flex;
473
473
-
flex-direction: column;
640
640
+
flex-direction: row;
641
641
+
gap: 1rem;
474
642
475
475
-
.identity-card-wrapper {
476
476
-
padding: 0 1rem;
477
477
-
margin-bottom: 1rem;
478
478
-
}
643
643
+
margin-top: calc((var(--avatar-size) / 2 + 0.75rem) * -1);
479
644
480
480
-
.identity-card {
481
481
-
background: hsla(var(--base) / 0.75);
482
482
-
backdrop-filter: blur(1.5rem);
483
483
-
-webkit-backdrop-filter: blur(1.5rem);
484
484
-
border: 1px solid hsla(var(--surface2) / 0.5);
485
485
-
border-radius: 1.5rem;
486
486
-
padding: 1.5rem;
487
487
-
488
488
-
display: flex;
489
489
-
flex-direction: column;
490
490
-
gap: 0.5rem;
491
491
-
492
492
-
&.loading {
493
493
-
min-height: 200px;
494
494
-
align-items: center;
495
495
-
justify-content: center;
496
496
-
}
497
497
-
}
498
498
-
499
499
-
.card-top-row {
500
500
-
display: flex;
501
501
-
justify-content: space-between;
502
502
-
align-items: flex-start;
503
503
-
margin-top: -3rem;
504
504
-
}
505
505
-
506
506
-
.avatar-large {
507
507
-
width: 5.5rem;
508
508
-
height: 5.5rem;
645
645
+
.avatar {
646
646
+
flex: 0 0 var(--avatar-size);
647
647
+
margin-left: 1rem;
648
648
+
width: var(--avatar-size);
649
649
+
height: var(--avatar-size);
509
650
border-radius: 50%;
510
651
overflow: hidden;
652
652
+
outline: 4px solid hsla(var(--surface0) / 1);
653
653
+
background: hsla(var(--base) / 1);
654
654
+
outline-offset: -1px;
511
655
512
656
img {
513
657
width: 100%;
514
658
height: 100%;
515
659
object-fit: cover;
516
660
}
517
517
-
.fallback {
518
518
-
width: 100%;
519
519
-
height: 100%;
520
520
-
display: flex;
521
521
-
align-items: center;
522
522
-
justify-content: center;
523
523
-
color: hsl(var(--accent));
524
524
-
}
525
661
}
526
662
527
527
-
.action-buttons {
663
663
+
.identity-row {
664
664
+
position: relative;
665
665
+
top: calc(var(--avatar-size) / 2);
666
666
+
height: calc(var(--avatar-size) / 2);
528
667
display: flex;
668
668
+
align-items: center;
669
669
+
justify-content: space-between;
670
670
+
flex: 1;
529
671
gap: 0.5rem;
530
530
-
margin-top: 3rem;
531
531
-
}
672
672
+
min-width: 0;
532
673
533
533
-
.card-names {
534
534
-
.display-name {
535
535
-
font-size: 1.75rem;
536
536
-
font-weight: 800;
537
537
-
color: hsl(var(--text));
538
538
-
line-height: 1;
539
539
-
}
674
674
+
&__names {
675
675
+
flex: 1 1 0%;
676
676
+
min-width: 0;
540
677
541
541
-
.handle-row {
542
678
display: flex;
543
543
-
align-items: center;
544
544
-
gap: 0.5rem;
545
545
-
margin-top: 0.25rem;
679
679
+
flex-direction: column;
680
680
+
681
681
+
span {
682
682
+
display: block;
683
683
+
white-space: nowrap;
684
684
+
overflow: hidden;
685
685
+
text-overflow: ellipsis;
686
686
+
min-width: 0;
687
687
+
}
546
688
547
547
-
.handle {
689
689
+
.header-name {
690
690
+
font-weight: 700;
691
691
+
font-size: 1.25rem;
692
692
+
}
693
693
+
.header-handle {
694
694
+
font-size: 0.9rem;
548
695
color: hsl(var(--subtext0));
549
549
-
font-size: 0.95rem;
696
696
+
text-decoration: none;
697
697
+
698
698
+
display: flex;
699
699
+
align-items: center;
700
700
+
gap: 0.25rem;
550
701
}
702
702
+
}
551
703
552
552
-
.badge {
553
553
-
font-size: 0.7rem;
554
554
-
background: hsla(var(--surface2) / 0.5);
555
555
-
color: hsl(var(--subtext1));
556
556
-
padding: 0.1rem 0.4rem;
557
557
-
border-radius: 4px;
558
558
-
font-weight: 600;
559
559
-
}
704
704
+
&__actions {
705
705
+
flex: 0 0 auto;
706
706
+
display: flex;
707
707
+
gap: 0.5rem;
560
708
}
561
709
}
710
710
+
}
711
711
+
712
712
+
.profile {
713
713
+
padding: 0 1.5rem 0.5rem 1.5rem;
714
714
+
display: flex;
715
715
+
flex-direction: column;
716
716
+
gap: 0.5rem;
562
717
563
563
-
.stats-row {
718
718
+
.stats {
564
719
display: flex;
565
565
-
gap: 1.5rem;
566
566
-
padding: 0.5rem 0;
567
567
-
border-bottom: 1px solid hsla(var(--surface2) / 0.5);
720
720
+
flex-direction: row;
721
721
+
gap: 0.25rem;
722
722
+
margin-top: 0.75rem;
568
723
569
569
-
.stat-item {
724
724
+
.stat {
570
725
display: flex;
571
571
-
align-items: baseline;
572
572
-
gap: 0.3rem;
573
573
-
cursor: pointer;
574
574
-
transition: opacity 0.2s;
726
726
+
flex-direction: row;
727
727
+
align-items: center;
728
728
+
gap: 0.25rem;
729
729
+
padding: 0 0.5rem;
730
730
+
border-radius: 1rem;
731
731
+
color: hsl(var(--subtext1));
575
732
576
576
-
&:hover {
577
577
-
opacity: 0.7;
733
733
+
.stat-count {
734
734
+
font-weight: 900;
735
735
+
font-size: 1rem;
736
736
+
color: inherit;
737
737
+
}
738
738
+
.stat-label {
739
739
+
font-size: 0.85rem;
740
740
+
color: inherit;
741
741
+
}
742
742
+
743
743
+
&:not(a) {
744
744
+
cursor: default;
578
745
}
579
746
580
580
-
.stat-val {
581
581
-
font-weight: 700;
747
747
+
&.alt {
748
748
+
background: hsla(var(--surface0) / 0.5);
749
749
+
color: hsl(var(--base));
750
750
+
padding: 0 0.5rem;
751
751
+
border-radius: 1rem;
752
752
+
753
753
+
.stat-label {
754
754
+
display: none;
755
755
+
}
756
756
+
757
757
+
color: hsl(var(--subtext1));
758
758
+
759
759
+
&:first-child {
760
760
+
background: hsla(var(--accent) / 0.75);
761
761
+
color: hsl(var(--base));
762
762
+
&:hover {
763
763
+
color: hsl(var(--base));
764
764
+
background: hsla(var(--accent) / 1);
765
765
+
}
766
766
+
&:active {
767
767
+
color: hsl(var(--base));
768
768
+
background: hsla(var(--accent) / 0.6);
769
769
+
}
770
770
+
}
771
771
+
}
772
772
+
773
773
+
&:hover {
582
774
color: hsl(var(--text));
775
775
+
background: hsla(var(--surface2) / 0.25);
583
776
}
584
584
-
.stat-label {
585
585
-
font-size: 0.85rem;
777
777
+
&:active {
586
778
color: hsl(var(--subtext0));
779
779
+
background: hsla(var(--surface2) / 0.15);
587
780
}
588
781
}
589
782
}
590
783
591
591
-
.bio-section {
592
592
-
white-space: pre-wrap;
593
593
-
color: hsl(var(--text));
594
594
-
line-height: 1.5;
595
595
-
font-size: 0.95rem;
596
596
-
}
597
597
-
598
598
-
.meta-details {
784
784
+
.additional {
599
785
display: flex;
600
600
-
flex-wrap: wrap;
786
786
+
flex-direction: row;
601
787
gap: 0.5rem;
788
788
+
color: hsl(var(--subtext0));
789
789
+
margin-left: -0.25rem;
602
790
603
603
-
.meta-pill {
791
791
+
.additional-item {
604
792
display: flex;
793
793
+
flex-direction: row;
605
794
align-items: center;
795
795
+
gap: 0.25rem;
796
796
+
font-size: 0.9rem;
606
797
607
607
-
gap: 0.3rem;
608
608
-
font-size: 0.8rem;
609
609
-
color: hsl(var(--subtext0));
610
610
-
background: hsla(var(--surface0) / 0.5);
611
611
-
padding: 0.25rem 0.75rem;
612
612
-
border-radius: 5rem;
613
613
-
text-decoration: none;
614
614
-
user-select: none;
798
798
+
padding: 0.1rem 0.75rem;
799
799
+
border-radius: 1rem;
800
800
+
background: hsla(var(--surface2) / 0.25);
801
801
+
802
802
+
a {
803
803
+
color: inherit;
804
804
+
text-decoration: none;
615
805
616
616
-
&:hover,
617
617
-
&:focus-visible {
618
618
-
background: hsla(var(--surface0) / 0.6);
619
619
-
}
620
620
-
&:active {
621
621
-
background: hsla(var(--surface0) / 0.45);
806
806
+
&:hover {
807
807
+
text-decoration: underline;
808
808
+
}
622
809
}
623
810
}
624
811
}
···
628
815
position: fixed;
629
816
bottom: calc(var(--inset-bottom) + 4.5rem);
630
817
z-index: 10;
818
818
+
631
819
left: 50%;
820
820
+
max-width: 75%;
821
821
+
overflow-x: auto;
632
822
transform: translateX(-50%);
633
823
634
824
padding: 0.25rem;