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 following/followers pages; new auth page; i forgot
vt3e.cat
1 month ago
ab334541
8c9868d0
verified
This commit was signed with the committer's
known signature
.
vt3e.cat
SSH Key Fingerprint:
SHA256:MaVgF6bXxDdD131G4rXizPh+sttp3IVsdPrj48HV0X0=
+1543
-389
26 changed files
expand all
collapse all
unified
split
.zed
settings.json
android
app
capacitor.build.gradle
capacitor.settings.gradle
bun.lock
package.json
src
assets
main.css
components
Feed
FeedItem.vue
Navigation
BackButton.vue
NavItem.vue
PageLayout.vue
TabStack.vue
Profile
ProfileRow.vue
UI
BaseButton.vue
ListItem.vue
composables
useFollowToggle.ts
useInfiniteScroll.ts
usePagedProfiles.ts
router
index.ts
stores
auth.ts
utils
haptics.ts
views
Auth
LoginPage.vue
Profile
FollowersView.vue
FollowingView.vue
FollowsView.vue
ProfileView.vue
SettingsPage.vue
+1
-1
.zed/settings.json
···
1
1
{
2
2
-
"hard_tabs": false
2
2
+
"hard_tabs": true,
3
3
}
+1
-1
android/app/capacitor.build.gradle
···
10
10
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
11
11
dependencies {
12
12
implementation project(':capacitor-app')
13
13
-
13
13
+
implementation project(':capacitor-haptics')
14
14
}
15
15
16
16
+3
android/capacitor.settings.gradle
···
4
4
5
5
include ':capacitor-app'
6
6
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
7
7
+
8
8
+
include ':capacitor-haptics'
9
9
+
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
+3
bun.lock
···
13
13
"@capacitor/android": "^8.0.0",
14
14
"@capacitor/app": "^8.0.0",
15
15
"@capacitor/core": "^8.0.0",
16
16
+
"@capacitor/haptics": "^8.0.0",
16
17
"@iconify-prerendered/vue-material-symbols": "^0.28.1755063979",
17
18
"add": "^2.0.6",
18
19
"android": "^0.0.8",
···
139
140
"@capacitor/cli": ["@capacitor/cli@8.0.0", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.1", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^6.1.11", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-v9hEBi69xGxuuZhg55N031bMEenKaPSv71Il8C22VOOH6surDyv/MPeImN0oVfFc7eiklaW3rDFYVz6cmXfJWQ=="],
140
141
141
142
"@capacitor/core": ["@capacitor/core@8.0.0", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-250HTVd/W/KdMygoqaedisvNbHbpbQTN2Hy/8ZYGm1nAqE0Fx7sGss4l0nDg33STxEdDhtVRoL2fIaaiukKseA=="],
143
143
+
144
144
+
"@capacitor/haptics": ["@capacitor/haptics@8.0.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-DY1IUOjke1T4ITl7mFHQIKCaJJyHYAYRYHG9bVApU7PDOZiMVGMp48Yjzdqjya+wv/AHS5mDabSTUmhJ5uDvBA=="],
142
145
143
146
"@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
144
147
+1
package.json
···
27
27
"@capacitor/android": "^8.0.0",
28
28
"@capacitor/app": "^8.0.0",
29
29
"@capacitor/core": "^8.0.0",
30
30
+
"@capacitor/haptics": "^8.0.0",
30
31
"@iconify-prerendered/vue-material-symbols": "^0.28.1755063979",
31
32
"add": "^2.0.6",
32
33
"android": "^0.0.8",
+6
src/assets/main.css
···
180
180
.mb-4 {
181
181
margin-bottom: var(--space-4);
182
182
}
183
183
+
184
184
+
.dot {
185
185
+
user-select: none;
186
186
+
color: hsl(var(--surface2));
187
187
+
font-weight: 900;
188
188
+
}
+16
-8
src/components/Feed/FeedItem.vue
···
17
17
import EmbedRecord from './Embeds/EmbedRecord.vue'
18
18
import ExternalEmbed from './Embeds/ExternalEmbed.vue'
19
19
import VideoEmbed from './Embeds/VideoEmbed.vue'
20
20
+
import { tap } from '@/utils/haptics'
20
21
21
22
type PostInput = AppBskyFeedDefs.PostView | AppBskyEmbedRecord.ViewRecord
22
23
···
78
79
}
79
80
80
81
const handleLike = () => {
81
81
-
if (displayPost.value && !props.embedded) postStore.toggleLike(displayPost.value)
82
82
+
if (displayPost.value && !props.embedded) {
83
83
+
postStore.toggleLike(displayPost.value)
84
84
+
tap()
85
85
+
}
82
86
}
83
87
84
88
const handleRepost = () => {
85
85
-
if (displayPost.value && !props.embedded) postStore.toggleRepost(displayPost.value)
89
89
+
if (displayPost.value && !props.embedded) {
90
90
+
postStore.toggleRepost(displayPost.value)
91
91
+
tap()
92
92
+
}
86
93
}
87
94
88
95
const handleClick = (e: MouseEvent) => {
···
145
152
<div v-if="item?.reason?.$type === 'app.bsky.feed.defs#reasonRepost'" class="repost-indicator">
146
153
<IconRefreshRounded class="repost-icon" />
147
154
<span>Reposted by {{ item.reason.by.displayName || item.reason.by.handle }}</span>
155
155
+
<span class="repost-indicator__time"> · {{ formatTime(item.reason.indexedAt) }} </span>
148
156
</div>
149
157
150
158
<div class="post-layout">
···
164
172
displayPost.author.displayName || displayPost.author.handle
165
173
}}</span>
166
174
<span class="handle">@{{ displayPost.author.handle }}</span>
175
175
+
<template v-if="displayPost.author.pronouns">
176
176
+
<span class="dot" aria-hidden="true">·</span>
177
177
+
<span class="pronouns">{{ displayPost.author.pronouns }}</span>
178
178
+
</template>
167
179
<span class="dot" aria-hidden="true">·</span>
168
180
<span class="time">{{ formatTime(displayPost.indexedAt) }}</span>
169
181
</div>
···
363
375
font-weight: 400;
364
376
}
365
377
366
366
-
.dot {
367
367
-
user-select: none;
368
368
-
color: hsl(var(--surface2));
369
369
-
}
370
370
-
371
371
-
.time {
378
378
+
.time,
379
379
+
.pronouns {
372
380
color: hsl(var(--subtext0));
373
381
font-size: 0.85rem;
374
382
flex-shrink: 0;
+2
-2
src/components/Navigation/BackButton.vue
···
77
77
padding: 0.5rem;
78
78
79
79
&:hover {
80
80
-
background: hsla(var(--surface1) / 1);
80
80
+
background: hsla(var(--surface0) / 1);
81
81
}
82
82
&:active {
83
83
-
background: hsla(var(--surface2) / 1);
83
83
+
background: hsla(var(--surface0) / 0.5);
84
84
}
85
85
86
86
.icon {
+2
src/components/Navigation/NavItem.vue
···
1
1
<script lang="ts" setup>
2
2
import { useNavigationStore } from '@/stores/navigation'
3
3
import type { Page, PageNames } from '@/router'
4
4
+
import { tap } from '@/utils/haptics'
4
5
5
6
const nav = useNavigationStore()
6
7
defineProps<{ item: Page }>()
···
9
10
const currentTab = nav.activeTab
10
11
if (currentTab === tab) nav.resetTab(tab)
11
12
else nav.switchTab(tab as PageNames)
13
13
+
tap()
12
14
}
13
15
14
16
const handleKeydown = (event: KeyboardEvent, tab: string) => {
+1
-1
src/components/Navigation/PageLayout.vue
···
104
104
-webkit-overflow-scrolling: touch;
105
105
height: 100%;
106
106
overflow-y: scroll;
107
107
-
padding-top: calc(var(--inset-top, 0) + 3.5rem);
107
107
+
padding-top: calc(var(--inset-top, 0) + 4.5rem);
108
108
109
109
display: flex;
110
110
flex-direction: column;
+57
-68
src/components/Navigation/TabStack.vue
···
1
1
<script lang="ts" setup>
2
2
-
import { computed, ref, watch, defineAsyncComponent } from "vue";
3
3
-
import { useNavigationStore } from "@/stores/navigation";
4
4
-
import { pages, type StackRootNames } from "@/router";
5
5
-
import { useEnvironmentStore } from "@/stores/environment";
2
2
+
import { computed, ref, watch, defineAsyncComponent } from 'vue'
3
3
+
import { useNavigationStore } from '@/stores/navigation'
4
4
+
import { pages, type StackRootNames } from '@/router'
5
5
+
import { useEnvironmentStore } from '@/stores/environment'
6
6
7
7
-
const props = defineProps<{ tab: StackRootNames }>();
8
8
-
const nav = useNavigationStore();
9
9
-
const env = useEnvironmentStore();
7
7
+
const props = defineProps<{ tab: StackRootNames }>()
8
8
+
const nav = useNavigationStore()
9
9
+
const env = useEnvironmentStore()
10
10
11
11
-
const stack = computed(() => nav.stacks[props.tab]);
11
11
+
const stack = computed(() => nav.stacks[props.tab])
12
12
13
13
+
// TODO)) rm the `any`s
13
14
const registry: Record<string, any> = pages.reduce(
14
15
(acc, page) => {
15
15
-
const comp = page.component;
16
16
+
const comp = page.component
16
17
acc[page.name] =
17
17
-
typeof comp === "function"
18
18
+
typeof comp === 'function'
18
19
? defineAsyncComponent({
19
20
loader: comp as unknown as () => Promise<any>,
20
21
})
21
21
-
: comp;
22
22
-
return acc;
22
22
+
: comp
23
23
+
return acc
23
24
},
24
25
{} as Record<string, any>,
25
25
-
);
26
26
+
)
26
27
27
27
-
const isAnimating = ref(false);
28
28
-
const animationType = ref<"push" | "pop" | null>(null);
29
29
-
const previousStackLength = ref(stack.value?.length || 0);
28
28
+
const isAnimating = ref(false)
29
29
+
const animationType = ref<'push' | 'pop' | null>(null)
30
30
+
const previousStackLength = ref(stack.value?.length || 0)
30
31
31
32
const visualTopIndex = computed(() => {
32
32
-
if (nav.pendingPop?.tab === props.tab)
33
33
-
return stack.value?.length ? stack.value.length - 2 : 0;
34
34
-
return stack.value?.length ? stack.value.length - 1 : 0;
35
35
-
});
33
33
+
if (nav.pendingPop?.tab === props.tab) return stack.value?.length ? stack.value.length - 2 : 0
34
34
+
return stack.value?.length ? stack.value.length - 1 : 0
35
35
+
})
36
36
37
37
const shouldAnimate = computed(() => {
38
38
-
return env.isMobile && !env.prefersReducedMotion;
39
39
-
});
38
38
+
return env.isMobile && !env.prefersReducedMotion
39
39
+
})
40
40
41
41
watch(
42
42
() => stack.value?.length,
43
43
(newLength, oldLength) => {
44
44
-
if (!newLength || !oldLength) return;
44
44
+
if (!newLength || !oldLength) return
45
45
46
46
if (nav.activeTab !== props.tab) {
47
47
-
previousStackLength.value = newLength;
48
48
-
return;
47
47
+
previousStackLength.value = newLength
48
48
+
return
49
49
}
50
50
51
51
if (newLength > oldLength && shouldAnimate.value) {
52
52
-
animationType.value = "push";
53
53
-
isAnimating.value = true;
52
52
+
animationType.value = 'push'
53
53
+
isAnimating.value = true
54
54
55
55
setTimeout(() => {
56
56
-
isAnimating.value = false;
57
57
-
animationType.value = null;
58
58
-
}, 300);
56
56
+
isAnimating.value = false
57
57
+
animationType.value = null
58
58
+
}, 300)
59
59
}
60
60
61
61
-
previousStackLength.value = newLength;
61
61
+
previousStackLength.value = newLength
62
62
},
63
63
-
);
63
63
+
)
64
64
65
65
watch(
66
66
() => nav.pendingPop,
67
67
(pendingPop) => {
68
68
-
if (
69
69
-
!pendingPop ||
70
70
-
pendingPop.tab !== props.tab ||
71
71
-
nav.activeTab !== props.tab
72
72
-
) {
73
73
-
return;
68
68
+
if (!pendingPop || pendingPop.tab !== props.tab || nav.activeTab !== props.tab) {
69
69
+
return
74
70
}
75
71
76
72
if (!shouldAnimate.value) {
77
77
-
nav.completePop();
78
78
-
return;
73
73
+
nav.completePop()
74
74
+
return
79
75
}
80
76
81
81
-
animationType.value = "pop";
82
82
-
isAnimating.value = true;
77
77
+
animationType.value = 'pop'
78
78
+
isAnimating.value = true
83
79
84
80
setTimeout(() => {
85
85
-
isAnimating.value = false;
86
86
-
animationType.value = null;
87
87
-
nav.completePop();
88
88
-
}, 300);
81
81
+
isAnimating.value = false
82
82
+
animationType.value = null
83
83
+
nav.completePop()
84
84
+
}, 300)
89
85
},
90
86
{ immediate: true },
91
91
-
);
87
87
+
)
92
88
</script>
93
89
94
90
<template>
···
105
101
v-for="(entry, index) in stack"
106
102
:key="entry.id"
107
103
:class="[
108
108
-
'stack-page',
109
109
-
{
110
110
-
'is-visible':
111
111
-
index === stack.length - 1 ||
112
112
-
index === visualTopIndex ||
113
113
-
index === visualTopIndex - 1,
104
104
+
'stack-page',
105
105
+
{
106
106
+
'is-visible':
107
107
+
index === stack.length - 1 ||
108
108
+
index === visualTopIndex ||
109
109
+
index === visualTopIndex - 1,
114
110
115
115
-
'is-visual-top': index === visualTopIndex,
116
116
-
'is-below-visual-top': index === visualTopIndex - 1,
117
117
-
'is-animating': isAnimating,
118
118
-
'push-enter':
119
119
-
isAnimating &&
120
120
-
animationType === 'push' &&
121
121
-
index === stack.length - 1,
122
122
-
'pop-exit':
123
123
-
isAnimating &&
124
124
-
animationType === 'pop' &&
125
125
-
index === stack.length - 1,
126
126
-
},
127
127
-
128
128
-
]"
111
111
+
'is-visual-top': index === visualTopIndex,
112
112
+
'is-below-visual-top': index === visualTopIndex - 1,
113
113
+
'is-animating': isAnimating,
114
114
+
'push-enter': isAnimating && animationType === 'push' && index === stack.length - 1,
115
115
+
'pop-exit': isAnimating && animationType === 'pop' && index === stack.length - 1,
116
116
+
},
117
117
+
]"
129
118
:data-entry-id="entry.id"
130
119
:data-index="index"
131
120
:aria-hidden="index !== visualTopIndex"
···
133
122
>
134
123
<Suspense>
135
124
<template #default>
136
136
-
<component :is="registry[entry.page]" v-bind="entry.props"/>
125
125
+
<component :is="registry[entry.page]" v-bind="entry.props" :routeName="entry.page" />
137
126
</template>
138
127
<template #fallback>
139
128
<div class="page-loading" aria-hidden="true"></div>
+205
src/components/Profile/ProfileRow.vue
···
1
1
+
<script setup lang="ts">
2
2
+
import { computed } from 'vue'
3
3
+
import type { AppBskyActorDefs } from '@atcute/bluesky'
4
4
+
import { useFollowToggle } from '@/composables/useFollowToggle'
5
5
+
6
6
+
const props = defineProps<{ profile: AppBskyActorDefs.ProfileView }>()
7
7
+
const emit = defineEmits(['toggled'])
8
8
+
const { toggle } = useFollowToggle()
9
9
+
10
10
+
import AppLink from '../Navigation/AppLink.vue'
11
11
+
12
12
+
const isFollowing = computed(() => !!props.profile.viewer?.following)
13
13
+
const isMutual = computed(
14
14
+
() => !!(props.profile.viewer?.following && props.profile.viewer?.followedBy),
15
15
+
)
16
16
+
const label = computed(() => {
17
17
+
if (isMutual.value) return 'Mutuals'
18
18
+
return isFollowing.value ? 'Following' : 'Follow'
19
19
+
})
20
20
+
21
21
+
async function onClick(e: Event) {
22
22
+
e.stopPropagation()
23
23
+
try {
24
24
+
await toggle(props.profile)
25
25
+
emit('toggled', props.profile)
26
26
+
} catch (err) {
27
27
+
console.error(err)
28
28
+
}
29
29
+
}
30
30
+
</script>
31
31
+
32
32
+
<template>
33
33
+
<AppLink
34
34
+
:key="profile.did"
35
35
+
:class="{
36
36
+
follow: true,
37
37
+
blocked: profile.viewer?.blockedBy,
38
38
+
blocking: profile.viewer?.blocking,
39
39
+
}"
40
40
+
name="user-profile"
41
41
+
:params="{ id: profile.did }"
42
42
+
role="listitem"
43
43
+
>
44
44
+
<img
45
45
+
class="avatar"
46
46
+
:src="profile.avatar"
47
47
+
:alt="`${profile.displayName || profile.handle}'s avatar`"
48
48
+
loading="lazy"
49
49
+
/>
50
50
+
<div class="info">
51
51
+
<div class="top">
52
52
+
<div class="display-name">{{ profile.displayName || profile.handle }}</div>
53
53
+
<span class="handle">@{{ profile.handle }}</span>
54
54
+
<span v-if="profile.pronouns" class="dot" aria-hidden="true">·</span>
55
55
+
<span v-if="profile.pronouns" class="pronouns">{{ profile.pronouns }}</span>
56
56
+
</div>
57
57
+
<div class="meta">
58
58
+
<span v-if="profile.description" class="bio">{{ profile.description }}</span>
59
59
+
</div>
60
60
+
</div>
61
61
+
62
62
+
<button
63
63
+
class="follow-btn"
64
64
+
:class="{ mutual: isMutual, following: isFollowing }"
65
65
+
@click.stop.prevent="onClick"
66
66
+
:aria-pressed="!!profile.viewer?.following"
67
67
+
:title="
68
68
+
isMutual ? 'Mutuals - click to unfollow' : profile.viewer?.following ? 'Unfollow' : 'Follow'
69
69
+
"
70
70
+
>
71
71
+
{{ label }}
72
72
+
</button>
73
73
+
</AppLink>
74
74
+
</template>
75
75
+
76
76
+
<style lang="scss" scoped>
77
77
+
.follow {
78
78
+
display: flex;
79
79
+
align-items: center;
80
80
+
gap: 0.75rem;
81
81
+
padding: 0.75rem;
82
82
+
/* border-radius: 0.75rem; */
83
83
+
/* background: hsla(var(--base) / 0.6); */
84
84
+
border-bottom: 1px solid hsla(var(--surface2) / 0.35);
85
85
+
text-decoration: none;
86
86
+
color: inherit;
87
87
+
88
88
+
&:hover {
89
89
+
background: hsla(var(--surface0) / 0.2);
90
90
+
}
91
91
+
92
92
+
&.blocked {
93
93
+
filter: brightness(0.75);
94
94
+
}
95
95
+
&.blocking {
96
96
+
filter: brightness(0.75);
97
97
+
}
98
98
+
99
99
+
.avatar {
100
100
+
width: 3rem;
101
101
+
height: 3rem;
102
102
+
border-radius: 50%;
103
103
+
object-fit: cover;
104
104
+
flex: 0 0 auto;
105
105
+
}
106
106
+
107
107
+
.info {
108
108
+
flex: 1;
109
109
+
display: flex;
110
110
+
flex-direction: column;
111
111
+
overflow: hidden;
112
112
+
113
113
+
.top {
114
114
+
display: flex;
115
115
+
align-items: center;
116
116
+
gap: 0.5rem;
117
117
+
min-width: 0;
118
118
+
font-size: 0.9rem;
119
119
+
120
120
+
.display-name {
121
121
+
font-weight: 700;
122
122
+
white-space: nowrap;
123
123
+
text-overflow: ellipsis;
124
124
+
overflow: hidden;
125
125
+
}
126
126
+
127
127
+
.handle {
128
128
+
color: hsl(var(--subtext1));
129
129
+
font-size: 0.9rem;
130
130
+
}
131
131
+
132
132
+
.pronouns {
133
133
+
color: hsl(var(--subtext0));
134
134
+
white-space: nowrap;
135
135
+
}
136
136
+
137
137
+
.badge {
138
138
+
margin-left: auto;
139
139
+
font-size: 0.72rem;
140
140
+
padding: 0.15rem 0.4rem;
141
141
+
border-radius: 999px;
142
142
+
background: hsla(var(--surface2) / 0.45);
143
143
+
color: hsl(var(--subtext1));
144
144
+
font-weight: 600;
145
145
+
}
146
146
+
147
147
+
.badge.mutual {
148
148
+
background: hsla(var(--accent) / 0.12);
149
149
+
color: hsl(var(--accent));
150
150
+
}
151
151
+
}
152
152
+
153
153
+
.meta {
154
154
+
color: hsl(var(--subtext0));
155
155
+
font-size: 0.88rem;
156
156
+
margin-top: 0.25rem;
157
157
+
overflow: hidden;
158
158
+
text-overflow: ellipsis;
159
159
+
white-space: nowrap;
160
160
+
}
161
161
+
}
162
162
+
163
163
+
.follow-btn {
164
164
+
flex: 0 0 auto;
165
165
+
padding: 0.35rem 0.75rem;
166
166
+
border-radius: 999px;
167
167
+
border: none;
168
168
+
cursor: pointer;
169
169
+
color: hsl(var(--crust));
170
170
+
font-weight: 700;
171
171
+
172
172
+
background: hsla(var(--accent) / 0.85);
173
173
+
174
174
+
&:hover {
175
175
+
background: hsla(var(--accent) / 1);
176
176
+
}
177
177
+
&:active {
178
178
+
background: hsla(var(--accent) / 0.75);
179
179
+
}
180
180
+
181
181
+
&.following {
182
182
+
background: transparent;
183
183
+
border: 1px solid hsla(var(--surface2) / 0.75);
184
184
+
color: hsl(var(--text));
185
185
+
&:hover {
186
186
+
background: hsla(var(--surface0) / 0.9);
187
187
+
}
188
188
+
&:active {
189
189
+
background: hsla(var(--surface0) / 0.5);
190
190
+
}
191
191
+
}
192
192
+
/* &.mutual {
193
193
+
background: hsla(var(--accent) / 0.85);
194
194
+
color: hsl(var(--base));
195
195
+
border: 1px solid hsla(var(--surface2) / 0.45);
196
196
+
&:hover {
197
197
+
background: hsla(var(--accent) / 1);
198
198
+
}
199
199
+
&:active {
200
200
+
background: hsla(var(--accent) / 0.6);
201
201
+
}
202
202
+
} */
203
203
+
}
204
204
+
}
205
205
+
</style>
+23
-8
src/components/UI/BaseButton.vue
···
1
1
<script setup lang="ts">
2
2
withDefaults(
3
3
defineProps<{
4
4
-
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'subtle-alt'
4
4
+
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle' | 'subtle-alt' | 'text'
5
5
size?: 'sm' | 'md' | 'lg'
6
6
icon?: boolean
7
7
block?: boolean
···
74
74
color: hsl(var(--text-colour));
75
75
border-color: hsla(var(--border-colour) / 0.2);
76
76
77
77
-
&:hover:not(:disabled) {
78
78
-
background-color: hsla(var(--bg-colour) / 1);
79
79
-
border-color: hsla(var(--border-colour) / 0.5);
80
80
-
}
77
77
+
&:not(.variant-text) {
78
78
+
&:hover:not(:disabled) {
79
79
+
background-color: hsla(var(--bg-colour) / 1);
80
80
+
border-color: hsla(var(--border-colour) / 0.5);
81
81
+
}
81
82
82
82
-
&:active:not(:disabled) {
83
83
-
background-color: hsla(var(--bg-colour) / 0.7);
84
84
-
border-color: hsla(var(--border-colour) / 0.5);
83
83
+
&:active:not(:disabled) {
84
84
+
background-color: hsla(var(--bg-colour) / 0.7);
85
85
+
border-color: hsla(var(--border-colour) / 0.5);
86
86
+
}
85
87
}
86
88
}
87
89
···
191
193
&:active:not(:disabled) {
192
194
background-color: hsla(var(--red) / 0.3);
193
195
border-color: hsla(var(--red) / 0.3);
196
196
+
}
197
197
+
}
198
198
+
199
199
+
.variant-text {
200
200
+
background-color: none;
201
201
+
color: hsl(var(--text-colour));
202
202
+
border-color: transparent;
203
203
+
204
204
+
&:hover {
205
205
+
color: hsla(var(--text) / 0.9);
206
206
+
}
207
207
+
&:active {
208
208
+
color: hsla(var(--text) / 0.75);
194
209
}
195
210
}
196
211
+114
-114
src/components/UI/ListItem.vue
···
4
4
import { IconChevronRightRounded } from '@iconify-prerendered/vue-material-symbols'
5
5
6
6
const props = defineProps<{
7
7
-
title?: string
8
8
-
subtitle?: string
9
9
-
chevron?: boolean
10
10
-
clickable?: boolean
11
11
-
danger?: boolean
7
7
+
title?: string
8
8
+
subtitle?: string
9
9
+
chevron?: boolean
10
10
+
clickable?: boolean
11
11
+
danger?: boolean
12
12
13
13
-
href?: string
14
14
-
to?: string | object
15
15
-
target?: string
13
13
+
href?: string
14
14
+
to?: string | object
15
15
+
target?: string
16
16
+
disabled?: boolean
16
17
}>()
17
18
18
19
const emit = defineEmits<{
19
19
-
(e: 'click', event: MouseEvent): void
20
20
+
(e: 'click', event: MouseEvent): void
20
21
}>()
21
22
22
23
const isLink = computed(() => !!props.href || !!props.to)
23
24
const isInteractive = computed(() => props.clickable || isLink.value)
24
25
25
26
const componentType = computed(() => {
26
26
-
if (props.to) return AppLink
27
27
-
if (props.href) return 'a'
28
28
-
return 'div'
27
27
+
if (props.to) return AppLink
28
28
+
if (props.href) return 'a'
29
29
+
return 'div'
29
30
})
30
31
31
32
function handleClick(e: MouseEvent) {
32
32
-
if (isInteractive.value) emit('click', e)
33
33
+
if (isInteractive.value) emit('click', e)
33
34
}
34
35
35
36
function handleKeydown(e: KeyboardEvent) {
36
36
-
if (!isInteractive.value) return
37
37
-
if (!isLink.value && (e.key === 'Enter' || e.key === ' ')) {
38
38
-
e.preventDefault()
39
39
-
emit('click', e as unknown as MouseEvent)
40
40
-
}
37
37
+
if (!isInteractive.value) return
38
38
+
if (!isLink.value && (e.key === 'Enter' || e.key === ' ')) {
39
39
+
e.preventDefault()
40
40
+
emit('click', e as unknown as MouseEvent)
41
41
+
}
41
42
}
42
43
</script>
43
44
44
45
<template>
45
45
-
<component
46
46
-
:is="componentType"
47
47
-
class="list-item"
48
48
-
:class="{ 'is-clickable': isInteractive, 'is-danger': danger }"
49
49
-
:href="href"
50
50
-
:to="to"
51
51
-
:target="target"
52
52
-
:tabindex="isInteractive ? 0 : -1"
53
53
-
:role="!isLink && isInteractive ? 'button' : undefined"
54
54
-
@click="handleClick"
55
55
-
@keydown="handleKeydown"
56
56
-
>
57
57
-
<div v-if="$slots.start" class="item-start">
58
58
-
<slot name="start" />
59
59
-
</div>
46
46
+
<component
47
47
+
:is="componentType"
48
48
+
class="list-item"
49
49
+
:class="{ 'is-clickable': isInteractive, 'is-danger': danger }"
50
50
+
:href="href"
51
51
+
:to="to"
52
52
+
:target="target"
53
53
+
:tabindex="isInteractive ? 0 : -1"
54
54
+
:role="!isLink && isInteractive ? 'button' : undefined"
55
55
+
@click="handleClick"
56
56
+
@keydown="handleKeydown"
57
57
+
>
58
58
+
<div v-if="$slots.start" class="item-start">
59
59
+
<slot name="start" />
60
60
+
</div>
60
61
61
61
-
<div class="item-content">
62
62
-
<div v-if="title" class="item-title">{{ title }}</div>
63
63
-
<div v-if="subtitle" class="item-subtitle">{{ subtitle }}</div>
64
64
-
<slot />
65
65
-
</div>
62
62
+
<div class="item-content">
63
63
+
<div v-if="title" class="item-title">{{ title }}</div>
64
64
+
<div v-if="subtitle" class="item-subtitle">{{ subtitle }}</div>
65
65
+
<slot />
66
66
+
</div>
66
67
67
67
-
<div v-if="$slots.end || chevron" class="item-end">
68
68
-
<slot name="end" />
69
69
-
<IconChevronRightRounded v-if="chevron" class="chevron" />
70
70
-
</div>
71
71
-
</component>
68
68
+
<div v-if="$slots.end || chevron" class="item-end">
69
69
+
<slot name="end" />
70
70
+
<IconChevronRightRounded v-if="chevron" class="chevron" />
71
71
+
</div>
72
72
+
</component>
72
73
</template>
73
74
74
75
<style scoped lang="scss">
75
76
.list-item {
76
76
-
position: relative;
77
77
-
display: flex;
78
78
-
align-items: center;
79
79
-
gap: var(--space-3);
80
80
-
padding: var(--space-4);
81
81
-
background: hsl(var(--surface0));
82
82
-
transition: background-color 0.2s ease;
83
83
-
text-decoration: none;
84
84
-
color: inherit;
85
85
-
min-height: 3.5rem;
86
86
-
outline: none;
77
77
+
position: relative;
78
78
+
display: flex;
79
79
+
align-items: center;
80
80
+
gap: var(--space-3);
81
81
+
padding: var(--space-4);
82
82
+
background: hsl(var(--surface0));
83
83
+
text-decoration: none;
84
84
+
color: inherit;
85
85
+
min-height: 3.5rem;
86
86
+
outline: none;
87
87
88
88
-
&::after {
89
89
-
content: '';
90
90
-
position: absolute;
91
91
-
bottom: 0;
92
92
-
right: 0;
93
93
-
left: 1rem;
94
94
-
height: 1px;
95
95
-
background-color: hsla(var(--surface2) / 0.5);
96
96
-
}
88
88
+
&::after {
89
89
+
content: '';
90
90
+
position: absolute;
91
91
+
bottom: 0;
92
92
+
right: 0;
93
93
+
left: 1rem;
94
94
+
height: 1px;
95
95
+
background-color: hsla(var(--surface2) / 0.5);
96
96
+
}
97
97
98
98
-
&:last-child::after {
99
99
-
display: none;
100
100
-
}
98
98
+
&:last-child::after {
99
99
+
display: none;
100
100
+
}
101
101
}
102
102
103
103
.is-clickable {
104
104
-
cursor: pointer;
105
105
-
&:hover {
106
106
-
background: hsl(var(--surface1));
107
107
-
}
108
108
-
&:active {
109
109
-
background: hsl(var(--surface2));
110
110
-
}
111
111
-
&:focus-visible {
112
112
-
z-index: 1;
113
113
-
background: hsl(var(--surface1));
114
114
-
box-shadow: inset 0 0 0 2px hsl(var(--accent));
115
115
-
}
104
104
+
cursor: pointer;
105
105
+
&:hover {
106
106
+
background: hsla(var(--surface1) / 1);
107
107
+
}
108
108
+
&:active {
109
109
+
background: hsla(var(--surface1) / 0.6);
110
110
+
}
111
111
+
&:focus-visible {
112
112
+
z-index: 1;
113
113
+
background: hsl(var(--surface1));
114
114
+
box-shadow: inset 0 0 0 2px hsl(var(--accent));
115
115
+
}
116
116
}
117
117
118
118
.is-danger {
119
119
-
.item-title,
120
120
-
.item-start,
121
121
-
.chevron {
122
122
-
color: hsl(var(--red));
123
123
-
}
119
119
+
.item-title,
120
120
+
.item-start,
121
121
+
.chevron {
122
122
+
color: hsl(var(--red));
123
123
+
}
124
124
}
125
125
126
126
.item-start {
127
127
-
display: flex;
128
128
-
align-items: center;
129
129
-
justify-content: center;
130
130
-
font-size: 1.5rem;
131
131
-
color: hsl(var(--accent));
127
127
+
display: flex;
128
128
+
align-items: center;
129
129
+
justify-content: center;
130
130
+
font-size: 1.5rem;
131
131
+
color: hsl(var(--accent));
132
132
133
133
-
svg {
134
134
-
display: block;
135
135
-
}
133
133
+
svg {
134
134
+
display: block;
135
135
+
}
136
136
}
137
137
138
138
.item-content {
139
139
-
flex: 1;
140
140
-
display: flex;
141
141
-
flex-direction: column;
142
142
-
justify-content: center;
143
143
-
min-width: 0;
144
144
-
gap: 2px;
139
139
+
flex: 1;
140
140
+
display: flex;
141
141
+
flex-direction: column;
142
142
+
justify-content: center;
143
143
+
min-width: 0;
144
144
+
gap: 2px;
145
145
}
146
146
147
147
.item-title {
148
148
-
font-weight: 600;
149
149
-
color: hsl(var(--text));
150
150
-
font-size: 1rem;
151
151
-
white-space: nowrap;
152
152
-
overflow: hidden;
153
153
-
text-overflow: ellipsis;
148
148
+
font-weight: 600;
149
149
+
color: hsl(var(--text));
150
150
+
font-size: 1rem;
151
151
+
white-space: nowrap;
152
152
+
overflow: hidden;
153
153
+
text-overflow: ellipsis;
154
154
}
155
155
156
156
.item-subtitle {
157
157
-
font-size: 0.8rem;
158
158
-
color: hsl(var(--subtext0));
159
159
-
line-height: 1.3;
157
157
+
font-size: 0.8rem;
158
158
+
color: hsl(var(--subtext0));
159
159
+
line-height: 1.3;
160
160
}
161
161
162
162
.item-end {
163
163
-
display: flex;
164
164
-
align-items: center;
165
165
-
gap: var(--space-2);
166
166
-
color: hsl(var(--subtext1));
167
167
-
font-size: 0.9rem;
168
168
-
font-weight: 500;
163
163
+
display: flex;
164
164
+
align-items: center;
165
165
+
gap: var(--space-2);
166
166
+
color: hsl(var(--subtext1));
167
167
+
font-size: 0.9rem;
168
168
+
font-weight: 500;
169
169
}
170
170
171
171
.chevron {
172
172
-
font-size: 1.25rem;
173
173
-
opacity: 0.4;
172
172
+
font-size: 1.25rem;
173
173
+
opacity: 0.4;
174
174
}
175
175
</style>
+42
src/composables/useFollowToggle.ts
···
1
1
+
import { useAuthStore } from '@/stores/auth'
2
2
+
import type { ViewerState } from '@atcute/bluesky/types/app/actor/defs'
3
3
+
4
4
+
export function useFollowToggle() {
5
5
+
const auth = useAuthStore()
6
6
+
async function toggle(profile: { did: string; viewer?: ViewerState }) {
7
7
+
if (!auth.isAuthenticated || !auth.session) return
8
8
+
const rpc = auth.getRpc()
9
9
+
const original = profile.viewer?.following
10
10
+
profile.viewer = profile.viewer || {}
11
11
+
if (original) profile.viewer.following = undefined
12
12
+
else profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary`
13
13
+
14
14
+
try {
15
15
+
if (original) {
16
16
+
const rkey = original.split('/').pop()!
17
17
+
await rpc.post('com.atproto.repo.deleteRecord', {
18
18
+
input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey },
19
19
+
})
20
20
+
} else {
21
21
+
const { data, ok } = await rpc.post('com.atproto.repo.createRecord', {
22
22
+
input: {
23
23
+
collection: 'app.bsky.graph.follow',
24
24
+
repo: auth.session.info.sub,
25
25
+
record: {
26
26
+
$type: 'app.bsky.graph.follow',
27
27
+
subject: profile.did,
28
28
+
createdAt: new Date().toISOString(),
29
29
+
},
30
30
+
},
31
31
+
})
32
32
+
if (ok) profile.viewer.following = data.uri
33
33
+
}
34
34
+
} catch (e) {
35
35
+
// revert
36
36
+
profile.viewer.following = original
37
37
+
throw e
38
38
+
}
39
39
+
}
40
40
+
41
41
+
return { toggle }
42
42
+
}
+21
src/composables/useInfiniteScroll.ts
···
1
1
+
import { onBeforeUnmount } from 'vue'
2
2
+
3
3
+
export function useInfiniteScroll(
4
4
+
sentinelRef: { value: HTMLElement | null },
5
5
+
onIntersect: () => void,
6
6
+
opts = { rootMargin: '200px', threshold: 0.1 },
7
7
+
) {
8
8
+
let obs: IntersectionObserver | null = null
9
9
+
const setup = () => {
10
10
+
if (!sentinelRef.value) return
11
11
+
obs = new IntersectionObserver((entries) => {
12
12
+
for (const e of entries) if (e.isIntersecting) onIntersect()
13
13
+
}, opts)
14
14
+
obs.observe(sentinelRef.value)
15
15
+
}
16
16
+
onBeforeUnmount(() => {
17
17
+
if (obs && sentinelRef.value) obs.unobserve(sentinelRef.value)
18
18
+
obs = null
19
19
+
})
20
20
+
return { setup }
21
21
+
}
+33
src/composables/usePagedProfiles.ts
···
1
1
+
import { shallowRef, ref } from 'vue'
2
2
+
import type { AppBskyActorDefs } from '@atcute/bluesky'
3
3
+
4
4
+
export function usePagedProfiles<T extends AppBskyActorDefs.ProfileView>() {
5
5
+
const items = shallowRef<T[]>([])
6
6
+
const cursor = ref<string | null>(null)
7
7
+
const loading = ref(false)
8
8
+
const error = ref<string | null>(null)
9
9
+
10
10
+
async function fetchPage(
11
11
+
fetcher: (cursor?: string | null) => Promise<{ items: T[]; cursor?: string | null }>,
12
12
+
reset = false,
13
13
+
) {
14
14
+
if (loading.value) return
15
15
+
loading.value = true
16
16
+
error.value = null
17
17
+
if (reset) {
18
18
+
items.value = []
19
19
+
cursor.value = null
20
20
+
}
21
21
+
try {
22
22
+
const res = await fetcher(cursor.value || undefined)
23
23
+
items.value.push(...res.items)
24
24
+
cursor.value = res.cursor ?? null
25
25
+
} catch (e) {
26
26
+
error.value = e instanceof Error ? e.message : String(e)
27
27
+
} finally {
28
28
+
loading.value = false
29
29
+
}
30
30
+
}
31
31
+
32
32
+
return { items, cursor, loading, error, fetchPage }
33
33
+
}
+23
-4
src/router/index.ts
···
12
12
path: string
13
13
component: Component | (() => Promise<Component>)
14
14
icon?: Component | (() => Promise<Component>)
15
15
+
defaultProps?: Record<string, unknown>
15
16
}
16
17
17
17
-
export const pages = [
18
18
+
export const devPages: Page[] = []
19
19
+
20
20
+
export const mainPages: Page[] = [
18
21
{
19
22
root: true,
20
23
label: 'Home',
···
43
46
label: 'User Profile',
44
47
name: 'user-profile',
45
48
path: '/profile/:id',
46
46
-
component: () => import('@/views/UserProfile.vue'),
49
49
+
component: () => import('@/views/Profile/ProfileView.vue'),
50
50
+
},
51
51
+
{
52
52
+
root: false,
53
53
+
label: 'Followers',
54
54
+
name: 'user-followers',
55
55
+
path: '/profile/:id/followers',
56
56
+
component: () => import('@/views/Profile/FollowsView.vue'),
57
57
+
},
58
58
+
{
59
59
+
root: false,
60
60
+
label: 'Follows',
61
61
+
name: 'user-follows',
62
62
+
path: '/profile/:id/follows',
63
63
+
component: () => import('@/views/Profile/FollowsView.vue'),
47
64
},
48
65
{
49
66
root: false,
···
74
91
path: '/profile/:identifier/post/:rkey',
75
92
component: () => import('@/views/Post/PostView.vue'),
76
93
},
77
77
-
] as const satisfies readonly Page[]
94
94
+
]
95
95
+
96
96
+
export const pages = [...mainPages, ...devPages] as const satisfies readonly Page[]
78
97
79
98
export const stackRoots = pages.filter((page) => page.root)
80
99
export type PageNames = (typeof pages)[number]['name']
···
131
150
url = url.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
132
151
if (props[key] !== undefined) {
133
152
usedKeys.add(key)
134
134
-
return encodeURIComponent(String(props[key]))
153
153
+
return String(props[key])
135
154
}
136
155
return ':' + key
137
156
})
+9
-1
src/stores/auth.ts
···
25
25
import _KEYS from '@/utils/keys'
26
26
const KEYS = _KEYS.AUTH
27
27
28
28
+
// TODO)) add as setting
29
29
+
export const BSKY_APPVIEW = 'did:web:api.bsky.app#bsky_appview'
30
30
+
28
31
export const useAuthStore = defineStore('auth', () => {
29
32
const session = ref<Session | null>(null)
30
33
const agent = ref<OAuthUserAgent | null>(null)
···
85
88
const data = await ok(
86
89
rpc.get('app.bsky.actor.getProfile', {
87
90
params: { actor: session.value.info.sub },
91
91
+
headers: {
92
92
+
'atproto-proxy': BSKY_APPVIEW,
93
93
+
},
88
94
}),
89
95
)
90
96
profile.value = data
···
93
99
}
94
100
}
95
101
96
96
-
async function login(input: string) {
102
102
+
async function login(input: string, createAccount = false) {
97
103
isLoading.value = true
98
104
error.value = null
99
105
···
105
111
authUrl = await createAuthorizationUrl({
106
112
target: { type: 'pds', serviceUrl: input },
107
113
scope,
114
114
+
// @ts-expect-error: craete will be available soon:tm:
115
115
+
prompt: createAccount ? 'create' : undefined,
108
116
})
109
117
} else {
110
118
try {
+67
src/utils/haptics.ts
···
1
1
+
/**
2
2
+
* this is v experimental.
3
3
+
* for the time being, since i cba to write a capacitor plugin for the system
4
4
+
* haptics api, we're just using the browser api.
5
5
+
*/
6
6
+
7
7
+
import {
8
8
+
Haptics,
9
9
+
type VibrateOptions,
10
10
+
type NotificationOptions,
11
11
+
type ImpactOptions,
12
12
+
ImpactStyle,
13
13
+
} from '@capacitor/haptics'
14
14
+
15
15
+
export function isVibrationsEnabled(): boolean {
16
16
+
return true
17
17
+
}
18
18
+
19
19
+
export function tap() {
20
20
+
Haptics.vibrate({ duration: 1 })
21
21
+
}
22
22
+
23
23
+
export async function impact(options?: ImpactOptions): Promise<void> {
24
24
+
if (!isVibrationsEnabled()) return
25
25
+
try {
26
26
+
await Haptics.impact(options || { style: ImpactStyle.Light })
27
27
+
} catch {}
28
28
+
}
29
29
+
30
30
+
export async function notification(options?: NotificationOptions): Promise<void> {
31
31
+
if (!isVibrationsEnabled()) return
32
32
+
try {
33
33
+
await Haptics.notification(options)
34
34
+
} catch {}
35
35
+
}
36
36
+
37
37
+
export async function vibrate(options?: VibrateOptions): Promise<void> {
38
38
+
if (!isVibrationsEnabled()) return
39
39
+
try {
40
40
+
await Haptics.vibrate(options)
41
41
+
} catch {}
42
42
+
}
43
43
+
44
44
+
async function selectionStart(): Promise<void> {
45
45
+
if (!isVibrationsEnabled()) return
46
46
+
try {
47
47
+
await Haptics.selectionStart()
48
48
+
} catch {}
49
49
+
}
50
50
+
async function selectionChanged(): Promise<void> {
51
51
+
if (!isVibrationsEnabled()) return
52
52
+
try {
53
53
+
await Haptics.selectionChanged()
54
54
+
} catch {}
55
55
+
}
56
56
+
async function selectionEnd(): Promise<void> {
57
57
+
if (!isVibrationsEnabled()) return
58
58
+
try {
59
59
+
await Haptics.selectionEnd()
60
60
+
} catch {}
61
61
+
}
62
62
+
63
63
+
export const selection = {
64
64
+
start: selectionStart,
65
65
+
changed: selectionChanged,
66
66
+
end: selectionEnd,
67
67
+
}
+200
-176
src/views/Auth/LoginPage.vue
···
1
1
<script setup lang="ts">
2
2
-
import { ref, watch } from 'vue'
3
3
-
import {
4
4
-
IconArrowForwardRounded,
5
5
-
IconOpenInNewRounded,
6
6
-
IconAlternateEmailRounded,
7
7
-
IconCloseRounded,
8
8
-
IconProgressActivity,
9
9
-
} from '@iconify-prerendered/vue-material-symbols'
10
10
-
import { simpleFetchHandler, Client, ok } from '@atcute/client'
2
2
+
import { ref, computed } from 'vue'
3
3
+
import { IconOpenInNewRounded } from '@iconify-prerendered/vue-material-symbols'
11
4
12
5
import { useAuthStore } from '@/stores/auth'
13
6
import PageLayout from '@/components/Navigation/PageLayout.vue'
14
7
import Button from '@/components/UI/BaseButton.vue'
15
8
import Modal from '@/components/UI/BaseModal.vue'
16
9
import TextInput from '@/components/UI/TextInput.vue'
17
17
-
import ListItem from '@/components/UI/ListItem.vue'
18
10
19
11
const auth = useAuthStore()
20
12
21
21
-
const handler = simpleFetchHandler({ service: 'https://public.api.bsky.app' })
22
22
-
const client = new Client({ handler })
23
23
-
24
24
-
const profilePicture = ref<string | null>(null)
25
25
-
const handle = ref('')
26
26
-
const showCreateAccount = ref(false)
27
27
-
const loading = ref(false)
28
28
-
const hasError = ref(false)
29
29
-
30
30
-
const handleSubmit = async () => {
31
31
-
if (!handle.value) return
32
32
-
await auth.login(handle.value)
13
13
+
type Provider = {
14
14
+
name: string
15
15
+
url: string
16
16
+
subtitle?: string
17
17
+
location: string
33
18
}
34
34
-
const pdsSubmit = async (pds: string) => {
35
35
-
await auth.login(pds)
36
36
-
}
37
37
-
38
38
-
let debounceTimer: ReturnType<typeof setTimeout>
39
39
-
40
40
-
watch(handle, (newHandle) => {
41
41
-
clearTimeout(debounceTimer)
42
42
-
hasError.value = false
43
43
-
loading.value = true
44
44
-
45
45
-
if (!newHandle) {
46
46
-
profilePicture.value = null
47
47
-
loading.value = false
48
48
-
return
49
49
-
}
50
50
-
51
51
-
debounceTimer = setTimeout(async () => {
52
52
-
try {
53
53
-
const cleanHandle = newHandle.startsWith('@') ? newHandle.slice(1) : newHandle
54
54
-
const data = ok(
55
55
-
await client.get('app.bsky.actor.getProfile', {
56
56
-
params: { actor: cleanHandle },
57
57
-
}),
58
58
-
)
59
59
-
60
60
-
profilePicture.value = data.avatar || null
61
61
-
hasError.value = false
62
62
-
} catch (error) {
63
63
-
profilePicture.value = null
64
64
-
hasError.value = true
65
65
-
console.error('Error fetching profile:', error)
66
66
-
} finally {
67
67
-
loading.value = false
68
68
-
}
69
69
-
}, 500)
70
70
-
})
71
71
-
72
72
-
const providerList: Array<{ name: string; subtitle?: string; url: string }> = [
73
73
-
{ name: 'Bluesky PBC', subtitle: 'bsky.social', url: 'https://bsky.social/' },
74
74
-
{ name: 'selfhosted.social', url: 'https://selfhosted.social/' },
19
19
+
const providerList: Provider[] = [
20
20
+
{ name: 'Bluesky PBC', subtitle: 'bsky.social', url: 'https://bsky.social/', location: 'US' },
21
21
+
{
22
22
+
name: 'selfhosted.social',
23
23
+
url: 'https://selfhosted.social/',
24
24
+
subtitle:
25
25
+
'A collection of hackers, designers, developers, ATProto enthusiasts, scrobblers, tinkerers, friends, and curious minds. A shared space where everyone is welcome to be themselves.',
26
26
+
location: 'US',
27
27
+
},
75
28
{
76
29
name: 'Tophhie Social',
77
30
subtitle: 'pds.tophhie.cloud',
78
31
url: 'https://pds.tophhie.cloud',
32
32
+
location: 'GB',
79
33
},
80
80
-
{ name: 'Blacksky', subtitle: 'A PDS for the black community.', url: 'https://blacksky.app/' },
81
81
-
{ name: 'Spark', subtitle: 'pds.sprk.so', url: 'https://pds.sprk.so' },
34
34
+
// {
35
35
+
// name: 'Zio',
36
36
+
// subtitle: 'zio.blue',
37
37
+
// url: 'https://zio.blue',
38
38
+
// location: 'Finland',
39
39
+
// },
40
40
+
{
41
41
+
name: 'peedee.es',
42
42
+
url: 'https://peedee.es',
43
43
+
location: 'Germany',
44
44
+
},
45
45
+
{
46
46
+
name: 'Blacksky',
47
47
+
subtitle: 'A PDS for the black community.',
48
48
+
url: 'https://blacksky.app/',
49
49
+
location: 'US',
50
50
+
},
51
51
+
{ name: 'Spark', subtitle: 'pds.sprk.so', url: 'https://pds.sprk.so', location: 'US' },
82
52
]
53
53
+
54
54
+
const isLoading = computed(() => auth.isLoading)
55
55
+
const showPdsModal = ref(false)
56
56
+
const pdsInput = ref('')
57
57
+
const pdsError = ref('')
58
58
+
59
59
+
async function pdsSubmit(pds: string, providerList = false) {
60
60
+
if (!pds || isLoading.value) return
61
61
+
await auth.login(pds, providerList)
62
62
+
}
63
63
+
64
64
+
function openPdsModal() {
65
65
+
pdsInput.value = ''
66
66
+
pdsError.value = ''
67
67
+
showPdsModal.value = true
68
68
+
}
69
69
+
70
70
+
async function submitPds() {
71
71
+
const val = pdsInput.value.trim()
72
72
+
if (!val) {
73
73
+
pdsError.value = 'Enter a PDS URL or handle (e.g. https://pds.example or awesome.cat)'
74
74
+
return
75
75
+
}
76
76
+
showPdsModal.value = false
77
77
+
await auth.login(val)
78
78
+
}
83
79
</script>
84
80
85
81
<template>
86
86
-
<PageLayout title="Login">
82
82
+
<PageLayout title="Sign in">
87
83
<div class="login-view">
88
88
-
<div class="header">
84
84
+
<header class="header">
89
85
<h1 class="title">Sign in</h1>
90
90
-
<p class="subtitle">Enter your AT Protocol handle to continue.</p>
91
91
-
</div>
86
86
+
<p class="subtitle">Pick a provider to sign in with.</p>
87
87
+
</header>
92
88
93
93
-
<form @submit.prevent="handleSubmit" class="form-stack">
94
94
-
<TextInput
95
95
-
v-model="handle"
96
96
-
placeholder="awesome.cat"
97
97
-
:error="auth.error || undefined"
98
98
-
@keydown.enter="handleSubmit"
99
99
-
autofocus
89
89
+
<div class="button-row compact">
90
90
+
<Button
91
91
+
variant="primary"
92
92
+
size="lg"
93
93
+
:block="true"
94
94
+
:loading="isLoading"
95
95
+
@click="pdsSubmit('https://bsky.social')"
100
96
>
101
101
-
<template #prefix>
102
102
-
<div v-if="loading" class="text-input-prefix spin">
103
103
-
<IconProgressActivity />
104
104
-
</div>
105
105
-
<div v-else-if="profilePicture" class="input-avatar">
106
106
-
<img :src="profilePicture" alt="Avatar" />
107
107
-
</div>
108
108
-
<div v-else class="text-input-prefix">
109
109
-
<IconCloseRounded v-if="hasError" style="color: hsl(var(--danger))" />
110
110
-
<IconAlternateEmailRounded v-else />
111
111
-
</div>
112
112
-
</template>
113
113
-
</TextInput>
97
97
+
<span class="btn-inner">
98
98
+
<span>Sign in with Bluesky</span>
99
99
+
</span>
100
100
+
</Button>
114
101
115
115
-
<div class="actions">
116
116
-
<Button type="button" variant="subtle-alt" @click="showCreateAccount = true">
117
117
-
Create account
118
118
-
</Button>
119
119
-
<Button type="submit" variant="primary" :loading="auth.isLoading" :disabled="!handle">
120
120
-
Next <IconArrowForwardRounded />
102
102
+
<div class="secondary-row">
103
103
+
<Button variant="text" @click="openPdsModal" :disabled="isLoading">
104
104
+
Sign in with AT Protocol
121
105
</Button>
122
106
</div>
123
123
-
</form>
107
107
+
</div>
108
108
+
109
109
+
<div class="provider-list">
110
110
+
<button
111
111
+
v-for="provider in providerList"
112
112
+
:key="provider.name"
113
113
+
class="list-item"
114
114
+
:disabled="isLoading"
115
115
+
@click="() => pdsSubmit(provider.url, true)"
116
116
+
>
117
117
+
<div class="content-grid">
118
118
+
<div class="top-row">
119
119
+
<span class="name">{{ provider.name }}</span>
120
120
+
<span class="location" v-if="provider.location">{{ provider.location }}</span>
121
121
+
</div>
122
122
+
123
123
+
<p class="subtitle">
124
124
+
{{ provider.subtitle || provider.url.replace('https://', '') }}
125
125
+
</p>
126
126
+
</div>
127
127
+
128
128
+
<div class="action">
129
129
+
<IconOpenInNewRounded />
130
130
+
</div>
131
131
+
</button>
132
132
+
</div>
133
133
+
134
134
+
<p v-if="auth.error" class="error-text">{{ auth.error }}</p>
124
135
</div>
125
136
126
126
-
<Modal v-model:open="showCreateAccount" title="Create Account">
127
127
-
<div class="create-account-content">
128
128
-
<p class="modal-text">
129
129
-
To use Bluebell, you need to create an Atmosphere account. Here are some open providers
130
130
-
where you can register.
131
131
-
</p>
137
137
+
<Modal v-model:open="showPdsModal" title="Sign in with PDS">
138
138
+
<div class="modal-body">
139
139
+
<label for="pds-input" class="sr-only">PDS URL or handle</label>
132
140
133
133
-
<div class="provider-list">
134
134
-
<ListItem
135
135
-
v-for="provider in providerList"
136
136
-
:key="provider.name"
137
137
-
:title="provider.name"
138
138
-
:subtitle="provider.subtitle"
139
139
-
@click="pdsSubmit(provider.url)"
140
140
-
clickable
141
141
-
:disabled="auth.isLoading"
142
142
-
>
143
143
-
<template #end><IconOpenInNewRounded /></template>
144
144
-
</ListItem>
145
145
-
</div>
141
141
+
<TextInput
142
142
+
id="pds-input"
143
143
+
v-model="pdsInput"
144
144
+
placeholder="https://pds.example or example.cat"
145
145
+
:error="pdsError"
146
146
+
inputmode="url"
147
147
+
aria-describedby="pds-error"
148
148
+
@keydown.enter.prevent="submitPds"
149
149
+
style="margin-top: 1rem"
150
150
+
/>
146
151
147
147
-
<p class="modal-subtext">
148
148
-
Make sure to to read the provider's terms of service and privacy policy before creating an
149
149
-
account.
150
150
-
</p>
152
152
+
<div id="pds-error" role="alert" v-if="pdsError">{{ pdsError }}</div>
151
153
</div>
154
154
+
152
155
<template #footer>
153
153
-
<Button variant="ghost" @click="showCreateAccount = false">Close</Button>
156
156
+
<Button variant="ghost" type="button" @click="showPdsModal = false">Cancel</Button>
157
157
+
<Button variant="primary" :loading="auth.isLoading" @click="submitPds" type="button"
158
158
+
>Sign in</Button
159
159
+
>
154
160
</template>
155
161
</Modal>
156
162
</PageLayout>
···
158
164
159
165
<style scoped lang="scss">
160
166
.login-view {
161
161
-
max-width: 400px;
162
167
width: 100%;
163
168
display: flex;
164
169
flex-direction: column;
165
170
gap: 1rem;
171
171
+
margin: 0 auto;
166
172
}
167
173
168
174
.header {
···
183
189
line-height: 1.5;
184
190
}
185
191
}
186
186
-
187
187
-
.form-stack {
192
192
+
.button-row {
188
193
display: flex;
189
194
flex-direction: column;
190
190
-
gap: 1rem;
195
195
+
gap: 0.5rem;
196
196
+
}
197
197
+
.btn-inner {
198
198
+
display: inline-flex;
199
199
+
align-items: center;
200
200
+
gap: 0.6rem;
201
201
+
}
202
202
+
.secondary-row {
203
203
+
display: flex;
204
204
+
justify-content: center;
205
205
+
}
191
206
192
192
-
.actions {
193
193
-
display: flex;
194
194
-
justify-content: flex-end;
195
195
-
gap: 1rem;
196
196
-
}
207
207
+
.provider-list {
208
208
+
border-radius: 1rem;
209
209
+
border: 1px solid hsla(var(--surface2) / 0.5);
210
210
+
overflow: hidden;
211
211
+
}
197
212
198
198
-
.text-input-prefix {
199
199
-
display: flex;
200
200
-
align-items: center;
213
213
+
.list-item {
214
214
+
width: 100%;
215
215
+
display: flex;
216
216
+
align-items: center;
217
217
+
gap: 1rem;
218
218
+
padding: 0.75rem 1rem;
219
219
+
text-align: left;
201
220
202
202
-
&.spin {
203
203
-
animation: spin 1s linear infinite;
204
204
-
color: hsl(var(--subtext0));
221
221
+
border-color: transparent;
222
222
+
border-bottom: 1px solid hsla(var(--surface2) / 0.5);
223
223
+
background: transparent;
224
224
+
cursor: pointer;
205
225
206
206
-
@keyframes spin {
207
207
-
from {
208
208
-
transform: rotate(0deg);
209
209
-
}
210
210
-
to {
211
211
-
transform: rotate(360deg);
212
212
-
}
213
213
-
}
214
214
-
}
226
226
+
&:hover {
227
227
+
background: hsla(var(--surface2) / 0.2);
215
228
}
216
229
217
217
-
.input-avatar {
218
218
-
display: flex;
219
219
-
align-items: center;
220
220
-
justify-content: center;
221
221
-
padding-left: 0.5rem;
222
222
-
padding-right: 0.5rem;
223
223
-
height: 100%;
230
230
+
&:focus-visible {
231
231
+
outline-color: transparent;
232
232
+
background-color: hsla(var(--accent) / 0.2);
233
233
+
}
224
234
225
225
-
img {
226
226
-
width: 2rem;
227
227
-
height: 2rem;
228
228
-
229
229
-
border-radius: 50%;
230
230
-
object-fit: cover;
231
231
-
background-color: hsl(var(--surface0));
232
232
-
border: 1px solid hsl(var(--surface2));
233
233
-
}
235
235
+
&:last-child {
236
236
+
border-bottom: none;
234
237
}
235
238
}
236
239
237
237
-
.create-account-content {
240
240
+
.content-grid {
241
241
+
flex: 1;
238
242
display: flex;
239
243
flex-direction: column;
240
240
-
gap: 1rem;
241
241
-
padding-top: 0.5rem;
244
244
+
gap: 0.2rem;
245
245
+
min-width: 0;
246
246
+
}
242
247
243
243
-
.modal-text {
248
248
+
.top-row {
249
249
+
display: flex;
250
250
+
align-items: center;
251
251
+
gap: 0.5rem;
252
252
+
253
253
+
.name {
254
254
+
font-weight: 600;
255
255
+
font-size: 0.95rem;
244
256
color: hsl(var(--text));
245
245
-
line-height: 1.5;
257
257
+
white-space: nowrap;
246
258
}
247
259
248
248
-
.modal-subtext {
249
249
-
font-size: 0.875rem;
250
250
-
color: hsl(var(--subtext0));
251
251
-
line-height: 1.4;
260
260
+
.location {
261
261
+
font-size: 0.7rem;
262
262
+
padding: 0.1rem 0.5rem;
263
263
+
border-radius: 1rem;
264
264
+
background: hsla(var(--accent) / 0.25);
265
265
+
color: hsl(var(--text));
252
266
}
267
267
+
}
253
268
254
254
-
.provider-list {
255
255
-
display: flex;
256
256
-
flex-direction: column;
257
257
-
border: 1px solid hsla(var(--surface2) / 0.5);
258
258
-
border-radius: var(--radius-lg);
259
259
-
overflow: hidden;
260
260
-
}
269
269
+
.subtitle {
270
270
+
font-size: 0.8rem;
271
271
+
color: hsl(var(--subtext0));
272
272
+
line-height: 1.4;
273
273
+
}
274
274
+
275
275
+
.action {
276
276
+
color: hsl(var(--subtext0));
277
277
+
display: flex;
278
278
+
align-items: center;
279
279
+
}
280
280
+
281
281
+
.error-text {
282
282
+
color: hsl(var(--red));
283
283
+
font-weight: 600;
284
284
+
margin-top: 0.5rem;
261
285
}
262
286
</style>
+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>
+111
src/views/Profile/FollowsView.vue
···
1
1
+
<script setup lang="ts">
2
2
+
import { computed, ref, onMounted } from 'vue'
3
3
+
import type { AppBskyActorDefs } from '@atcute/bluesky'
4
4
+
5
5
+
import { usePagedProfiles } from '@/composables/usePagedProfiles'
6
6
+
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
7
7
+
8
8
+
import ProfileRow from '@/components/Profile/ProfileRow.vue'
9
9
+
import PageLayout from '@/components/Navigation/PageLayout.vue'
10
10
+
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
11
11
+
import Button from '@/components/UI/BaseButton.vue'
12
12
+
13
13
+
import { useAuthStore, BSKY_APPVIEW } from '@/stores/auth'
14
14
+
import type { ActorIdentifier } from '@atcute/lexicons'
15
15
+
16
16
+
const props = defineProps<{
17
17
+
id?: string
18
18
+
routeName?: string
19
19
+
}>()
20
20
+
21
21
+
const mode = computed(() => {
22
22
+
if (props.routeName === 'user-followers') return 'followers'
23
23
+
if (props.routeName === 'user-follows') return 'follows'
24
24
+
return 'follows'
25
25
+
})
26
26
+
27
27
+
const auth = useAuthStore()
28
28
+
29
29
+
const {
30
30
+
items: follows,
31
31
+
cursor,
32
32
+
loading,
33
33
+
error,
34
34
+
fetchPage,
35
35
+
} = usePagedProfiles<AppBskyActorDefs.ProfileView>()
36
36
+
const sentinel = ref<HTMLElement | null>(null)
37
37
+
38
38
+
const fetcher = async (c?: string | null) => {
39
39
+
const rpc = auth.getRpc()
40
40
+
console.log(mode.value)
41
41
+
const endpoint =
42
42
+
mode.value === 'followers' ? 'app.bsky.graph.getFollowers' : 'app.bsky.graph.getFollows'
43
43
+
44
44
+
const { data, ok } = await rpc.get(endpoint, {
45
45
+
params: { actor: props.id as ActorIdentifier, limit: 50, cursor: c ?? undefined },
46
46
+
headers: { 'atproto-proxy': BSKY_APPVIEW },
47
47
+
})
48
48
+
if (!ok) throw new Error((data && data.error) || 'Failed')
49
49
+
50
50
+
console.log(props)
51
51
+
52
52
+
return {
53
53
+
// @ts-expect-error: its ok typescript. ..
54
54
+
items: mode.value === 'followers' ? data.followers || [] : data.follows || [],
55
55
+
cursor: data.cursor ?? null,
56
56
+
}
57
57
+
}
58
58
+
59
59
+
const { setup } = useInfiniteScroll(sentinel, () => {
60
60
+
if (cursor.value && !loading.value) fetchPage(fetcher, false)
61
61
+
})
62
62
+
63
63
+
onMounted(async () => {
64
64
+
await fetchPage(fetcher, true)
65
65
+
setup()
66
66
+
})
67
67
+
</script>
68
68
+
69
69
+
<template>
70
70
+
<PageLayout :title="mode === 'followers' ? 'Followers' : 'Following'" noPadding>
71
71
+
<div class="follows-list" role="list">
72
72
+
<template v-if="loading && follows.length === 0">
73
73
+
<SkeletonLoader v-for="n in 6" :key="n" />
74
74
+
</template>
75
75
+
<div v-else-if="error">
76
76
+
{{ error }} <Button @click="() => fetchPage(fetcher, true)">Retry</Button>
77
77
+
</div>
78
78
+
<ProfileRow v-for="profile in follows" :key="profile.did" :profile="profile" />
79
79
+
<div ref="sentinel" style="height: 1px"></div>
80
80
+
<div v-if="cursor && !loading">
81
81
+
<Button @click="() => fetchPage(fetcher, false)">Load more</Button>
82
82
+
</div>
83
83
+
</div>
84
84
+
</PageLayout>
85
85
+
</template>
86
86
+
87
87
+
<style lang="scss" scoped>
88
88
+
.empty-state,
89
89
+
.error-state {
90
90
+
padding: 2rem;
91
91
+
display: flex;
92
92
+
flex-direction: column;
93
93
+
align-items: center;
94
94
+
gap: 0.75rem;
95
95
+
color: hsl(var(--subtext0));
96
96
+
}
97
97
+
98
98
+
.load_more {
99
99
+
&__sentinel {
100
100
+
height: 1px;
101
101
+
102
102
+
&__fallback {
103
103
+
display: flex;
104
104
+
justify-content: center;
105
105
+
padding: 1rem;
106
106
+
margin-top: 1rem;
107
107
+
margin-bottom: 8rem;
108
108
+
}
109
109
+
}
110
110
+
}
111
111
+
</style>
+3
src/views/SettingsPage.vue
···
1
1
<script lang="ts" setup>
2
2
import { ref, computed } from 'vue'
3
3
+
3
4
import { useThemeStore, AccentColours } from '@/stores/theme'
4
5
import { useNavigationStore } from '@/stores/navigation'
5
6
import { useAuthStore } from '@/stores/auth'
···
159
160
>
160
161
</ListItem>
161
162
</ListGroup>
163
163
+
164
164
+
<ListGroup title="Developer"> </ListGroup>
162
165
163
166
<!-- modals -->
164
167
<Modal v-model:open="showThemeModal" title="Select Theme" width="640px">
+9
-5
src/views/UserProfile.vue
src/views/Profile/ProfileView.vue
···
20
20
import Button from '@/components/UI/BaseButton.vue'
21
21
import SkeletonLoader from '@/components/UI/SkeletonLoader.vue'
22
22
import SVG from '@/components/UI/SVG.vue'
23
23
+
23
24
import BluebellLogo from '@/assets/icons/bluebell.svg?raw'
24
25
import type { ActorIdentifier } from '@atcute/lexicons'
26
26
+
import AppLink from '@/components/Navigation/AppLink.vue'
25
27
26
28
const props = defineProps<{ id: string }>()
27
29
···
266
268
<component :is="isFollowing ? IconRemoveRounded : IconAddRounded" />
267
269
{{ isFollowing ? 'Following' : 'Follow' }}
268
270
</Button>
269
269
-
<Button variant="secondary" icon size="sm" flat><IconMoreHoriz /></Button>
271
271
+
<Button variant="secondary" icon size="sm" flat>
272
272
+
<IconMoreHoriz />
273
273
+
</Button>
270
274
</div>
271
275
</div>
272
276
···
283
287
</div>
284
288
285
289
<div class="stats-row">
286
286
-
<div class="stat-item">
290
290
+
<AppLink class="stat-item" name="user-followers" :params="{ id: profile.did }">
287
291
<span class="stat-val">{{ formatCount(profile.followersCount) }}</span>
288
292
<span class="stat-label">Followers</span>
289
289
-
</div>
290
290
-
<div class="stat-item">
293
293
+
</AppLink>
294
294
+
<AppLink class="stat-item" name="user-follows" :params="{ id: profile.did }">
291
295
<span class="stat-val">{{ formatCount(profile.followsCount) }}</span>
292
296
<span class="stat-label">Following</span>
293
293
-
</div>
297
297
+
</AppLink>
294
298
<div class="stat-item">
295
299
<span class="stat-val">{{ formatCount(profile.postsCount) }}</span>
296
300
<span class="stat-label">Posts</span>