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