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
fix: oh wait no theyre not meant to be there
vt3e.cat
1 month ago
44ee16ff
ab334541
verified
This commit was signed with the committer's
known signature
.
vt3e.cat
SSH Key Fingerprint:
SHA256:MaVgF6bXxDdD131G4rXizPh+sttp3IVsdPrj48HV0X0=
-590
2 changed files
expand all
collapse all
unified
split
src
views
Profile
FollowersView.vue
FollowingView.vue
-222
src/views/Profile/FollowersView.vue
···
1
1
-
<script lang="ts" setup>
2
2
-
import { ref, onMounted, watch, computed, onBeforeUnmount } from 'vue'
3
3
-
import type { ActorIdentifier } from '@atcute/lexicons'
4
4
-
import { AppBskyActorDefs } from '@atcute/bluesky'
5
5
-
import { BSKY_APPVIEW, useAuthStore } from '@/stores/auth'
6
6
-
7
7
-
import PageLayout from '@/components/Navigation/PageLayout.vue'
8
8
-
import AppLink from '@/components/Navigation/AppLink.vue'
9
9
-
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
10
10
-
import Button from '@/components/UI/BaseButton.vue'
11
11
-
12
12
-
const props = defineProps<{ id: ActorIdentifier }>()
13
13
-
const auth = useAuthStore()
14
14
-
15
15
-
const follows = ref<AppBskyActorDefs.ProfileView[]>([])
16
16
-
const cursor = ref<string | null>(null)
17
17
-
const isLoading = ref(false)
18
18
-
const isError = ref<string | null>(null)
19
19
-
const loadMoreTrigger = ref<HTMLElement | null>(null)
20
20
-
let obs: IntersectionObserver | null = null
21
21
-
22
22
-
const isEmpty = computed(() => !isLoading.value && follows.value.length === 0 && !isError.value)
23
23
-
24
24
-
async function fetchFollows(reset = false) {
25
25
-
if (isLoading.value) return
26
26
-
isLoading.value = true
27
27
-
isError.value = null
28
28
-
29
29
-
if (reset) {
30
30
-
follows.value = []
31
31
-
cursor.value = null
32
32
-
}
33
33
-
34
34
-
try {
35
35
-
const rpc = auth.getRpc()
36
36
-
const { data, ok } = await rpc.get('app.bsky.graph.getFollowers', {
37
37
-
params: {
38
38
-
actor: props.id,
39
39
-
limit: 50,
40
40
-
cursor: cursor.value || undefined,
41
41
-
},
42
42
-
headers: { 'atproto-proxy': BSKY_APPVIEW },
43
43
-
})
44
44
-
45
45
-
if (!ok) {
46
46
-
isError.value = (data && data.error) || 'Failed to fetch follows'
47
47
-
return
48
48
-
}
49
49
-
50
50
-
follows.value.push(...(data.followers || []))
51
51
-
cursor.value = data.cursor || null
52
52
-
} catch (e) {
53
53
-
console.error('fetchFollows error', e)
54
54
-
isError.value = e instanceof Error ? e.message : 'Unknown error'
55
55
-
} finally {
56
56
-
isLoading.value = false
57
57
-
}
58
58
-
}
59
59
-
60
60
-
async function toggleFollowRow(profile: AppBskyActorDefs.ProfileView) {
61
61
-
if (!auth.isAuthenticated || !auth.session) return
62
62
-
const rpc = auth.getRpc()
63
63
-
const original = profile.viewer?.following
64
64
-
65
65
-
profile.viewer = profile.viewer || {}
66
66
-
if (original) {
67
67
-
profile.viewer.following = undefined
68
68
-
} else {
69
69
-
profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary`
70
70
-
}
71
71
-
72
72
-
try {
73
73
-
if (original) {
74
74
-
const rkey = original.split('/').pop()!
75
75
-
await rpc.post('com.atproto.repo.deleteRecord', {
76
76
-
input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey },
77
77
-
})
78
78
-
} else {
79
79
-
const { data, ok } = await rpc.post('com.atproto.repo.createRecord', {
80
80
-
input: {
81
81
-
collection: 'app.bsky.graph.follow',
82
82
-
repo: auth.session.info.sub,
83
83
-
record: {
84
84
-
$type: 'app.bsky.graph.follow',
85
85
-
subject: profile.did,
86
86
-
createdAt: new Date().toISOString(),
87
87
-
},
88
88
-
},
89
89
-
})
90
90
-
if (ok)
91
91
-
profile.viewer
92
92
-
? (profile.viewer.following = data.uri)
93
93
-
: (profile.viewer = { following: data.uri })
94
94
-
}
95
95
-
} catch (e) {
96
96
-
console.error('toggleFollowRow failed', e)
97
97
-
profile.viewer.following = original
98
98
-
}
99
99
-
}
100
100
-
101
101
-
const isMutual = (p: AppBskyActorDefs.ProfileView) =>
102
102
-
!!(p.viewer?.following && p.viewer?.followedBy)
103
103
-
104
104
-
const isFollowingRow = (p: AppBskyActorDefs.ProfileView) => !!p.viewer?.following
105
105
-
106
106
-
function followButtonLabel(p: AppBskyActorDefs.ProfileView) {
107
107
-
if (isMutual(p)) return 'Mutuals'
108
108
-
return isFollowingRow(p) ? 'Following' : 'Follow'
109
109
-
}
110
110
-
111
111
-
function setupObserver() {
112
112
-
if (!loadMoreTrigger.value) return
113
113
-
obs = new IntersectionObserver(
114
114
-
(entries) => {
115
115
-
for (const ent of entries) {
116
116
-
if (ent.isIntersecting && cursor.value && !isLoading.value) {
117
117
-
fetchFollows(false)
118
118
-
}
119
119
-
}
120
120
-
},
121
121
-
{ root: null, rootMargin: '200px', threshold: 0.1 },
122
122
-
)
123
123
-
obs.observe(loadMoreTrigger.value)
124
124
-
}
125
125
-
126
126
-
onMounted(async () => {
127
127
-
await fetchFollows(true)
128
128
-
setupObserver()
129
129
-
})
130
130
-
131
131
-
onBeforeUnmount(() => {
132
132
-
if (obs && loadMoreTrigger.value) obs.unobserve(loadMoreTrigger.value)
133
133
-
obs = null
134
134
-
})
135
135
-
136
136
-
watch(
137
137
-
() => props.id,
138
138
-
() => {
139
139
-
fetchFollows(true)
140
140
-
},
141
141
-
)
142
142
-
</script>
143
143
-
144
144
-
<template>
145
145
-
<PageLayout title="Following" no-padding>
146
146
-
<div class="follows-list" role="list">
147
147
-
<div v-if="isLoading && follows.length === 0" class="skeletons">
148
148
-
<SkeletonLoader v-for="n in 6" :key="n" width="100%" height="72px" />
149
149
-
</div>
150
150
-
151
151
-
<div v-else-if="isError" class="error-state">
152
152
-
<p>Failed to load follows - {{ isError }}</p>
153
153
-
<Button @click="fetchFollows(true)">Retry</Button>
154
154
-
</div>
155
155
-
156
156
-
<div v-else-if="isEmpty" class="empty-state">
157
157
-
<p>This user isn't following anyone yet.</p>
158
158
-
</div>
159
159
-
160
160
-
<AppLink
161
161
-
v-for="follow in follows"
162
162
-
:key="follow.did"
163
163
-
class="follow"
164
164
-
name="user-profile"
165
165
-
:params="{ id: follow.did }"
166
166
-
role="listitem"
167
167
-
>
168
168
-
<img
169
169
-
class="avatar"
170
170
-
:src="follow.avatar"
171
171
-
:alt="`${follow.displayName || follow.handle}'s avatar`"
172
172
-
loading="lazy"
173
173
-
/>
174
174
-
<div class="info">
175
175
-
<div class="top">
176
176
-
<div class="display-name">{{ follow.displayName || follow.handle }}</div>
177
177
-
<span class="handle">@{{ follow.handle }}</span>
178
178
-
<span v-if="follow.pronouns" class="dot" aria-hidden="true">·</span>
179
179
-
<span v-if="follow.pronouns" class="pronouns">{{ follow.pronouns }}</span>
180
180
-
</div>
181
181
-
<div class="meta">
182
182
-
<span v-if="follow.description" class="bio">{{ follow.description }}</span>
183
183
-
</div>
184
184
-
</div>
185
185
-
186
186
-
<button
187
187
-
class="follow-btn"
188
188
-
:class="{ mutual: isMutual(follow), following: isFollowingRow(follow) }"
189
189
-
@click.stop.prevent="toggleFollowRow(follow)"
190
190
-
:aria-pressed="!!follow.viewer?.following"
191
191
-
:title="
192
192
-
isMutual(follow)
193
193
-
? 'Mutuals - click to unfollow'
194
194
-
: follow.viewer?.following
195
195
-
? 'Unfollow'
196
196
-
: 'Follow'
197
197
-
"
198
198
-
>
199
199
-
{{ followButtonLabel(follow) }}
200
200
-
</button>
201
201
-
</AppLink>
202
202
-
203
203
-
<div ref="loadMoreTrigger" class="load-more__sentinel" />
204
204
-
<div class="load-more__fallback" v-if="cursor && !isLoading">
205
205
-
<Button variant="ghost" @click="fetchFollows(false)">Load more</Button>
206
206
-
</div>
207
207
-
<div v-if="isLoading && follows.length > 0" class="loading-more">
208
208
-
<SkeletonLoader width="100%" height="72px" />
209
209
-
</div>
210
210
-
</div>
211
211
-
</PageLayout>
212
212
-
</template>
213
213
-
214
214
-
<style scoped lang="scss">
215
215
-
.follows-list {
216
216
-
/* padding: 1rem; */
217
217
-
display: flex;
218
218
-
flex-direction: column;
219
219
-
/* gap: 0.5rem; */
220
220
-
min-height: 200px;
221
221
-
}
222
222
-
</style>
-368
src/views/Profile/FollowingView.vue
···
1
1
-
<script lang="ts" setup>
2
2
-
import { ref, onMounted, watch, computed, onBeforeUnmount } from 'vue'
3
3
-
import type { ActorIdentifier } from '@atcute/lexicons'
4
4
-
import { AppBskyActorDefs } from '@atcute/bluesky'
5
5
-
import { BSKY_APPVIEW, useAuthStore } from '@/stores/auth'
6
6
-
7
7
-
import PageLayout from '@/components/Navigation/PageLayout.vue'
8
8
-
import AppLink from '@/components/Navigation/AppLink.vue'
9
9
-
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
10
10
-
import Button from '@/components/UI/BaseButton.vue'
11
11
-
12
12
-
const props = defineProps<{ id: ActorIdentifier }>()
13
13
-
const auth = useAuthStore()
14
14
-
15
15
-
const follows = ref<AppBskyActorDefs.ProfileView[]>([])
16
16
-
const cursor = ref<string | null>(null)
17
17
-
const isLoading = ref(false)
18
18
-
const isError = ref<string | null>(null)
19
19
-
const loadMoreTrigger = ref<HTMLElement | null>(null)
20
20
-
let obs: IntersectionObserver | null = null
21
21
-
22
22
-
const isEmpty = computed(() => !isLoading.value && follows.value.length === 0 && !isError.value)
23
23
-
24
24
-
async function fetchFollows(reset = false) {
25
25
-
if (isLoading.value) return
26
26
-
isLoading.value = true
27
27
-
isError.value = null
28
28
-
29
29
-
if (reset) {
30
30
-
follows.value = []
31
31
-
cursor.value = null
32
32
-
}
33
33
-
34
34
-
try {
35
35
-
const rpc = auth.getRpc()
36
36
-
const { data, ok } = await rpc.get('app.bsky.graph.getFollows', {
37
37
-
params: {
38
38
-
actor: props.id,
39
39
-
limit: 50,
40
40
-
cursor: cursor.value || undefined,
41
41
-
},
42
42
-
headers: { 'atproto-proxy': BSKY_APPVIEW },
43
43
-
})
44
44
-
45
45
-
if (!ok) {
46
46
-
isError.value = (data && data.error) || 'Failed to fetch follows'
47
47
-
return
48
48
-
}
49
49
-
50
50
-
follows.value.push(...(data.follows || []))
51
51
-
cursor.value = data.cursor || null
52
52
-
} catch (e) {
53
53
-
console.error('fetchFollows error', e)
54
54
-
isError.value = e instanceof Error ? e.message : 'Unknown error'
55
55
-
} finally {
56
56
-
isLoading.value = false
57
57
-
}
58
58
-
}
59
59
-
60
60
-
async function toggleFollowRow(profile: AppBskyActorDefs.ProfileView) {
61
61
-
if (!auth.isAuthenticated || !auth.session) return
62
62
-
const rpc = auth.getRpc()
63
63
-
const original = profile.viewer?.following
64
64
-
65
65
-
profile.viewer = profile.viewer || {}
66
66
-
if (original) {
67
67
-
profile.viewer.following = undefined
68
68
-
} else {
69
69
-
profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary`
70
70
-
}
71
71
-
72
72
-
try {
73
73
-
if (original) {
74
74
-
const rkey = original.split('/').pop()!
75
75
-
await rpc.post('com.atproto.repo.deleteRecord', {
76
76
-
input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey },
77
77
-
})
78
78
-
} else {
79
79
-
const { data, ok } = await rpc.post('com.atproto.repo.createRecord', {
80
80
-
input: {
81
81
-
collection: 'app.bsky.graph.follow',
82
82
-
repo: auth.session.info.sub,
83
83
-
record: {
84
84
-
$type: 'app.bsky.graph.follow',
85
85
-
subject: profile.did,
86
86
-
createdAt: new Date().toISOString(),
87
87
-
},
88
88
-
},
89
89
-
})
90
90
-
if (ok)
91
91
-
profile.viewer
92
92
-
? (profile.viewer.following = data.uri)
93
93
-
: (profile.viewer = { following: data.uri })
94
94
-
}
95
95
-
} catch (e) {
96
96
-
console.error('toggleFollowRow failed', e)
97
97
-
profile.viewer!.following = original
98
98
-
}
99
99
-
}
100
100
-
101
101
-
const isMutual = (p: AppBskyActorDefs.ProfileView) =>
102
102
-
!!(p.viewer?.following && p.viewer?.followedBy)
103
103
-
104
104
-
const isFollowingRow = (p: AppBskyActorDefs.ProfileView) => !!p.viewer?.following
105
105
-
106
106
-
function followButtonLabel(p: AppBskyActorDefs.ProfileView) {
107
107
-
if (isMutual(p)) return 'Mutuals'
108
108
-
return isFollowingRow(p) ? 'Following' : 'Follow'
109
109
-
}
110
110
-
111
111
-
function setupObserver() {
112
112
-
if (!loadMoreTrigger.value) return
113
113
-
obs = new IntersectionObserver(
114
114
-
(entries) => {
115
115
-
for (const ent of entries) {
116
116
-
if (ent.isIntersecting && cursor.value && !isLoading.value) {
117
117
-
fetchFollows(false)
118
118
-
}
119
119
-
}
120
120
-
},
121
121
-
{ root: null, rootMargin: '200px', threshold: 0.1 },
122
122
-
)
123
123
-
obs.observe(loadMoreTrigger.value)
124
124
-
}
125
125
-
126
126
-
onMounted(async () => {
127
127
-
await fetchFollows(true)
128
128
-
setupObserver()
129
129
-
})
130
130
-
131
131
-
onBeforeUnmount(() => {
132
132
-
if (obs && loadMoreTrigger.value) obs.unobserve(loadMoreTrigger.value)
133
133
-
obs = null
134
134
-
})
135
135
-
136
136
-
watch(
137
137
-
() => props.id,
138
138
-
() => {
139
139
-
fetchFollows(true)
140
140
-
},
141
141
-
)
142
142
-
</script>
143
143
-
144
144
-
<template>
145
145
-
<PageLayout title="Following" no-padding>
146
146
-
<div class="follows-list" role="list">
147
147
-
<div v-if="isLoading && follows.length === 0" class="skeletons">
148
148
-
<SkeletonLoader v-for="n in 6" :key="n" width="100%" height="72px" />
149
149
-
</div>
150
150
-
151
151
-
<div v-else-if="isError" class="error-state">
152
152
-
<p>Failed to load follows - {{ isError }}</p>
153
153
-
<Button @click="fetchFollows(true)">Retry</Button>
154
154
-
</div>
155
155
-
156
156
-
<div v-else-if="isEmpty" class="empty-state">
157
157
-
<p>This user isn't following anyone yet.</p>
158
158
-
</div>
159
159
-
160
160
-
<AppLink
161
161
-
v-for="follow in follows"
162
162
-
:key="follow.did"
163
163
-
class="follow"
164
164
-
name="user-profile"
165
165
-
:params="{ id: follow.did }"
166
166
-
role="listitem"
167
167
-
>
168
168
-
<img
169
169
-
class="avatar"
170
170
-
:src="follow.avatar"
171
171
-
:alt="`${follow.displayName || follow.handle}'s avatar`"
172
172
-
loading="lazy"
173
173
-
/>
174
174
-
<div class="info">
175
175
-
<div class="top">
176
176
-
<div class="display-name">{{ follow.displayName || follow.handle }}</div>
177
177
-
<span class="handle">@{{ follow.handle }}</span>
178
178
-
<span v-if="follow.pronouns" class="dot" aria-hidden="true">·</span>
179
179
-
<span v-if="follow.pronouns" class="pronouns">{{ follow.pronouns }}</span>
180
180
-
</div>
181
181
-
<div class="meta">
182
182
-
<span v-if="follow.description" class="bio">{{ follow.description }}</span>
183
183
-
</div>
184
184
-
</div>
185
185
-
186
186
-
<button
187
187
-
class="follow-btn"
188
188
-
:class="{ mutual: isMutual(follow), following: isFollowingRow(follow) }"
189
189
-
@click.stop.prevent="toggleFollowRow(follow)"
190
190
-
:aria-pressed="!!follow.viewer?.following"
191
191
-
:title="
192
192
-
isMutual(follow)
193
193
-
? 'Mutuals - click to unfollow'
194
194
-
: follow.viewer?.following
195
195
-
? 'Unfollow'
196
196
-
: 'Follow'
197
197
-
"
198
198
-
>
199
199
-
{{ followButtonLabel(follow) }}
200
200
-
</button>
201
201
-
</AppLink>
202
202
-
203
203
-
<div ref="loadMoreTrigger" class="load-more__sentinel" />
204
204
-
<div class="load-more__fallback" v-if="cursor && !isLoading">
205
205
-
<Button variant="ghost" @click="fetchFollows(false)">Load more</Button>
206
206
-
</div>
207
207
-
<div v-if="isLoading && follows.length > 0" class="loading-more">
208
208
-
<SkeletonLoader width="100%" height="72px" />
209
209
-
</div>
210
210
-
</div>
211
211
-
</PageLayout>
212
212
-
</template>
213
213
-
214
214
-
<style scoped lang="scss">
215
215
-
.follows-list {
216
216
-
/* padding: 1rem; */
217
217
-
display: flex;
218
218
-
flex-direction: column;
219
219
-
/* gap: 0.5rem; */
220
220
-
min-height: 200px;
221
221
-
}
222
222
-
223
223
-
.follow {
224
224
-
display: flex;
225
225
-
align-items: center;
226
226
-
gap: 0.75rem;
227
227
-
padding: 0.75rem;
228
228
-
/* border-radius: 0.75rem; */
229
229
-
/* background: hsla(var(--base) / 0.6); */
230
230
-
border-bottom: 1px solid hsla(var(--surface2) / 0.35);
231
231
-
text-decoration: none;
232
232
-
color: inherit;
233
233
-
234
234
-
&:hover {
235
235
-
background: hsla(var(--surface0) / 0.2);
236
236
-
}
237
237
-
238
238
-
.avatar {
239
239
-
width: 3rem;
240
240
-
height: 3rem;
241
241
-
border-radius: 50%;
242
242
-
object-fit: cover;
243
243
-
flex: 0 0 auto;
244
244
-
}
245
245
-
246
246
-
.info {
247
247
-
flex: 1;
248
248
-
display: flex;
249
249
-
flex-direction: column;
250
250
-
overflow: hidden;
251
251
-
252
252
-
.top {
253
253
-
display: flex;
254
254
-
align-items: center;
255
255
-
gap: 0.5rem;
256
256
-
min-width: 0;
257
257
-
font-size: 0.9rem;
258
258
-
259
259
-
.display-name {
260
260
-
font-weight: 700;
261
261
-
white-space: nowrap;
262
262
-
text-overflow: ellipsis;
263
263
-
overflow: hidden;
264
264
-
}
265
265
-
266
266
-
.handle {
267
267
-
color: hsl(var(--subtext1));
268
268
-
font-size: 0.9rem;
269
269
-
}
270
270
-
271
271
-
.pronouns {
272
272
-
color: hsl(var(--subtext0));
273
273
-
white-space: nowrap;
274
274
-
}
275
275
-
276
276
-
.badge {
277
277
-
margin-left: auto;
278
278
-
font-size: 0.72rem;
279
279
-
padding: 0.15rem 0.4rem;
280
280
-
border-radius: 999px;
281
281
-
background: hsla(var(--surface2) / 0.45);
282
282
-
color: hsl(var(--subtext1));
283
283
-
font-weight: 600;
284
284
-
}
285
285
-
286
286
-
.badge.mutual {
287
287
-
background: hsla(var(--accent) / 0.12);
288
288
-
color: hsl(var(--accent));
289
289
-
}
290
290
-
}
291
291
-
292
292
-
.meta {
293
293
-
color: hsl(var(--subtext0));
294
294
-
font-size: 0.88rem;
295
295
-
margin-top: 0.25rem;
296
296
-
overflow: hidden;
297
297
-
text-overflow: ellipsis;
298
298
-
white-space: nowrap;
299
299
-
}
300
300
-
}
301
301
-
302
302
-
.follow-btn {
303
303
-
flex: 0 0 auto;
304
304
-
padding: 0.35rem 0.75rem;
305
305
-
border-radius: 999px;
306
306
-
border: none;
307
307
-
cursor: pointer;
308
308
-
color: hsl(var(--crust));
309
309
-
font-weight: 700;
310
310
-
311
311
-
background: hsla(var(--accent) / 0.85);
312
312
-
313
313
-
&:hover {
314
314
-
background: hsla(var(--accent) / 1);
315
315
-
}
316
316
-
&:active {
317
317
-
background: hsla(var(--accent) / 0.75);
318
318
-
}
319
319
-
320
320
-
&.following {
321
321
-
background: transparent;
322
322
-
border: 1px solid hsla(var(--surface2) / 0.75);
323
323
-
color: hsl(var(--text));
324
324
-
&:hover {
325
325
-
background: hsla(var(--surface0) / 0.9);
326
326
-
}
327
327
-
&:active {
328
328
-
background: hsla(var(--surface0) / 0.5);
329
329
-
}
330
330
-
}
331
331
-
/* &.mutual {
332
332
-
background: hsla(var(--accent) / 0.85);
333
333
-
color: hsl(var(--base));
334
334
-
border: 1px solid hsla(var(--surface2) / 0.45);
335
335
-
&:hover {
336
336
-
background: hsla(var(--accent) / 1);
337
337
-
}
338
338
-
&:active {
339
339
-
background: hsla(var(--accent) / 0.6);
340
340
-
}
341
341
-
} */
342
342
-
}
343
343
-
}
344
344
-
345
345
-
.empty-state,
346
346
-
.error-state {
347
347
-
padding: 2rem;
348
348
-
display: flex;
349
349
-
flex-direction: column;
350
350
-
align-items: center;
351
351
-
gap: 0.75rem;
352
352
-
color: hsl(var(--subtext0));
353
353
-
}
354
354
-
355
355
-
.load_more {
356
356
-
&__sentinel {
357
357
-
height: 1px;
358
358
-
359
359
-
&__fallback {
360
360
-
display: flex;
361
361
-
justify-content: center;
362
362
-
padding: 1rem;
363
363
-
margin-top: 1rem;
364
364
-
margin-bottom: 8rem;
365
365
-
}
366
366
-
}
367
367
-
}
368
368
-
</style>