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(profile): show fronters in profile
vt3e.cat
6 days ago
c3a91649
b24a441e
verified
This commit was signed with the committer's
known signature
.
vt3e.cat
SSH Key Fingerprint:
SHA256:MaVgF6bXxDdD131G4rXizPh+sttp3IVsdPrj48HV0X0=
+375
-8
3 changed files
expand all
collapse all
unified
split
src
components
Modals
Plurality
PluralHelp.vue
utils
atproto.ts
views
Profile
ProfileView.vue
+197
src/components/Modals/Plurality/PluralHelp.vue
···
1
1
+
<script lang="ts" setup>
2
2
+
import {
3
3
+
IconGroupsRounded,
4
4
+
IconInfoRounded,
5
5
+
IconOpenInNewRounded,
6
6
+
} from '@iconify-prerendered/vue-material-symbols'
7
7
+
8
8
+
import Modal from '@/components/UI/BaseModal.vue'
9
9
+
import Button from '@/components/UI/BaseButton.vue'
10
10
+
11
11
+
const serviceLink = 'https://plural.host'
12
12
+
const infoLink = 'https://morethanone.info'
13
13
+
</script>
14
14
+
15
15
+
<template>
16
16
+
<Modal title="Plurality & Fronting" width="480px">
17
17
+
<div class="plural-modal">
18
18
+
<div class="modal-header">
19
19
+
<div class="header-icon">&</div>
20
20
+
<p class="desc">
21
21
+
Plurality is when multiple self-aware entities exist together in one head. Similarly to
22
22
+
lifelong roommates, but who share a body rather than an apartment.
23
23
+
</p>
24
24
+
</div>
25
25
+
26
26
+
<div class="info-grid">
27
27
+
<div class="info-item primary">
28
28
+
<IconInfoRounded class="info-icon" />
29
29
+
<div class="info-text">
30
30
+
<h3>What is "Fronting"?</h3>
31
31
+
<p>
32
32
+
Fronting refers to which member(s) of a system are currently present and active, if
33
33
+
any.
34
34
+
</p>
35
35
+
</div>
36
36
+
</div>
37
37
+
38
38
+
<a :href="infoLink" target="_blank" class="info-item">
39
39
+
<IconOpenInNewRounded class="info-icon" />
40
40
+
<span>Learn More</span>
41
41
+
</a>
42
42
+
<a :href="serviceLink" target="_blank" class="info-item">
43
43
+
<IconGroupsRounded class="info-icon" />
44
44
+
<span>Set Up Profile</span>
45
45
+
</a>
46
46
+
</div>
47
47
+
</div>
48
48
+
<template #footer>
49
49
+
<Button variant="primary" @click="$emit('close')">okay!!!</Button>
50
50
+
</template>
51
51
+
</Modal>
52
52
+
</template>
53
53
+
54
54
+
<style lang="scss" scoped>
55
55
+
.plural-modal {
56
56
+
display: flex;
57
57
+
flex-direction: column;
58
58
+
gap: 1rem;
59
59
+
padding: 0.5rem 0;
60
60
+
text-align: center;
61
61
+
62
62
+
.modal-header {
63
63
+
display: flex;
64
64
+
flex-direction: column;
65
65
+
align-items: center;
66
66
+
gap: 0.75rem;
67
67
+
68
68
+
.header-icon {
69
69
+
font-size: 4rem;
70
70
+
font-weight: 900;
71
71
+
color: hsl(var(--accent));
72
72
+
}
73
73
+
.desc {
74
74
+
color: hsl(var(--subtext0));
75
75
+
}
76
76
+
}
77
77
+
78
78
+
.info-grid {
79
79
+
display: grid;
80
80
+
gap: 0.5rem;
81
81
+
82
82
+
.info-item {
83
83
+
display: flex;
84
84
+
gap: 1rem;
85
85
+
align-items: flex-start;
86
86
+
87
87
+
padding: 1rem;
88
88
+
background: hsla(var(--accent) / 0.05);
89
89
+
border-radius: var(--radius-md);
90
90
+
text-decoration: none;
91
91
+
color: hsl(var(--text));
92
92
+
user-select: none;
93
93
+
94
94
+
.info-icon {
95
95
+
font-size: 1.5rem;
96
96
+
color: hsl(var(--accent));
97
97
+
flex-shrink: 0;
98
98
+
}
99
99
+
100
100
+
.info-text {
101
101
+
h3 {
102
102
+
font-size: 0.95rem;
103
103
+
margin-bottom: 0.25rem;
104
104
+
}
105
105
+
p {
106
106
+
font-size: 0.85rem;
107
107
+
color: hsl(var(--subtext0));
108
108
+
line-height: 1.4;
109
109
+
}
110
110
+
}
111
111
+
112
112
+
&.primary {
113
113
+
grid-column: span 2;
114
114
+
text-align: left;
115
115
+
gap: 0.5rem;
116
116
+
}
117
117
+
118
118
+
&:hover {
119
119
+
background: hsla(var(--accent) / 0.1);
120
120
+
}
121
121
+
}
122
122
+
}
123
123
+
124
124
+
/* .info-section {
125
125
+
background: hsla(var(--surface1) / 0.1);
126
126
+
border-radius: 1rem;
127
127
+
padding: 1rem;
128
128
+
text-align: left;
129
129
+
130
130
+
.info-item {
131
131
+
display: flex;
132
132
+
gap: 1rem;
133
133
+
align-items: flex-start;
134
134
+
135
135
+
.info-icon {
136
136
+
font-size: 1.5rem;
137
137
+
color: hsl(var(--accent));
138
138
+
flex-shrink: 0;
139
139
+
}
140
140
+
141
141
+
.info-text {
142
142
+
strong {
143
143
+
display: block;
144
144
+
margin-bottom: 0.25rem;
145
145
+
font-size: 0.9rem;
146
146
+
}
147
147
+
p {
148
148
+
font-size: 0.85rem;
149
149
+
color: hsl(var(--subtext0));
150
150
+
line-height: 1.4;
151
151
+
}
152
152
+
}
153
153
+
}
154
154
+
}
155
155
+
156
156
+
.modal-actions {
157
157
+
display: flex;
158
158
+
justify-content: center;
159
159
+
gap: 0.75rem;
160
160
+
161
161
+
.action-card {
162
162
+
display: flex;
163
163
+
flex-direction: column;
164
164
+
align-items: center;
165
165
+
justify-content: center;
166
166
+
gap: 0.5rem;
167
167
+
padding: 1rem;
168
168
+
flex: 1;
169
169
+
background: hsla(var(--surface1) / 0.1);
170
170
+
border-radius: var(--radius-md);
171
171
+
text-decoration: none;
172
172
+
color: hsl(var(--text));
173
173
+
174
174
+
.action-icon {
175
175
+
font-size: 1.25rem;
176
176
+
color: hsl(var(--accent));
177
177
+
}
178
178
+
179
179
+
span {
180
180
+
font-size: 0.8rem;
181
181
+
font-weight: 600;
182
182
+
}
183
183
+
184
184
+
&:hover {
185
185
+
background: hsla(var(--surface1) / 0.2);
186
186
+
}
187
187
+
}
188
188
+
} */
189
189
+
190
190
+
.service-credit {
191
191
+
font-size: 0.8rem;
192
192
+
color: hsl(var(--subtext0));
193
193
+
opacity: 0.8;
194
194
+
line-height: 1.4;
195
195
+
}
196
196
+
}
197
197
+
</style>
+11
src/utils/atproto.ts
···
1
1
import type { AppBskyFeedDefs } from '@atcute/bluesky'
2
2
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto'
3
3
+
import type { ActorIdentifier, Blob, LegacyBlob } from '@atcute/lexicons'
4
4
+
import { isLegacyBlob } from '@atcute/lexicons/interfaces'
3
5
4
6
export function createStrongRef(post: AppBskyFeedDefs.PostView): ComAtprotoRepoStrongRef.Main {
5
7
return {
···
8
10
uri: post.uri,
9
11
}
10
12
}
13
13
+
14
14
+
export function blobUrl(pdsUrl: string, did: ActorIdentifier, blob: LegacyBlob | Blob): string {
15
15
+
let cid: string
16
16
+
17
17
+
if (isLegacyBlob(blob)) cid = blob.cid
18
18
+
else cid = blob.ref.$link
19
19
+
20
20
+
return `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`
21
21
+
}
+167
-8
src/views/Profile/ProfileView.vue
···
1
1
<script setup lang="ts">
2
2
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
3
3
import { AppBskyActorDefs, AppBskyFeedDefs } from '@atcute/bluesky'
4
4
-
import { ok } from '@atcute/client'
4
4
+
import { Client, ok, simpleFetchHandler } from '@atcute/client'
5
5
import {
6
6
IconAddRounded,
7
7
IconRemoveRounded,
8
8
IconMoreVert,
9
9
IconGlobe,
10
10
+
IconInfoRounded,
10
11
IconCalendarMonthRounded,
11
12
IconBombRounded,
12
13
IconPets,
···
17
18
import { useAuthStore } from '@/stores/auth'
18
19
import { usePostStore } from '@/stores/posts'
19
20
import { useToastStore } from '@/stores/toast'
21
21
+
import { useModalStore } from '@/stores/modal'
22
22
+
20
23
import { useEnvironmentStore } from '@/stores/environment'
21
24
22
25
import { useDraggableScroll } from '@/composables/useDraggableScroll'
···
29
32
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
30
33
import RichText from '@/components/RichText.vue'
31
34
35
35
+
import PluralModal from '@/components/Modals/Plurality/PluralHelp.vue'
36
36
+
32
37
import type { ActorIdentifier } from '@atcute/lexicons'
33
38
import AppLink from '@/components/Navigation/AppLink.vue'
34
39
import type { CollectionString } from '@/types/atproto'
40
40
+
import { getIdentity } from '@/utils/identity'
41
41
+
import { HostPluralSystemMember, type HostPluralFrontLog } from '@/lex'
42
42
+
import { blobUrl } from '@/utils/atproto'
35
43
36
44
const props = defineProps<{ id: string }>()
37
45
···
39
47
const postStore = usePostStore()
40
48
const toast = useToastStore()
41
49
const env = useEnvironmentStore()
50
50
+
const modal = useModalStore()
42
51
43
52
const { events: statsDragEvents, isDragging } = useDraggableScroll()
44
53
54
54
+
const getPdsClient = async () => {
55
55
+
if (!profile.value) throw new Error('profile not loaded')
56
56
+
const { pds } = await getIdentity(profile.value.did)
57
57
+
if (!pds) throw new Error('Failed to resolve user PDS')
58
58
+
return new Client({ handler: simpleFetchHandler({ service: pds }) })
59
59
+
}
60
60
+
61
61
+
const pdsUrl = ref<string | null>(null)
45
62
const profile = ref<AppBskyActorDefs.ProfileViewDetailed | null>(null)
46
63
const feed = ref<AppBskyFeedDefs.FeedViewPost[]>([])
47
64
const cursor = ref<string | undefined>(undefined)
···
55
72
type Tab = {
56
73
label: string
57
74
value: TabType
75
75
+
isFeed: boolean
58
76
}
59
77
60
60
-
type TabType = 'posts_no_replies' | 'posts_with_replies' | 'posts_with_media'
78
78
+
type TabType = 'posts_no_replies' | 'posts_with_replies' | 'posts_with_media' | 'pluralhost'
61
79
const tabs: Tab[] = [
62
62
-
{ label: 'Posts', value: 'posts_no_replies' },
63
63
-
{ label: 'Replies', value: 'posts_with_replies' },
64
64
-
{ label: 'Media', value: 'posts_with_media' },
80
80
+
{ label: 'Posts', value: 'posts_no_replies', isFeed: true },
81
81
+
{ label: 'Replies', value: 'posts_with_replies', isFeed: true },
82
82
+
{ label: 'Media', value: 'posts_with_media', isFeed: true },
65
83
]
66
84
const activeTab = ref<TabType>('posts_no_replies')
67
85
const tabRefs = ref<HTMLElement[]>([])
···
75
93
transform: `translateX(${el.offsetLeft}px)`,
76
94
}
77
95
})
96
96
+
97
97
+
const isSystem = ref(false)
98
98
+
const fronters = ref<HostPluralSystemMember.Main[]>([])
78
99
79
100
const formatCount = (num: number | undefined) => {
80
101
if (!num) return '0'
···
112
133
() => profile.value?.viewer?.following && profile.value?.viewer?.followedBy,
113
134
)
114
135
136
136
+
const memberAvatar = (member: HostPluralSystemMember.Main) => {
137
137
+
return blobUrl(pdsUrl.value!, profile.value!.did, member.avatar!)
138
138
+
}
139
139
+
115
140
const followTerm = computed(() => {
116
141
if (!profile.value) return 'Follow'
117
142
const viewer = profile.value.viewer
···
151
176
loadingProfile.value = true
152
177
try {
153
178
const rpc = auth.getRpc()
179
179
+
154
180
const data = await ok(
155
181
rpc.get('app.bsky.actor.getProfile', { params: { actor: props.id as ActorIdentifier } }),
156
182
)
157
183
profile.value = data
184
184
+
185
185
+
const { pds } = await getIdentity(profile.value.did)
186
186
+
if (!pds) throw new Error('Failed to resolve user PDS')
187
187
+
pdsUrl.value = pds
158
188
} catch (e) {
159
189
if (e instanceof Error) error.value = e.message
160
190
else error.value = 'An unknown error occurred :c'
···
162
192
} finally {
163
193
loadingProfile.value = false
164
194
}
195
195
+
196
196
+
await fetchSystemStatus()
197
197
+
}
198
198
+
199
199
+
const fetchSystemStatus = async () => {
200
200
+
isSystem.value = false
201
201
+
fronters.value = []
202
202
+
203
203
+
if (!profile.value) {
204
204
+
console.error('profile not loaded, cannot fetch system status')
205
205
+
return
206
206
+
}
207
207
+
208
208
+
try {
209
209
+
const client = await getPdsClient()
210
210
+
if (!client) {
211
211
+
console.error('PDS client not initialized')
212
212
+
return
213
213
+
}
214
214
+
215
215
+
const frontLog = await ok(
216
216
+
client.get('com.atproto.repo.listRecords', {
217
217
+
params: {
218
218
+
repo: profile.value.did,
219
219
+
collection: 'host.plural.front.log',
220
220
+
},
221
221
+
}),
222
222
+
)
223
223
+
224
224
+
if (frontLog.records.length > 0) {
225
225
+
isSystem.value = true
226
226
+
}
227
227
+
228
228
+
const lastLog = frontLog.records[0]?.value as HostPluralFrontLog.Main
229
229
+
if (!lastLog) return
230
230
+
231
231
+
const fronterIds = lastLog.fronters
232
232
+
if (!fronterIds || fronterIds.length === 0) return
233
233
+
234
234
+
const fronterRes = await Promise.all(
235
235
+
fronterIds.map((fronterDid) =>
236
236
+
ok(
237
237
+
client.get('com.atproto.repo.getRecord', {
238
238
+
params: {
239
239
+
repo: profile.value!.did,
240
240
+
collection: 'host.plural.system.member',
241
241
+
rkey: fronterDid,
242
242
+
},
243
243
+
}),
244
244
+
),
245
245
+
),
246
246
+
)
247
247
+
248
248
+
fronters.value = fronterRes.map((res) => res.value as HostPluralSystemMember.Main)
249
249
+
} catch {}
165
250
}
166
251
167
252
const fetchFeed = async (reset = false) => {
···
516
601
</div>
517
602
</div>
518
603
604
604
+
<div v-if="isSystem && fronters?.length" class="system">
605
605
+
<div class="system__label">
606
606
+
<span>Fronting</span>
607
607
+
<Button variant="text" size="sm" icon @click="modal.open(PluralModal)">
608
608
+
<IconInfoRounded />
609
609
+
</Button>
610
610
+
</div>
611
611
+
612
612
+
<div class="fronters">
613
613
+
<div
614
614
+
class="fronter"
615
615
+
v-for="fronter in fronters"
616
616
+
:key="fronter.createdAt || fronter.displayName"
617
617
+
>
618
618
+
<div v-if="memberAvatar(fronter)" class="avatar">
619
619
+
<img :src="memberAvatar(fronter)" alt="" />
620
620
+
</div>
621
621
+
<div class="name">{{ fronter.displayName }}</div>
622
622
+
</div>
623
623
+
</div>
624
624
+
</div>
625
625
+
519
626
<RichText v-if="profile.description" :text="profile.description" class="description" />
520
627
521
628
<div class="additional">
···
728
835
align-items: center;
729
836
gap: 0.25rem;
730
837
}
731
731
-
732
732
-
&:not(.mobile) {
733
733
-
}
734
838
}
735
839
736
840
.profile {
···
738
842
display: flex;
739
843
flex-direction: column;
740
844
gap: 0.5rem;
845
845
+
align-items: flex-start;
741
846
742
847
.stats {
743
848
display: flex;
···
844
949
text-decoration: underline;
845
950
}
846
951
}
952
952
+
}
953
953
+
}
954
954
+
955
955
+
.system {
956
956
+
display: inline-flex;
957
957
+
align-items: center;
958
958
+
gap: 0.25rem;
959
959
+
border-radius: 2rem;
960
960
+
961
961
+
&__label {
962
962
+
font-size: 0.75rem;
963
963
+
color: hsl(var(--subtext0));
964
964
+
text-transform: uppercase;
965
965
+
display: flex;
966
966
+
align-items: center;
967
967
+
}
968
968
+
969
969
+
.fronters {
970
970
+
display: flex;
971
971
+
gap: 0.5rem;
972
972
+
973
973
+
.fronter {
974
974
+
display: flex;
975
975
+
align-items: center;
976
976
+
border-radius: 2rem;
977
977
+
background: hsl(var(--surface0));
978
978
+
padding: 0.1rem;
979
979
+
980
980
+
.avatar {
981
981
+
width: 1.5rem;
982
982
+
height: 1.5rem;
983
983
+
border-radius: 50%;
984
984
+
overflow: hidden;
985
985
+
}
986
986
+
987
987
+
img {
988
988
+
width: 100%;
989
989
+
height: 100%;
990
990
+
object-fit: cover;
991
991
+
}
992
992
+
993
993
+
.name {
994
994
+
font-size: 0.75rem;
995
995
+
color: hsl(var(--subtext0));
996
996
+
margin: 0 0.5rem 0 0.25rem;
997
997
+
}
998
998
+
}
999
999
+
}
1000
1000
+
1001
1001
+
scrollbar-width: none;
1002
1002
+
-ms-overflow-style: none;
1003
1003
+
1004
1004
+
&::-webkit-scrollbar {
1005
1005
+
display: none;
847
1006
}
848
1007
}
849
1008
}