diff --git a/package.json b/package.json index bd4105ea..40209ab6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.54", + "version": "0.20.55", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 57c98332..543811b9 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1974,6 +1974,10 @@ button.sidebar-plugin-nav-item { text-align: center; } +.notifications-load-more-error { + padding: 32px 16px; +} + .feed-feedback-message { font-size: 13px; color: var(--text-color-muted); diff --git a/src/js/templates/tryAgainButton.template.js b/src/js/templates/tryAgainButton.template.js index 07e93792..680f4bac 100644 --- a/src/js/templates/tryAgainButton.template.js +++ b/src/js/templates/tryAgainButton.template.js @@ -1,10 +1,12 @@ import { html } from "/js/lib/lit-html.js"; import "/js/components/app-icon.js"; -export function tryAgainButtonTemplate() { +export function tryAgainButtonTemplate({ + onClick = () => window.location.reload(), +} = {}) { return html``; diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js index a9a80faf..fb79f18c 100644 --- a/src/js/views/notifications.view.js +++ b/src/js/views/notifications.view.js @@ -33,6 +33,7 @@ import { NOTIFICATIONS_PAGE_SIZE } from "/js/config.js"; import "/js/components/infinite-scroll-container.js"; import "/js/components/container-link.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; +import { showToast } from "/js/toasts.js"; function notificationItemTemplate({ href, isUnread, children }) { const unreadClass = isUnread ? "unread" : ""; @@ -145,6 +146,8 @@ export default async function notificationsView({ state.$activeTab = new Signal.State("all"); state.$isReloadingNotifications = new Signal.State(false); state.$isReloadingMentionNotifications = new Signal.State(false); + state.$loadMoreError = new Signal.State(null); + state.$mentionLoadMoreError = new Signal.State(null); async function handleMenuClick() { layout.openSidebar(); @@ -662,11 +665,22 @@ export default async function notificationsView({ `; } + function loadMoreErrorTemplate({ onRetry }) { + return html`
+
There was an error loading notifications.
+ ${tryAgainButtonTemplate({ onClick: onRetry })} +
`; + } + function notificationsTemplate({ groupedNotifications, hasMore, currentUser, loadMore, + loadMoreError, }) { if (groupedNotifications.length === 0) { return html`
@@ -676,6 +690,7 @@ export default async function notificationsView({ try { return html` { if (hasMore) { await loadMore(); @@ -686,9 +701,17 @@ export default async function notificationsView({ ${groupedNotifications.map((notificationGroup) => notificationGroupTemplate({ notificationGroup, currentUser }), )} - ${!hasMore - ? html`
No more notifications
` - : Array.from({ length: 10 }).map(() => postSkeletonTemplate())} + ${(() => { + if (loadMoreError) { + return loadMoreErrorTemplate({ onRetry: loadMore }); + } + if (!hasMore) { + return html`
+ No more notifications +
`; + } + return Array.from({ length: 10 }).map(() => postSkeletonTemplate()); + })()}
`; } catch (error) { @@ -704,14 +727,14 @@ export default async function notificationsView({ if (state.$activeTab.get() === "all") { state.$isReloadingNotifications.set(true); try { - await loadNotifications({ reload: true }); + await reloadNotifications(); } finally { state.$isReloadingNotifications.set(false); } } else { state.$isReloadingMentionNotifications.set(true); try { - await loadMentionNotifications({ reload: true }); + await reloadMentionNotifications(); } finally { state.$isReloadingMentionNotifications.set(false); } @@ -786,7 +809,7 @@ export default async function notificationsView({
${(() => { - if (notificationsRequestStatus.error) { + if (notificationsRequestStatus.error && !groupedNotifications) { return notificationsErrorTemplate({ error: notificationsRequestStatus.error, }); @@ -795,7 +818,8 @@ export default async function notificationsView({ groupedNotifications, currentUser, hasMore, - loadMore: loadNotifications, + loadMore: loadMoreNotifications, + loadMoreError: state.$loadMoreError.get(), }); } else { return notificationsSkeletonTemplate(); @@ -804,7 +828,10 @@ export default async function notificationsView({
${(() => { - if (mentionNotificationsRequestStatus.error) { + if ( + mentionNotificationsRequestStatus.error && + !groupedMentionNotifications + ) { return notificationsErrorTemplate({ error: mentionNotificationsRequestStatus.error, }); @@ -813,7 +840,8 @@ export default async function notificationsView({ groupedNotifications: groupedMentionNotifications, currentUser, hasMore: mentionHasMore, - loadMore: loadMentionNotifications, + loadMore: loadMoreMentionNotifications, + loadMoreError: state.$mentionLoadMoreError.get(), }); } else if (activeTab === "mentions") { return notificationsSkeletonTemplate(); @@ -850,10 +878,50 @@ export default async function notificationsView({ }); } + async function loadMoreNotifications() { + await loadNotifications(); + const loadError = + dataLayer.requests.statusStore.getError("loadNotifications"); + state.$loadMoreError.set(loadError); + } + + async function loadMoreMentionNotifications() { + await loadMentionNotifications(); + const loadError = dataLayer.requests.statusStore.getError( + "loadMentionNotifications", + ); + state.$mentionLoadMoreError.set(loadError); + } + + async function reloadNotifications() { + const hadNotifications = !!dataLayer.derived.$notifications.get(); + await loadNotifications({ reload: true }); + const loadError = + dataLayer.requests.statusStore.getError("loadNotifications"); + if (hadNotifications && loadError) { + showToast("Couldn't refresh notifications", { style: "error" }); + } else { + state.$loadMoreError.set(null); + } + } + + async function reloadMentionNotifications() { + const hadNotifications = !!dataLayer.derived.$mentionNotifications.get(); + await loadMentionNotifications({ reload: true }); + const loadError = dataLayer.requests.statusStore.getError( + "loadMentionNotifications", + ); + if (hadNotifications && loadError) { + showToast("Couldn't refresh notifications", { style: "error" }); + } else { + state.$mentionLoadMoreError.set(null); + } + } + onPageShow(root, async ({ scrollY }) => { if (scrollY <= 200) { window.scrollTo(0, 0); - await loadNotifications({ reload: true }); + await reloadNotifications(); } }); } diff --git a/tests/e2e/specs/views/notifications.view.test.js b/tests/e2e/specs/views/notifications.view.test.js index 01ab9085..d3fbc8de 100644 --- a/tests/e2e/specs/views/notifications.view.test.js +++ b/tests/e2e/specs/views/notifications.view.test.js @@ -1130,6 +1130,124 @@ test.describe("Notifications view", () => { ); }); + function createPagedLikeNotifications(count) { + const likedPosts = []; + const notifications = []; + for (let i = 1; i <= count; i++) { + const post = createPost({ + uri: `at://did:plc:testuser123/app.bsky.feed.post/notifpost${i}`, + text: `Notification post ${i}`, + authorHandle: "testuser.bsky.social", + authorDisplayName: "Test User", + }); + likedPosts.push(post); + notifications.push( + createNotification({ + reason: "like", + author: createProfile({ + did: `did:plc:liker${i}`, + handle: `liker${i}.bsky.social`, + displayName: `Liker ${i}`, + }), + reasonSubject: post.uri, + indexedAt: new Date(Date.now() - i * 60000).toISOString(), + }), + ); + } + return { likedPosts, notifications }; + } + + async function failListNotifications(page, { onlyWithCursor }) { + let failing = true; + await page.route( + "**/xrpc/app.bsky.notification.listNotifications*", + (route) => { + const hasCursor = !!new URL(route.request().url()).searchParams.get( + "cursor", + ); + if (!failing || (onlyWithCursor && !hasCursor)) { + return route.fallback(); + } + return route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "InternalServerError" }), + }); + }, + ); + return { + stop() { + failing = false; + }, + }; + } + + test("should show an inline error with retry when loading more fails", async ({ + page, + }) => { + const { likedPosts, notifications } = createPagedLikeNotifications(60); + const mockServer = new MockServer(); + mockServer.addPosts(likedPosts); + mockServer.addNotifications(notifications); + await mockServer.setup(page); + const failure = await failListNotifications(page, { onlyWithCursor: true }); + + await login(page); + await page.goto("/notifications"); + + const view = page.locator("#notifications-view"); + const items = view.locator(".notification-item"); + await expect(items.first()).toBeVisible({ timeout: 10000 }); + const initialCount = await items.count(); + + await items.last().scrollIntoViewIfNeeded(); + + const loadMoreError = view.locator( + '[data-testid="notifications-load-more-error"]', + ); + await expect(loadMoreError).toBeVisible({ timeout: 10000 }); + await expect(items).toHaveCount(initialCount); + + failure.stop(); + await loadMoreError.locator(".try-again-button").click(); + + await expect(items).toHaveCount(60, { timeout: 10000 }); + await expect(loadMoreError).toHaveCount(0); + }); + + test("should keep notifications and show a toast when a refresh fails", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.addNotifications([ + createNotification({ + reason: "follow", + author: alice, + indexedAt: new Date().toISOString(), + }), + ]); + await mockServer.setup(page); + + await login(page); + await page.goto("/notifications"); + + const view = page.locator("#notifications-view"); + const items = view.locator(".notification-item"); + await expect(items).toHaveCount(1, { timeout: 10000 }); + + await failListNotifications(page, { onlyWithCursor: false }); + await view.locator(".tab-bar-button").nth(0).click(); + + await expect(page.locator('[data-testid="toast"]')).toBeVisible({ + timeout: 10000, + }); + await expect(items).toHaveCount(1); + await expect( + view.locator('[data-testid="notifications-load-more-error"]'), + ).toHaveCount(0); + await expect(view.locator(".error-state")).toHaveCount(0); + }); + test.describe("Logged-out behavior", () => { test("should redirect to /login when not authenticated", async ({ page, @@ -1343,6 +1461,109 @@ test.describe("Notifications view", () => { await expect(view).toContainText("A mention post"); }); + function createPagedMentionNotifications(count) { + const mentionPosts = []; + const notifications = []; + for (let i = 1; i <= count; i++) { + const uri = `at://did:plc:bob1/app.bsky.feed.post/mention${i}`; + const text = `Mention post ${i}`; + mentionPosts.push( + createPost({ + uri, + text, + authorHandle: "bob.bsky.social", + authorDisplayName: "Bob", + }), + ); + notifications.push( + createNotification({ + reason: "mention", + author: bob, + uri, + record: { + $type: "app.bsky.feed.post", + text, + createdAt: new Date().toISOString(), + }, + indexedAt: new Date(Date.now() - i * 60000).toISOString(), + }), + ); + } + return { mentionPosts, notifications }; + } + + test("should show an inline error with retry when loading more mentions fails", async ({ + page, + }) => { + const { mentionPosts, notifications } = + createPagedMentionNotifications(60); + const mockServer = new MockServer(); + mockServer.addPosts(mentionPosts); + mockServer.addNotifications(notifications); + await mockServer.setup(page); + const failure = await failListNotifications(page, { + onlyWithCursor: true, + }); + + await login(page); + await page.goto("/notifications"); + + const view = page.locator("#notifications-view"); + await view.locator(".tab-bar-button").nth(1).click(); + + const activePanel = view.locator(".notifications-feed:not([hidden])"); + const items = activePanel.locator(".notification-reply-wrapper"); + await expect(items.first()).toBeVisible({ timeout: 10000 }); + const initialCount = await items.count(); + + await items.last().scrollIntoViewIfNeeded(); + + const loadMoreError = activePanel.locator( + '[data-testid="notifications-load-more-error"]', + ); + await expect(loadMoreError).toBeVisible({ timeout: 10000 }); + await expect(items).toHaveCount(initialCount); + + failure.stop(); + await loadMoreError.locator(".try-again-button").click(); + + await expect(items).toHaveCount(60, { timeout: 10000 }); + await expect(loadMoreError).toHaveCount(0); + }); + + test("should keep mentions and show a toast when a refresh fails", async ({ + page, + }) => { + const { mentionPosts, notifications } = + createPagedMentionNotifications(1); + const mockServer = new MockServer(); + mockServer.addPosts(mentionPosts); + mockServer.addNotifications(notifications); + await mockServer.setup(page); + + await login(page); + await page.goto("/notifications"); + + const view = page.locator("#notifications-view"); + await view.locator(".tab-bar-button").nth(1).click(); + + const activePanel = view.locator(".notifications-feed:not([hidden])"); + const items = activePanel.locator(".notification-reply-wrapper"); + await expect(items).toHaveCount(1, { timeout: 10000 }); + + await failListNotifications(page, { onlyWithCursor: false }); + await view.locator(".tab-bar-button").nth(1).click(); + + await expect(page.locator('[data-testid="toast"]')).toBeVisible({ + timeout: 10000, + }); + await expect(items).toHaveCount(1); + await expect( + activePanel.locator('[data-testid="notifications-load-more-error"]'), + ).toHaveCount(0); + await expect(activePanel.locator(".error-state")).toHaveCount(0); + }); + test("should reload when clicking the already-active tab", async ({ page, }) => {