the {coilest,wokest,funnest} client for bluesky next.bbell.vt3e.cat/
bluesky client
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(notifications): separate out into stores

+314 -232
+5 -23
src/components/NotificationItem.vue
··· 15 15 } from "@iconify-prerendered/vue-material-symbols"; 16 16 import type { Icon, NotificationGroup } from "@/types"; 17 17 18 - import { getAuthClient, getBlueskyAppview, useAuthStore } from "@/services/atproto"; 19 - 20 - const auth = useAuthStore(); 18 + import { usePostStore } from "@/stores/post"; 19 + const postStore = usePostStore(); 21 20 22 21 type Reason = NotificationGroup["reason"]; 23 22 ··· 73 72 return; 74 73 } 75 74 76 - const rpc = await getAuthClient(auth.activeDid, { proxy: getBlueskyAppview() }); 77 - const { data, ok } = await rpc.get("app.bsky.feed.getPosts", { 78 - params: { 79 - uris: [postUri], 80 - }, 81 - }); 82 - 83 - if (!ok) { 84 - console.error("failed to fetch post for notification:", data); 85 - return; 86 - } 87 - 88 - const post = data.posts?.[0]; 89 - if (!post) { 90 - console.error("no post found for notification:", data); 91 - return; 92 - } 93 - 94 - replyPost.value = post; 75 + const post = postStore.getPost(postUri); 76 + if (post) replyPost.value = post ?? (await postStore.fetchPost(postUri)); 95 77 }); 96 78 </script> 97 79 ··· 153 135 padding: var(--space-2); 154 136 padding-left: calc(var(--space-2) - 4px); 155 137 gap: var(--space-2); 156 - border-bottom: var(--border-1); 138 + border-bottom: var(--border); 157 139 border-left: 4px transparent solid; 158 140 159 141 &:hover {
+239
src/stores/notifications.ts
··· 1 + import { defineStore } from "pinia"; 2 + import { ref } from "vue"; 3 + import { getAuthClient, getBlueskyAppview } from "@/services/atproto"; 4 + import type { NotificationGroup, NotificationGroupBase, NotificationReason } from "@/types"; 5 + import { AppBskyFeedDefs, AppBskyNotificationListNotifications } from "@atcute/bluesky"; 6 + import type { ResourceUri } from "@atcute/lexicons"; 7 + import type { AtprotoDid } from "@atcute/lexicons/syntax"; 8 + import { usePostStore } from "./post"; 9 + 10 + export interface AccountNotificationState { 11 + notifications: NotificationGroupBase[]; 12 + cursor?: string; 13 + hasMore: boolean; 14 + isFetching: boolean; 15 + error: string | null; 16 + readAt: Date | null; 17 + unreadCount: number; 18 + } 19 + 20 + function parseIso(ts?: string | null) { 21 + if (!ts) return 0; 22 + const n = Date.parse(ts); 23 + return Number.isFinite(n) ? n : 0; 24 + } 25 + 26 + function groupNotifications( 27 + notificationsList: AppBskyNotificationListNotifications.Notification[], 28 + seenAtParam?: string | null, 29 + ): NotificationGroupBase[] { 30 + const groups: NotificationGroup[] = []; 31 + 32 + for (const notif of notificationsList) { 33 + const groupableReasons: NotificationReason[] = [ 34 + "follow", 35 + "like", 36 + "like-via-repost", 37 + "repost", 38 + "repost-via-repost", 39 + ]; 40 + const isGroupable = groupableReasons.includes(notif.reason); 41 + 42 + let subjectUri = notif.reasonSubject; 43 + if ( 44 + notif.reason === "repost" || 45 + notif.reason === "repost-via-repost" || 46 + notif.reason === "like-via-repost" 47 + ) { 48 + const record = notif.record as { subject?: { uri?: ResourceUri } }; 49 + if (record?.subject?.uri) subjectUri = record.subject.uri; 50 + } 51 + 52 + const notifRead = 53 + typeof notif.isRead === "boolean" 54 + ? notif.isRead 55 + : !!seenAtParam && parseIso(notif.indexedAt) <= parseIso(seenAtParam); 56 + 57 + const lastGroup = groups.length > 0 ? groups[groups.length - 1] : null; 58 + 59 + const canMerge = 60 + isGroupable && 61 + lastGroup && 62 + lastGroup.reason === notif.reason && 63 + lastGroup.reasonSubject === subjectUri; 64 + 65 + if (canMerge) { 66 + lastGroup.notifications.push(notif); 67 + 68 + if (!lastGroup.authors.some((a) => a.did === notif.author.did)) 69 + lastGroup.authors.push(notif.author); 70 + 71 + lastGroup.isRead = lastGroup.isRead && notifRead; 72 + } else { 73 + groups.push({ 74 + id: notif.uri, 75 + reason: notif.reason, 76 + reasonSubject: subjectUri, 77 + authors: [notif.author], 78 + isRead: notifRead, 79 + indexedAt: notif.indexedAt, 80 + notifications: [notif], 81 + record: notif.record, 82 + }); 83 + } 84 + } 85 + 86 + return groups; 87 + } 88 + 89 + export const useNotificationsStore = defineStore("notifications", () => { 90 + const accountsData = ref<Record<AtprotoDid, AccountNotificationState>>({}); 91 + const postStore = usePostStore(); 92 + 93 + function getAccountState(did: AtprotoDid): AccountNotificationState { 94 + if (!accountsData.value[did]) { 95 + accountsData.value[did] = { 96 + notifications: [], 97 + cursor: undefined, 98 + hasMore: true, 99 + isFetching: false, 100 + error: null, 101 + readAt: null, 102 + unreadCount: 0, 103 + }; 104 + } 105 + return accountsData.value[did]; 106 + } 107 + 108 + async function fetchNotifications(did: AtprotoDid, reset = false) { 109 + const state = getAccountState(did); 110 + 111 + if (state.isFetching) return; 112 + if (!reset && !state.hasMore) return; 113 + 114 + state.isFetching = true; 115 + state.error = null; 116 + 117 + if (reset) { 118 + state.cursor = undefined; 119 + state.hasMore = true; 120 + } 121 + 122 + try { 123 + const rpc = await getAuthClient(did, { proxy: getBlueskyAppview() }); 124 + 125 + const unreadPromise = rpc 126 + .get("app.bsky.notification.getUnreadCount", { params: {} }) 127 + .catch(() => null); 128 + 129 + const { data, ok } = await rpc.get("app.bsky.notification.listNotifications", { 130 + params: { cursor: state.cursor }, 131 + }); 132 + 133 + if (!ok) throw new Error("Failed to fetch notification feed."); 134 + 135 + const unreadRes = await unreadPromise; 136 + if (unreadRes?.ok) { 137 + state.unreadCount = unreadRes.data.count; 138 + } 139 + 140 + if (data.seenAt) { 141 + const newSeenDate = new Date(data.seenAt); 142 + if (!state.readAt || newSeenDate > state.readAt) { 143 + state.readAt = newSeenDate; 144 + } 145 + } 146 + 147 + const groups = groupNotifications(data.notifications, data.seenAt); 148 + 149 + if (!reset && state.notifications.length > 0 && groups.length > 0) { 150 + const lastExisting = state.notifications[state.notifications.length - 1]; 151 + const firstNew = groups[0]; 152 + if (!firstNew || !lastExisting) { 153 + state.notifications.push(...groups); 154 + state.cursor = data.cursor; 155 + state.hasMore = !!data.cursor; 156 + return; 157 + } 158 + 159 + const groupableReasons: NotificationReason[] = [ 160 + "follow", 161 + "like", 162 + "like-via-repost", 163 + "repost", 164 + "repost-via-repost", 165 + ]; 166 + const canMergeBoundary = 167 + groupableReasons.includes(firstNew.reason) && 168 + lastExisting.reason === firstNew.reason && 169 + lastExisting.reasonSubject === firstNew.reasonSubject; 170 + 171 + if (canMergeBoundary) { 172 + for (const n of firstNew.notifications) lastExisting.notifications.push(n); 173 + for (const a of firstNew.authors) { 174 + if (!lastExisting.authors.some((ex) => ex.did === a.did)) { 175 + lastExisting.authors.push(a); 176 + } 177 + } 178 + lastExisting.isRead = lastExisting.isRead && firstNew.isRead; 179 + groups.shift(); 180 + } 181 + } 182 + 183 + const subjectUris = Array.from( 184 + new Set(groups.map((g) => g.reasonSubject).filter(Boolean) as string[]), 185 + ); 186 + const postsByUri = new Map<string, AppBskyFeedDefs.PostView>(); 187 + 188 + for (let i = 0; i < subjectUris.length; i += 25) { 189 + const uris = subjectUris.slice(i, i + 25) as ResourceUri[]; 190 + 191 + const posts = await postStore.fetchPosts(uris); 192 + for (const post of posts) postsByUri.set(post.uri, post); 193 + } 194 + 195 + for (const group of groups) { 196 + if (group.reasonSubject) group.subject = postsByUri.get(group.reasonSubject); 197 + } 198 + 199 + if (reset) state.notifications = groups; 200 + else state.notifications.push(...groups); 201 + 202 + state.cursor = data.cursor; 203 + state.hasMore = !!data.cursor; 204 + } catch (err) { 205 + if (err instanceof Error) state.error = err.message; 206 + else state.error = "an unknown error occurred while fetching notifications."; 207 + } finally { 208 + state.isFetching = false; 209 + } 210 + } 211 + 212 + async function markAsSeen(did: AtprotoDid, until?: string) { 213 + const state = getAccountState(did); 214 + try { 215 + const rpc = await getAuthClient(did, { proxy: getBlueskyAppview() }); 216 + 217 + const dateUntil = until ? new Date(until) : new Date(); 218 + dateUntil.setMilliseconds(dateUntil.getMilliseconds() - 1); 219 + 220 + state.readAt = dateUntil; 221 + state.unreadCount = 0; 222 + state.notifications.forEach((g) => (g.isRead = true)); 223 + 224 + await rpc.post("app.bsky.notification.updateSeen", { 225 + input: { seenAt: dateUntil.toISOString() }, 226 + as: null, 227 + }); 228 + } catch (err) { 229 + console.error("failed to mark as seen:", err); 230 + } 231 + } 232 + 233 + return { 234 + accountsData, 235 + getAccountState, 236 + fetchNotifications, 237 + markAsSeen, 238 + }; 239 + });
+8 -1
src/stores/post.ts
··· 2 2 import { reactive } from "vue"; 3 3 import type { AppBskyFeedDefs, AppBskyFeedLike, AppBskyFeedRepost } from "@atcute/bluesky"; 4 4 import type { ResourceUri } from "@atcute/lexicons"; 5 - import { getAuthClient, getBlueskyAppview, SERVICES, useAuthStore } from "@/services/atproto"; 5 + import { getAuthClient, getBlueskyAppview, useAuthStore } from "@/services/atproto"; 6 6 import { AppviewPostProvider } from "@/services/atproto/providers"; 7 7 8 8 export const usePostStore = defineStore("post", () => { ··· 28 28 const result = await postProvider.getPosts([uri]); 29 29 setPosts(result.posts); 30 30 return result.posts[0]; 31 + } 32 + async function fetchPosts(uris: ResourceUri[]) { 33 + const postProvider = new AppviewPostProvider(); 34 + const result = await postProvider.getPosts(uris); 35 + setPosts(result.posts); 36 + return result.posts; 31 37 } 32 38 33 39 // -- post interactions ----------------------------------------- ··· 284 290 setPosts, 285 291 getPost, 286 292 fetchPost, 293 + fetchPosts, 287 294 288 295 // --- interactions 289 296 likePost,
+62 -208
src/views/NotificationsPage.vue
··· 1 1 <script lang="ts" setup> 2 + import { computed, onMounted, watch } from "vue"; 3 + import { IconDoneAllRounded } from "@iconify-prerendered/vue-material-symbols"; 4 + 5 + import { useNotificationsStore } from "@/stores/notifications"; 2 6 import { PageLayout, Button, AccountPicker } from "@/components"; 3 7 import NotificationItem from "@/components/NotificationItem.vue"; 4 - import { getAuthClient, getBlueskyAppview, useAuthStore } from "@/services/atproto"; 5 - import type { NotificationGroup, NotificationGroupBase, NotificationReason } from "@/types"; 6 - import { AppBskyFeedDefs, AppBskyNotificationListNotifications } from "@atcute/bluesky"; 7 - import type { ResourceUri } from "@atcute/lexicons"; 8 - import { IconDoneAllRounded } from "@iconify-prerendered/vue-material-symbols"; 9 - import { computed, onMounted, watch } from "vue"; 10 - import { ref } from "vue"; 8 + import { useAuthStore } from "@/services/atproto"; 11 9 12 10 const auth = useAuthStore(); 13 - const activeAccount = ref(auth.activeDid); 11 + const notificationsStore = useNotificationsStore(); 14 12 15 - const notifications = ref<NotificationGroupBase[]>([]); 16 - const isFetching = ref(false); 17 - const hasMore = ref(true); 18 - const cursor = ref<string | undefined>(undefined); 19 - const error = ref<string | null>(null); 13 + const activeAccountState = computed(() => { 14 + if (!auth.activeDid) return null; 15 + return notificationsStore.getAccountState(auth.activeDid); 16 + }); 17 + 18 + const activeAccount = computed({ 19 + get: () => auth.activeDid, 20 + set: (newDid) => { 21 + auth.activeDid = newDid; 22 + }, 23 + }); 20 24 21 - const readAt = ref<Date | null>(null); 25 + const notifications = computed(() => activeAccountState.value?.notifications ?? []); 26 + const isFetching = computed(() => activeAccountState.value?.isFetching ?? false); 27 + const hasMore = computed(() => activeAccountState.value?.hasMore ?? false); 28 + const error = computed(() => activeAccountState.value?.error ?? null); 29 + const unreadCount = computed(() => activeAccountState.value?.unreadCount ?? 0); 30 + const readAt = computed(() => activeAccountState.value?.readAt ?? null); 31 + 22 32 const hasUnread = computed(() => { 33 + if (unreadCount.value > 0) return true; 34 + 23 35 const lastNotification = notifications.value[0]; 24 - console.log(lastNotification); 25 36 if (!lastNotification) return false; 26 37 27 38 const indexedAt = new Date(lastNotification.indexedAt); 28 - console.log({ 29 - indexedAt, 30 - readAt: readAt.value, 31 - result: readAt.value ? indexedAt > readAt.value : true, 32 - }); 33 39 return readAt.value ? indexedAt > readAt.value : true; 34 40 }); 35 41 36 - const getRpc = async () => { 37 - if (!activeAccount.value) return null; 38 - return await getAuthClient(activeAccount.value, { 39 - proxy: getBlueskyAppview(), 40 - }); 41 - }; 42 - 43 - function parseIso(ts?: string | null) { 44 - if (!ts) return 0; 45 - const n = Date.parse(ts); 46 - return Number.isFinite(n) ? n : 0; 47 - } 48 - 49 - function groupNotifications( 50 - notificationsList: AppBskyNotificationListNotifications.Notification[], 51 - seenAtParam?: string | null, 52 - ): NotificationGroupBase[] { 53 - const groups: NotificationGroup[] = []; 54 - 55 - for (const notif of notificationsList) { 56 - const groupableReasons: NotificationReason[] = [ 57 - "follow", 58 - "like", 59 - "like-via-repost", 60 - "repost", 61 - "repost-via-repost", 62 - ]; 63 - const isGroupable = groupableReasons.includes(notif.reason); 64 - 65 - let subjectUri = notif.reasonSubject; 66 - if ( 67 - notif.reason === "repost" || 68 - notif.reason === "repost-via-repost" || 69 - notif.reason === "like-via-repost" 70 - ) { 71 - const record = notif.record as { subject?: { uri?: ResourceUri } }; 72 - if (record?.subject?.uri) subjectUri = record.subject.uri; 73 - } 74 - 75 - const notifRead = 76 - typeof notif.isRead === "boolean" 77 - ? notif.isRead 78 - : !!seenAtParam && parseIso(notif.indexedAt) <= parseIso(seenAtParam); 79 - 80 - const lastGroup = groups.length > 0 ? groups[groups.length - 1] : null; 81 - 82 - const canMerge = 83 - isGroupable && 84 - lastGroup && 85 - lastGroup.reason === notif.reason && 86 - lastGroup.reasonSubject === subjectUri; 87 - 88 - if (canMerge) { 89 - lastGroup.notifications.push(notif); 90 - 91 - if (!lastGroup.authors.some((a) => a.did === notif.author.did)) 92 - lastGroup.authors.push(notif.author); 93 - 94 - lastGroup.isRead = lastGroup.isRead && notifRead; 95 - } else { 96 - groups.push({ 97 - id: notif.uri, 98 - reason: notif.reason, 99 - reasonSubject: subjectUri, 100 - authors: [notif.author], 101 - isRead: notifRead, 102 - indexedAt: notif.indexedAt, 103 - notifications: [notif], 104 - record: notif.record, 105 - }); 106 - } 107 - } 108 - 109 - return groups; 110 - } 111 - 112 - async function fetchNotifications() { 113 - if (isFetching.value) return; 114 - isFetching.value = true; 115 - 116 - const rpc = await getRpc(); 117 - if (!rpc) { 118 - error.value = "No active account selected."; 119 - isFetching.value = false; 120 - return; 121 - } 122 - 123 - const { data, ok } = await rpc.get("app.bsky.notification.listNotifications", { 124 - params: {}, 125 - }); 126 - 127 - isFetching.value = false; 128 - if (!ok) { 129 - error.value = `Failed to fetch notifications: ${data.error} (${data.message})`; 130 - isFetching.value = false; 131 - return; 132 - } 133 - 134 - if (data.seenAt) readAt.value = new Date(data.seenAt); 135 - const groups = groupNotifications(data.notifications, data.seenAt); 136 - 137 - if (notifications.value.length > 0 && groups.length > 0) { 138 - const lastExisting = notifications.value[notifications.value.length - 1]; 139 - const firstNew = groups[0]; 140 - if (!lastExisting || !firstNew) return; 141 - 142 - const groupableReasons: NotificationReason[] = [ 143 - "follow", 144 - "like", 145 - "like-via-repost", 146 - "repost", 147 - "repost-via-repost", 148 - ]; 149 - const canMergeBoundary = 150 - groupableReasons.includes(firstNew.reason) && 151 - lastExisting.reason === firstNew.reason && 152 - lastExisting.reasonSubject === firstNew.reasonSubject; 153 - 154 - if (canMergeBoundary) { 155 - for (const n of firstNew.notifications) lastExisting.notifications.push(n); 156 - for (const a of firstNew.authors) { 157 - if (!lastExisting.authors.some((ex) => ex.did === a.did)) { 158 - lastExisting.authors.push(a); 159 - } 160 - } 161 - lastExisting.isRead = lastExisting.isRead && firstNew.isRead; 162 - groups.shift(); 163 - } 164 - } 165 - 166 - const subjectUris = Array.from( 167 - new Set(groups.map((g) => g.reasonSubject).filter(Boolean) as string[]), 168 - ); 169 - const postsByUri = new Map<string, AppBskyFeedDefs.PostView>(); 170 - 171 - for (let i = 0; i < subjectUris.length; i += 25) { 172 - const uris = subjectUris.slice(i, i + 25) as ResourceUri[]; 173 - const { data: postsData, ok: postsOk } = await rpc.get("app.bsky.feed.getPosts", { 174 - params: { uris }, 175 - }); 176 - 177 - if (!postsOk) { 178 - console.warn("failed to fetch posts for notifications", { uris, error: postsData }); 179 - continue; 180 - } 181 - 182 - for (const post of postsData.posts) postsByUri.set(post.uri, post); 183 - } 184 - 185 - for (const group of groups) { 186 - if (group.reasonSubject) group.subject = postsByUri.get(group.reasonSubject); 187 - } 188 - 189 - notifications.value.push(...groups); 190 - cursor.value = data.cursor; 191 - hasMore.value = !!data.cursor; 192 - 193 - isFetching.value = false; 42 + async function loadNotifications(reset = false) { 43 + if (!auth.activeDid) return; 44 + await notificationsStore.fetchNotifications(auth.activeDid, reset); 194 45 } 195 46 196 47 async function markAsSeen(until?: string) { 197 - const rpc = await getRpc(); 198 - if (!rpc) return; 199 - 200 - const dateUntil = until ? new Date(until) : new Date(); 201 - dateUntil.setMilliseconds(dateUntil.getMilliseconds() - 1); 202 - readAt.value = dateUntil; 203 - 204 - await rpc.post("app.bsky.notification.updateSeen", { 205 - input: { seenAt: dateUntil.toISOString() }, 206 - as: null, 207 - }); 48 + if (!auth.activeDid) return; 49 + await notificationsStore.markAsSeen(auth.activeDid, until); 208 50 } 209 51 210 - onMounted(async () => { 211 - if (!activeAccount.value) return; 212 - await fetchNotifications(); 52 + onMounted(() => { 53 + loadNotifications(); 213 54 }); 214 55 215 - watch(activeAccount, async (newVal, oldVal) => { 216 - console.log({ newVal, oldVal }); 217 - if (newVal !== oldVal) { 218 - notifications.value = []; 219 - cursor.value = undefined; 220 - hasMore.value = true; 221 - await fetchNotifications(); 222 - } 223 - }); 56 + watch( 57 + () => auth.activeDid, 58 + (newDid, oldDid) => { 59 + if (newDid && newDid !== oldDid) { 60 + const state = notificationsStore.getAccountState(newDid); 61 + const needsReset = state.notifications.length === 0; 62 + loadNotifications(needsReset); 63 + } 64 + }, 65 + ); 224 66 </script> 225 67 226 68 <template> ··· 243 85 <p>You must be logged in to view your notifications.</p> 244 86 </div> 245 87 246 - <NotificationItem 247 - v-for="notification in notifications" 248 - :key="notification.id" 249 - :group="notification" 250 - :seen="new Date(notification.indexedAt) <= (readAt || new Date(0))" 251 - @click="markAsSeen(notification.indexedAt)" 252 - /> 88 + <template v-else> 89 + <div class="error-banner" v-if="error"> 90 + <p>{{ error }}</p> 91 + </div> 92 + 93 + <NotificationItem 94 + v-for="notification in notifications" 95 + :key="notification.id" 96 + :group="notification" 97 + :seen="readAt ? new Date(notification.indexedAt) <= readAt : false" 98 + @click="markAsSeen(notification.indexedAt)" 99 + /> 100 + 101 + <div v-if="hasMore" style="padding: 1rem; display: flex; justify-content: center"> 102 + <Button :disabled="isFetching" @click="loadNotifications(false)"> 103 + {{ isFetching ? "loading..." : "load more" }} 104 + </Button> 105 + </div> 106 + </template> 253 107 </PageLayout> 254 108 </template> 255 109