[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

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

Add "followed you back" notifications

Grace Kind (Mar 31, 2026, 4:28 PM -0500) 6049cbf2 cf6253a7

+134 -4
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.8.22", 3 + "version": "0.8.23", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+13
src/js/atproto.js
··· 79 79 80 80 let lastTimestamp = 0n; 81 81 82 + // Will return null if the rkey is not a TID. 83 + export function getTimestampFromRkey(rkey) { 84 + const noDashes = rkey.replaceAll("-", ""); 85 + if (noDashes.length !== 13) { 86 + return null; 87 + } 88 + let value = 0; 89 + for (const c of noDashes.slice(0, 11)) { 90 + value = value * 32 + TID_ALPHABET.indexOf(c); 91 + } 92 + return value; 93 + } 94 + 82 95 export function generateTid() { 83 96 const nowMicroseconds = BigInt(Date.now()) * 1000n; 84 97 const timestamp =
+25 -3
src/js/views/notifications.view.js
··· 16 16 getImagesFromPost, 17 17 getVideoFromPost, 18 18 isUnavailablePost, 19 + parseUri, 19 20 } from "/js/dataHelpers.js"; 21 + import { getTimestampFromRkey } from "/js/atproto.js"; 20 22 import { notificationsIconTemplate } from "/js/templates/icons/notificationsIcon.template.js"; 21 23 import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 22 24 import { NOTIFICATIONS_PAGE_SIZE } from "/js/config.js"; ··· 114 116 "repost-via-repost", 115 117 ]; 116 118 119 + // Check if you're following the author of the notification, 120 + // and the notification was created after you followed them. 121 + function isFollowBackNotification(notification) { 122 + if (notification.reason !== "follow") return false; 123 + const viewerFollowing = notification.author?.viewer?.following; 124 + if (!viewerFollowing) return false; 125 + const { rkey: followingRkey } = parseUri(viewerFollowing); 126 + const followingTimestamp = getTimestampFromRkey(followingRkey); 127 + if (followingTimestamp === null) return false; 128 + const followedTimestamp = 129 + new Date(notification.record?.createdAt).getTime() * 1000; 130 + return followedTimestamp > followingTimestamp; 131 + } 132 + 117 133 function groupNotificationsForBatch(notifications) { 118 134 const notificationGroups = []; 119 135 120 136 notifications.forEach((notification) => { 121 - const type = notification.reason; 137 + const reason = notification.reason; 122 138 const subject = notification.reasonSubject; 139 + 140 + const isFollowBackNotif = isFollowBackNotification(notification); 141 + const type = isFollowBackNotif ? "follow-back" : reason; 123 142 124 143 const existingGroup = notificationGroups.find( 125 144 (group) => group.type === type && group.subject === subject, ··· 207 226 ${notificationAvatarsTemplate({ notifications })} 208 227 <div class="notification-text"> 209 228 ${notificationProfileNamesTemplate({ notificationGroup })} 210 - followed you <span class="notification-time">· ${timeAgo}</span> 229 + ${notificationGroup.type === "follow-back" 230 + ? "followed you back" 231 + : "followed you"} 232 + <span class="notification-time">· ${timeAgo}</span> 211 233 </div> 212 234 </div> 213 235 </div> ··· 357 379 358 380 function notificationGroupTemplate({ notificationGroup, currentUser }) { 359 381 const { type } = notificationGroup; 360 - if (type === "follow") { 382 + if (type === "follow" || type === "follow-back") { 361 383 return followNotificationTemplate({ notificationGroup }); 362 384 } 363 385 if (type === "like") {
+14
tests/e2e/factories.js
··· 1 + const TID_ALPHABET = "234567abcdefghijklmnopqrstuvwxyz"; 2 + 3 + export function createTid(dateString) { 4 + let tid = BigInt(new Date(dateString).getTime()) * 1000n; 5 + tid = tid << 10n; 6 + let result = ""; 7 + for (let i = 0; i < 13; i++) { 8 + const remainder = tid % 32n; 9 + result = TID_ALPHABET[Number(remainder)] + result; 10 + tid = tid / 32n; 11 + } 12 + return result; 13 + } 14 + 1 15 export function createConvo({ 2 16 id, 3 17 otherMember,
+81
tests/e2e/specs/views/notifications.view.test.js
··· 5 5 createNotification, 6 6 createPost, 7 7 createProfile, 8 + createTid, 8 9 } from "../../factories.js"; 9 10 10 11 const alice = createProfile({ ··· 205 206 await expect(item).toContainText("1 other"); 206 207 await expect(item).toContainText("followed you"); 207 208 await expect(item.locator(".notification-avatar")).toHaveCount(2); 209 + }); 210 + 211 + test("should display 'followed you back' for a follow-back notification", async ({ 212 + page, 213 + }) => { 214 + const yourFollowRkey = createTid("2025-01-10T00:00:00.000Z"); 215 + const followBackAuthor = createProfile({ 216 + ...alice, 217 + viewer: { 218 + ...alice.viewer, 219 + following: `at://did:plc:testuser123/app.bsky.graph.follow/${yourFollowRkey}`, 220 + }, 221 + }); 222 + 223 + const mockServer = new MockServer(); 224 + mockServer.addNotifications([ 225 + createNotification({ 226 + reason: "follow", 227 + author: followBackAuthor, 228 + indexedAt: "2025-01-15T00:00:00.000Z", 229 + record: { 230 + $type: "app.bsky.graph.follow", 231 + subject: "did:plc:testuser123", 232 + createdAt: "2025-01-15T00:00:00.000Z", 233 + }, 234 + }), 235 + ]); 236 + await mockServer.setup(page); 237 + 238 + await login(page); 239 + await page.goto("/notifications"); 240 + 241 + const view = page.locator("#notifications-view"); 242 + const item = view.locator(".notification-item"); 243 + await expect(item).toHaveCount(1, { timeout: 10000 }); 244 + await expect(item).toContainText("Alice"); 245 + await expect(item).toContainText("followed you back"); 246 + }); 247 + 248 + test("should not group follow-back notifications with regular follows", async ({ 249 + page, 250 + }) => { 251 + const yourFollowRkey = createTid("2025-01-10T00:00:00.000Z"); 252 + const followBackAuthor = createProfile({ 253 + ...alice, 254 + viewer: { 255 + ...alice.viewer, 256 + following: `at://did:plc:testuser123/app.bsky.graph.follow/${yourFollowRkey}`, 257 + }, 258 + }); 259 + 260 + const mockServer = new MockServer(); 261 + mockServer.addNotifications([ 262 + createNotification({ 263 + reason: "follow", 264 + author: followBackAuthor, 265 + indexedAt: "2025-01-15T00:00:00.000Z", 266 + record: { 267 + $type: "app.bsky.graph.follow", 268 + subject: "did:plc:testuser123", 269 + createdAt: "2025-01-15T00:00:00.000Z", 270 + }, 271 + }), 272 + createNotification({ 273 + reason: "follow", 274 + author: bob, 275 + indexedAt: "2025-01-15T00:00:00.000Z", 276 + }), 277 + ]); 278 + await mockServer.setup(page); 279 + 280 + await login(page); 281 + await page.goto("/notifications"); 282 + 283 + const view = page.locator("#notifications-view"); 284 + const items = view.locator(".notification-item"); 285 + await expect(items).toHaveCount(2, { timeout: 10000 }); 286 + await expect(items.nth(0)).toContainText("followed you back"); 287 + await expect(items.nth(1)).toContainText("followed you"); 288 + await expect(items.nth(1)).not.toContainText("followed you back"); 208 289 }); 209 290 210 291 test("should display a reply notification as a post", async ({ page }) => {