+1
-1
src/components/AccountList/AccountItem.vue
+1
-1
src/components/AccountList/AccountItem.vue
+5
-4
src/components/PageHeader.vue
+5
-4
src/components/PageHeader.vue
···
13
14
defineProps<{
15
title: string
16
-
caption: string | string[]
17
}>()
18
</script>
19
···
73
<style scoped>
74
header {
75
padding: 0.5rem;
76
-
border-bottom: 1px solid hsla(var(--overlay0) / 0.2);
77
}
78
79
.title-row {
80
display: flex;
81
align-items: center;
82
gap: 0.25rem;
83
-
margin-bottom: 0.25rem;
84
}
85
86
.title-row__button {
···
132
overflow: hidden;
133
}
134
135
-
header .hint {
136
display: flex;
137
gap: 0.25rem;
138
margin: 0;
···
13
14
defineProps<{
15
title: string
16
+
caption?: string | string[]
17
}>()
18
</script>
19
···
73
<style scoped>
74
header {
75
padding: 0.5rem;
76
+
border-bottom: 1px solid var(--border);
77
}
78
79
.title-row {
80
display: flex;
81
align-items: center;
82
gap: 0.25rem;
83
}
84
85
.title-row__button {
···
131
overflow: hidden;
132
}
133
134
+
.hint {
135
+
margin-top: 0.25rem;
136
+
137
display: flex;
138
gap: 0.25rem;
139
margin: 0;
+192
src/components/PostItem.vue
+192
src/components/PostItem.vue
···
···
1
+
<script setup lang="ts">
2
+
import { useProfilesStore } from '@/stores/profiles'
3
+
import type { Post } from '@/stores/posts'
4
+
import { APP_CONFIG } from '@/config'
5
+
import { computed } from 'vue'
6
+
const props = defineProps<{ p: Post }>()
7
+
8
+
const profileStore = useProfilesStore()
9
+
const profile = computed(
10
+
() =>
11
+
profileStore.profiles.find((pr) => pr.did === props.p.authorDid)?.profile,
12
+
)
13
+
14
+
function timeAgo(input: string | Date | number): string {
15
+
const date = input instanceof Date ? input : new Date(input)
16
+
if (isNaN(date.getTime())) return 'invalid date'
17
+
18
+
const now = new Date()
19
+
let seconds = Math.floor((now.getTime() - date.getTime()) / 1000)
20
+
const isFuture = seconds < 0
21
+
seconds = Math.abs(seconds)
22
+
23
+
if (seconds < 5) return 'just now'
24
+
const units = [
25
+
{ sec: 31536000, name: 'year' },
26
+
{ sec: 2592000, name: 'month' },
27
+
{ sec: 86400, name: 'day' },
28
+
{ sec: 3600, name: 'hour' },
29
+
{ sec: 60, name: 'minute' },
30
+
{ sec: 1, name: 'second' },
31
+
]
32
+
33
+
for (const { sec, name } of units) {
34
+
const count = Math.floor(seconds / sec)
35
+
if (count >= 1) {
36
+
const plural = count > 1 ? 's' : ''
37
+
return isFuture
38
+
? `in ${count} ${name}${plural}`
39
+
: `${count} ${name}${plural} ago`
40
+
}
41
+
}
42
+
43
+
return 'just now'
44
+
}
45
+
46
+
const postUrl = computed(
47
+
() =>
48
+
`${APP_CONFIG.bskyClient}/profile/${props.p.authorDid}/post/${props.p.rkey}`,
49
+
)
50
+
const replyTo = computed(() => {
51
+
const parentUri = props.p.value.reply?.parent?.uri
52
+
if (!parentUri) return null
53
+
54
+
const m = parentUri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/?#]+)(?:[/?#].*)?$/)
55
+
if (!m) return null
56
+
const authorDid = m[1]
57
+
const rkey = m[2]
58
+
return {
59
+
did: authorDid,
60
+
profile:
61
+
profileStore.profiles.find((pr) => pr.did === authorDid)?.profile || null,
62
+
url: `${APP_CONFIG.bskyClient}/profile/${authorDid}/post/${rkey}`,
63
+
}
64
+
})
65
+
</script>
66
+
67
+
<template>
68
+
<article class="post" @click="console.log(p)" v-if="profile">
69
+
<div class="author-avatar">
70
+
<img
71
+
v-if="profile.avatar"
72
+
:src="profile.avatar"
73
+
alt="Avatar"
74
+
width="40"
75
+
height="40"
76
+
aria-hidden="true"
77
+
/>
78
+
</div>
79
+
<div class="post-main">
80
+
<div class="post-head">
81
+
<div class="post-author">
82
+
<RouterLink :to="`/profile/${p.authorDid}`" class="author-did">{{
83
+
profile.displayName || profile.handle
84
+
}}</RouterLink>
85
+
<span class="post-date">{{ timeAgo(p.createdAt) }}</span>
86
+
<p>
87
+
<small v-if="replyTo">
88
+
replying to
89
+
<RouterLink :to="`/profile/${replyTo.did}`">{{
90
+
replyTo.profile
91
+
? replyTo.profile.displayName || `@${replyTo.profile.handle}`
92
+
: replyTo.did
93
+
}}</RouterLink>
94
+
</small>
95
+
</p>
96
+
</div>
97
+
</div>
98
+
<div class="post-body">
99
+
<p v-if="p.value.text">{{ p.value.text }}</p>
100
+
<p v-else class="no-text">[no text]</p>
101
+
</div>
102
+
<div class="post-footer">
103
+
<a :href="postUrl" target="_blank" rel="noopener noreferrer">
104
+
view post
105
+
</a>
106
+
<a
107
+
v-if="replyTo"
108
+
:href="replyTo.url"
109
+
target="_blank"
110
+
rel="noopener noreferrer"
111
+
>
112
+
view thread
113
+
</a>
114
+
</div>
115
+
</div>
116
+
</article>
117
+
</template>
118
+
119
+
<style scoped>
120
+
.post {
121
+
border-bottom: 1px dashed var(--border-strong);
122
+
padding: 0.75rem;
123
+
display: flex;
124
+
flex-direction: row;
125
+
gap: 0.5rem;
126
+
}
127
+
128
+
.author-avatar {
129
+
flex-shrink: 0;
130
+
width: 40px;
131
+
height: 40px;
132
+
border-radius: 50%;
133
+
overflow: hidden;
134
+
background-color: hsla(var(--overlay0) / 0.1);
135
+
img {
136
+
width: 100%;
137
+
height: 100%;
138
+
object-fit: cover;
139
+
object-position: center;
140
+
display: block;
141
+
}
142
+
}
143
+
144
+
.post-head {
145
+
display: flex;
146
+
justify-content: space-between;
147
+
margin-bottom: 0.25rem;
148
+
.post-author {
149
+
font-weight: 700;
150
+
font-size: 0.85rem;
151
+
color: hsla(var(--subtext1) / 1);
152
+
display: flex;
153
+
gap: 0.5rem;
154
+
align-items: baseline;
155
+
}
156
+
.post-date {
157
+
font-weight: 500;
158
+
font-size: 0.75rem;
159
+
color: hsla(var(--subtext0) / 1);
160
+
}
161
+
}
162
+
.post-body {
163
+
color: hsla(var(--text) / 1);
164
+
min-width: 0;
165
+
166
+
p {
167
+
margin: 0;
168
+
white-space: pre-wrap;
169
+
word-break: break-word;
170
+
}
171
+
172
+
.no-text {
173
+
color: hsla(var(--subtext0) / 1);
174
+
font-style: italic;
175
+
}
176
+
}
177
+
.post-footer {
178
+
margin-top: 0.5rem;
179
+
display: flex;
180
+
gap: 0.5rem;
181
+
align-items: center;
182
+
183
+
a {
184
+
font-size: 0.75rem;
185
+
color: hsla(var(--blue) / 1);
186
+
text-decoration: none;
187
+
&:hover {
188
+
text-decoration: underline;
189
+
}
190
+
}
191
+
}
192
+
</style>
+1
-1
src/components/ThemeSwitcher.vue
+1
-1
src/components/ThemeSwitcher.vue
+1
src/config/schema.ts
+1
src/config/schema.ts
+7
src/css/base.css
+7
src/css/base.css
+19
-13
src/pages/HomeView.vue
+19
-13
src/pages/HomeView.vue
···
9
import AccountSection from '@/components/Sections/AccountSection.vue'
10
import { APP_CONFIG } from '@/config'
11
import { useProfilesStore } from '@/stores/profiles'
12
13
const profilesStore = useProfilesStore()
14
15
onMounted(async () => {
16
await profilesStore.init()
17
})
18
19
const appName = APP_CONFIG?.appName ?? 'pds-landing'
···
27
28
const select = (did: string | null) => profilesStore.selectDid(did)
29
30
-
onMounted(async () => {
31
-
if (adminConfig?.did) {
32
-
const res = await profilesStore.fetchDid(adminConfig.did as Did)
33
-
adminProfile.value = res.profile
34
-
}
35
-
})
36
-
37
watch(
38
isMobile,
39
(mobile) => {
···
54
watch(
55
() => route.path,
56
(p) => {
57
-
currentTab.value = tabFromRoutePath(p)
58
},
59
{ immediate: true },
60
)
61
watch(currentTab, (tab) => {
62
const routeTab = tabFromRoutePath(route.path)
63
-
if (routeTab !== tab) router.push({ path: `/${tab}` }).catch(() => {})
64
})
65
</script>
66
···
179
display: flex;
180
flex-direction: column;
181
gap: 0.5rem;
182
-
border: 1px solid hsla(var(--overlay0) / 0.2);
183
border-radius: 0.5rem;
184
background: hsla(var(--base) / 1);
185
186
section {
187
margin-top: 0.5rem;
188
-
border-top: 1px dashed hsla(var(--overlay0) / 0.5);
189
padding-top: 0.5rem;
190
h3 {
191
font-size: 0.85rem;
···
283
.main {
284
flex: 1 1 auto;
285
overflow-y: auto;
286
-
border: 1px solid hsla(var(--overlay0) / 0.2);
287
border-radius: 0.5rem;
288
background: hsla(var(--base) / 1);
289
}
···
299
300
.admin {
301
margin-top: 0.5rem;
302
-
border-top: 1px dashed hsla(var(--overlay0) / 0.5);
303
padding-top: 0.5rem;
304
305
.admin-extra {
···
9
import AccountSection from '@/components/Sections/AccountSection.vue'
10
import { APP_CONFIG } from '@/config'
11
import { useProfilesStore } from '@/stores/profiles'
12
+
import { usePostsStore } from '@/stores/posts'
13
14
+
const postsStore = usePostsStore()
15
const profilesStore = useProfilesStore()
16
17
onMounted(async () => {
18
+
if (adminConfig?.did) {
19
+
profilesStore
20
+
.fetchDid(adminConfig.did as Did)
21
+
.then((profile) => {
22
+
adminProfile.value = profile?.profile ?? null
23
+
})
24
+
.catch(() => {
25
+
adminProfile.value = null
26
+
})
27
+
}
28
+
29
await profilesStore.init()
30
+
await postsStore.loadFeedFromDids(profilesStore.dids)
31
})
32
33
const appName = APP_CONFIG?.appName ?? 'pds-landing'
···
41
42
const select = (did: string | null) => profilesStore.selectDid(did)
43
44
watch(
45
isMobile,
46
(mobile) => {
···
61
watch(
62
() => route.path,
63
(p) => {
64
+
currentTab.value = tabFromRoutePath(p as unknown as string)
65
},
66
{ immediate: true },
67
)
68
watch(currentTab, (tab) => {
69
const routeTab = tabFromRoutePath(route.path)
70
+
if (routeTab !== tab.value) router.push({ path: `/${tab}` }).catch(() => {})
71
})
72
</script>
73
···
186
display: flex;
187
flex-direction: column;
188
gap: 0.5rem;
189
+
border: 1px solid var(--border);
190
border-radius: 0.5rem;
191
background: hsla(var(--base) / 1);
192
193
section {
194
margin-top: 0.5rem;
195
+
border-top: 1px dashed var(--border-strong);
196
padding-top: 0.5rem;
197
h3 {
198
font-size: 0.85rem;
···
290
.main {
291
flex: 1 1 auto;
292
overflow-y: auto;
293
+
border: 1px solid var(--border);
294
border-radius: 0.5rem;
295
background: hsla(var(--base) / 1);
296
}
···
306
307
.admin {
308
margin-top: 0.5rem;
309
padding-top: 0.5rem;
310
311
.admin-extra {
+33
src/pages/PostsView.vue
+33
src/pages/PostsView.vue
···
1
<script setup lang="ts">
2
import PageHeader from '@/components/PageHeader.vue'
3
+
import PostItem from '@/components/PostItem.vue'
4
+
5
+
import { usePostsStore } from '@/stores/posts'
6
+
const postsStore = usePostsStore()
7
</script>
8
9
<template>
10
<PageHeader title="posts." caption="posts from users on this pds!" />
11
+
<section class="block posts-list">
12
+
<div v-if="postsStore.loading" class="loading">loading posts...</div>
13
+
<div v-else-if="postsStore.error" class="error">{{ postsStore.error }}</div>
14
+
15
+
<div
16
+
v-if="!postsStore.loading && postsStore.feed.length === 0"
17
+
class="empty"
18
+
>
19
+
no posts found.
20
+
</div>
21
+
22
+
<template v-if="!postsStore.loading">
23
+
<div v-for="p in postsStore.feed" :key="p.uri + p.cid">
24
+
<PostItem :p="p" />
25
+
</div>
26
+
</template>
27
+
</section>
28
</template>
29
+
30
+
<style scoped>
31
+
.posts-list {
32
+
padding: 0.5rem;
33
+
}
34
+
.loading,
35
+
.error,
36
+
.empty {
37
+
padding: 0.5rem;
38
+
color: hsla(var(--subtext0) / 1);
39
+
}
40
+
</style>
+125
-41
src/pages/ProfileView.vue
+125
-41
src/pages/ProfileView.vue
···
1
<script setup lang="ts">
2
import PageHeader from '@/components/PageHeader.vue'
3
4
import { useRoute } from 'vue-router'
5
-
import { watch, computed } from 'vue'
6
-
import type { Did } from '@atcute/lexicons'
7
8
import { useProfilesStore } from '@/stores/profiles'
9
import { onUnmounted } from 'vue'
10
11
const profilesStore = useProfilesStore()
12
const selectedProfile = computed(() => profilesStore.selectedProfile)
13
14
const route = useRoute()
15
16
watch(
17
() => route.params.did,
18
-
(newDid) => {
19
-
if (newDid) profilesStore.selectDid(newDid as Did)
20
window.scrollTo(0, 0)
21
},
22
{ immediate: true },
···
39
</script>
40
41
<template>
42
-
<PageHeader
43
-
:title="`${selectedProfile?.profile?.displayName || selectedProfile?.profile?.handle}'s profile`"
44
-
:caption="stats"
45
-
/>
46
-
<div
47
-
:class="{ profile: true, 'no-banner': !selectedProfile?.profile?.banner }"
48
-
>
49
-
<div class="profile-banner">
50
-
<img
51
-
v-if="selectedProfile?.profile?.banner"
52
-
:src="selectedProfile?.profile?.banner"
53
-
class="banner-image"
54
-
alt="profile banner"
55
-
aria-hidden="true"
56
-
/>
57
-
<div class="profile-avatar">
58
<img
59
-
v-if="selectedProfile?.profile?.avatar"
60
-
:src="selectedProfile?.profile?.avatar"
61
-
alt="profile avatar"
62
aria-hidden="true"
63
/>
64
</div>
65
</div>
66
67
-
<section class="block profile-info">
68
-
<h2 class="profile-name">
69
-
{{
70
-
selectedProfile?.profile?.displayName ||
71
-
`@${selectedProfile?.profile?.handle}`
72
-
}}
73
-
</h2>
74
-
<a
75
-
:href="`https://${selectedProfile?.profile?.handle}`"
76
-
class="profile-handle"
77
-
>
78
-
@{{ selectedProfile?.profile?.handle }}
79
</a>
80
-
<p v-if="selectedProfile?.profile?.description" class="profile-bio">
81
-
{{ selectedProfile?.profile?.description }}
82
-
</p>
83
-
</section>
84
-
</div>
85
</template>
86
87
<style scoped>
···
109
left: 1rem;
110
width: 8rem;
111
height: 8rem;
112
-
border: 3px solid hsla(var(--overlay0) / 0.5);
113
border-radius: 50%;
114
overflow: hidden;
115
background: hsla(var(--surface0) / 1);
···
155
word-break: break-word;
156
}
157
}
158
}
159
</style>
···
1
<script setup lang="ts">
2
import PageHeader from '@/components/PageHeader.vue'
3
+
import PostItem from '@/components/PostItem.vue'
4
5
import { useRoute } from 'vue-router'
6
+
import { watch, computed, ref } from 'vue'
7
8
import { useProfilesStore } from '@/stores/profiles'
9
import { onUnmounted } from 'vue'
10
+
import { type Post, usePostsStore } from '@/stores/posts'
11
+
import { APP_CONFIG } from '@/config'
12
13
const profilesStore = useProfilesStore()
14
+
const postsStore = usePostsStore()
15
+
16
const selectedProfile = computed(() => profilesStore.selectedProfile)
17
+
const selectedDid = ref<string | null>(null)
18
+
const profileUrl = computed(() => {
19
+
return `${APP_CONFIG.bskyClient}/profile/${selectedDid.value}`
20
+
})
21
22
const route = useRoute()
23
+
const posts = ref<Post[]>([])
24
25
watch(
26
() => route.params.did,
27
+
async (newDid) => {
28
+
if (newDid) {
29
+
const did = Array.isArray(newDid) ? newDid[0] : newDid
30
+
selectedDid.value = did
31
+
profilesStore.selectDid(did)
32
+
const userPosts = await postsStore.loadUserPosts(did, 100)
33
+
posts.value = userPosts
34
+
} else {
35
+
posts.value = []
36
+
}
37
window.scrollTo(0, 0)
38
},
39
{ immediate: true },
···
56
</script>
57
58
<template>
59
+
<template v-if="selectedProfile">
60
+
<PageHeader
61
+
:title="`${selectedProfile?.profile?.displayName || selectedProfile?.profile?.handle}'s profile`"
62
+
:caption="stats"
63
+
/>
64
+
<div
65
+
:class="{ profile: true, 'no-banner': !selectedProfile?.profile?.banner }"
66
+
>
67
+
<div class="profile-banner">
68
<img
69
+
v-if="selectedProfile?.profile?.banner"
70
+
:src="selectedProfile?.profile?.banner"
71
+
class="banner-image"
72
+
alt="profile banner"
73
aria-hidden="true"
74
/>
75
+
<div class="profile-avatar">
76
+
<img
77
+
v-if="selectedProfile?.profile?.avatar"
78
+
:src="selectedProfile?.profile?.avatar"
79
+
alt="profile avatar"
80
+
aria-hidden="true"
81
+
/>
82
+
</div>
83
</div>
84
+
85
+
<section class="block profile-info">
86
+
<h2 class="profile-name">
87
+
{{
88
+
selectedProfile?.profile?.displayName ||
89
+
`@${selectedProfile?.profile?.handle}`
90
+
}}
91
+
</h2>
92
+
<a
93
+
:href="`https://${selectedProfile?.profile?.handle}`"
94
+
class="profile-handle"
95
+
>
96
+
@{{ selectedProfile?.profile?.handle }}
97
+
</a>
98
+
<p v-if="selectedProfile?.profile?.description" class="profile-bio">
99
+
{{ selectedProfile?.profile?.description }}
100
+
</p>
101
+
102
+
<a :href="profileUrl" rel="noopener noreferrer" target="_blank">
103
+
view on bluesky
104
+
</a>
105
+
</section>
106
+
107
+
<section class="block profile-posts">
108
+
<div v-if="postsStore.loading" class="loading">loading posts...</div>
109
+
<div v-if="postsStore.error" class="error">{{ postsStore.error }}</div>
110
+
<div v-if="!postsStore.loading && posts.length === 0" class="empty">
111
+
no posts.
112
+
</div>
113
+
114
+
<template v-if="!postsStore.loading">
115
+
<PostItem :p="p" v-for="p in posts" :key="p.rkey" />
116
+
</template>
117
+
</section>
118
</div>
119
+
</template>
120
+
<template v-else>
121
+
<PageHeader title="profile." />
122
+
<div class="no-profile">
123
+
<h2>no profile found</h2>
124
+
<p>this profile couldn't be found on this pds.</p>
125
126
+
<a :href="profileUrl" rel="noopener noreferrer" target="_blank">
127
+
view on bluesky
128
</a>
129
+
</div>
130
+
</template>
131
</template>
132
133
<style scoped>
···
155
left: 1rem;
156
width: 8rem;
157
height: 8rem;
158
+
border: 3px solid var(--border-strong);
159
border-radius: 50%;
160
overflow: hidden;
161
background: hsla(var(--surface0) / 1);
···
201
word-break: break-word;
202
}
203
}
204
+
}
205
+
206
+
.profile-posts {
207
+
width: 100%;
208
+
padding: 0.5rem;
209
+
border-top: 1px solid var(--border);
210
+
}
211
+
212
+
.no-profile {
213
+
padding: 1rem;
214
+
215
+
h2 {
216
+
margin: 0;
217
+
font-size: 1.5rem;
218
+
font-weight: bold;
219
+
color: hsla(var(--text) / 1);
220
+
}
221
+
222
+
p {
223
+
font-size: 1rem;
224
+
color: hsla(var(--subtext0) / 1);
225
+
}
226
+
227
+
a {
228
+
display: inline-block;
229
+
margin-top: 1rem;
230
+
font-size: 1rem;
231
+
&:hover {
232
+
text-decoration: underline;
233
+
}
234
+
}
235
+
}
236
+
237
+
.loading,
238
+
.error,
239
+
.empty {
240
+
padding: 0.5rem;
241
+
color: hsla(var(--subtext0) / 1);
242
}
243
</style>
+142
src/stores/posts.ts
+142
src/stores/posts.ts
···
···
1
+
import type { ComAtprotoRepoListRecords } from '@atcute/atproto'
2
+
import type { AppBskyFeedPost } from '@atcute/bluesky'
3
+
import type { Did } from '@atcute/lexicons'
4
+
import { defineStore } from 'pinia'
5
+
import { ref } from 'vue'
6
+
import { pdsClient } from '@/api/clients'
7
+
8
+
export type Post = {
9
+
uri: string
10
+
cid: string
11
+
value: AppBskyFeedPost.Main
12
+
createdAt: string
13
+
rkey: string
14
+
authorDid: Did
15
+
}
16
+
17
+
export const usePostsStore = defineStore('posts', () => {
18
+
const feed = ref<Post[]>([])
19
+
const userPosts = ref<Record<Did, Post[]>>({})
20
+
const loading = ref(false)
21
+
const error = ref<string | null>(null)
22
+
23
+
async function fetchPostsFromRepo(did: Did, limit = 50, cursor?: string) {
24
+
try {
25
+
const { ok, data } = await pdsClient.get('com.atproto.repo.listRecords', {
26
+
params: {
27
+
repo: did,
28
+
collection: 'app.bsky.feed.post',
29
+
limit,
30
+
cursor,
31
+
},
32
+
})
33
+
34
+
if (!ok)
35
+
throw new Error(
36
+
`failed to fetch posts for ${did}: ${data.error ?? 'unknown'}`,
37
+
)
38
+
39
+
const typed = data as ComAtprotoRepoListRecords.$output
40
+
41
+
const records = Array.isArray(typed.records) ? typed.records : []
42
+
43
+
const posts = records
44
+
.map((rec) => {
45
+
const val = rec.value as AppBskyFeedPost.Main
46
+
return {
47
+
uri: rec.uri,
48
+
cid: rec.cid,
49
+
value: val,
50
+
createdAt: val.createdAt,
51
+
rkey: rec.uri.split('/').pop() ?? '',
52
+
authorDid: did,
53
+
} as Post
54
+
})
55
+
.filter(Boolean)
56
+
57
+
return {
58
+
posts,
59
+
cursor: typed.cursor ?? null,
60
+
}
61
+
} catch (err: unknown) {
62
+
const msg = err instanceof Error ? err.message : String(err)
63
+
throw new Error(msg)
64
+
}
65
+
}
66
+
67
+
async function loadUserPosts(did: Did, limit = 100) {
68
+
loading.value = true
69
+
error.value = null
70
+
try {
71
+
const { posts } = await fetchPostsFromRepo(did, limit)
72
+
userPosts.value = {
73
+
...userPosts.value,
74
+
[did]: posts,
75
+
}
76
+
return posts
77
+
} catch (err: unknown) {
78
+
error.value = err instanceof Error ? err.message : String(err)
79
+
userPosts.value[did] = []
80
+
return []
81
+
} finally {
82
+
loading.value = false
83
+
}
84
+
}
85
+
86
+
async function loadFeedFromDids(dids: Did[], perUserLimit = 100) {
87
+
loading.value = true
88
+
error.value = null
89
+
try {
90
+
const promises = dids.map(async (did) => {
91
+
try {
92
+
const { posts } = await fetchPostsFromRepo(did, perUserLimit)
93
+
return posts
94
+
} catch {
95
+
return [] as Post[]
96
+
}
97
+
})
98
+
99
+
const results = await Promise.all(promises)
100
+
const aggregated = results.flat()
101
+
102
+
aggregated.sort((a, b) => {
103
+
const ta = Date.parse(a.createdAt)
104
+
const tb = Date.parse(b.createdAt)
105
+
return tb - ta
106
+
})
107
+
108
+
feed.value = aggregated
109
+
return aggregated
110
+
} catch (err: unknown) {
111
+
error.value = err instanceof Error ? err.message : String(err)
112
+
feed.value = []
113
+
return []
114
+
} finally {
115
+
loading.value = false
116
+
}
117
+
}
118
+
119
+
function clearFeed() {
120
+
feed.value = []
121
+
}
122
+
123
+
function clearUserPosts(did?: Did) {
124
+
if (did) {
125
+
delete userPosts.value[did]
126
+
return
127
+
}
128
+
userPosts.value = {}
129
+
}
130
+
131
+
return {
132
+
feed,
133
+
userPosts,
134
+
loading,
135
+
error,
136
+
fetchPostsFromRepo,
137
+
loadUserPosts,
138
+
loadFeedFromDids,
139
+
clearFeed,
140
+
clearUserPosts,
141
+
}
142
+
})