diff --git a/package.json b/package.json index 55bf1c8a..3c73b551 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.53", + "version": "0.18.54", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/index.html b/src/index.html index 9be5a1b1..3a637b93 100644 --- a/src/index.html +++ b/src/index.html @@ -131,6 +131,7 @@ import chatDetailView from "/js/views/chatDetail.view.js"; import groupChatDetailsView from "/js/views/groupChatDetails.view.js"; import feedsView from "/js/views/feeds.view.js"; + import listsView from "/js/views/lists.view.js"; import profileView from "/js/views/profile.view.js"; import profileFollowersView from "/js/views/profileFollowers.view.js"; import profileFollowingView from "/js/views/profileFollowing.view.js"; @@ -385,6 +386,9 @@ router.addRoute("/feeds", () => feedsView, { layoutOptions: { activeNavItem: "feeds" }, }); + router.addRoute("/lists", () => listsView, { + layoutOptions: { activeNavItem: "lists" }, + }); router.addRoute("/bookmarks", () => bookmarksView, { layoutOptions: { activeNavItem: "bookmarks" }, }); diff --git a/src/js/templates/sidebar.template.js b/src/js/templates/sidebar.template.js index 464b6f90..c601a360 100644 --- a/src/js/templates/sidebar.template.js +++ b/src/js/templates/sidebar.template.js @@ -14,6 +14,7 @@ import { settingsIconTemplate } from "/js/templates/icons/settingsIcon.template. import { notificationsIconTemplate } from "/js/templates/icons/notificationsIcon.template.js"; import { hashtagIconTemplate } from "/js/templates/icons/hashtagIcon.template.js"; import { bookmarkIconTemplate } from "/js/templates/icons/bookmarkIcon.template.js"; +import { listIconTemplate } from "/js/templates/icons/listIcon.template.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { editIconTemplate } from "/js/templates/icons/editIcon.template.js"; import { @@ -201,6 +202,12 @@ export function sidebarTemplate({ label: "Feeds", url: "/feeds", }, + { + id: "lists", + icon: listIconTemplate, + label: "Lists", + url: "/lists", + }, { id: "bookmarks", icon: bookmarkIconTemplate, diff --git a/src/js/views/bookmarks.view.js b/src/js/views/bookmarks.view.js index 950a538a..373b561a 100644 --- a/src/js/views/bookmarks.view.js +++ b/src/js/views/bookmarks.view.js @@ -63,9 +63,15 @@ class BookmarksView extends View { await loadBookmarks(); }); - root.addEventListener("page-restore", (e) => { + root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + window.scrollTo(0, scrollY); + } else { + window.scrollTo(0, 0); + await loadBookmarks({ reload: true }); + } }); } } diff --git a/src/js/views/feedDetail.view.js b/src/js/views/feedDetail.view.js index 4063e2d3..46f315d5 100644 --- a/src/js/views/feedDetail.view.js +++ b/src/js/views/feedDetail.view.js @@ -134,9 +134,15 @@ class FeedDetailView extends View { await loadFeed(); }); - root.addEventListener("page-restore", (e) => { + root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + window.scrollTo(0, scrollY); + } else { + window.scrollTo(0, 0); + await loadFeed({ reload: true }); + } }); } } diff --git a/src/js/views/feeds.view.js b/src/js/views/feeds.view.js index f5b4a8c2..59237e7d 100644 --- a/src/js/views/feeds.view.js +++ b/src/js/views/feeds.view.js @@ -304,9 +304,16 @@ class FeedsView extends View { resetEditingState(); }); - root.addEventListener("page-restore", (e) => { + root.addEventListener("page-restore", async (e) => { + resetEditingState(); const scrollY = e.detail?.scrollY ?? 0; - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + window.scrollTo(0, scrollY); + } else { + window.scrollTo(0, 0); + await dataLayer.requests.loadPinnedItems(); + } }); } } diff --git a/src/js/views/groupChatDetails.view.js b/src/js/views/groupChatDetails.view.js index ec71eb6c..af781ca8 100644 --- a/src/js/views/groupChatDetails.view.js +++ b/src/js/views/groupChatDetails.view.js @@ -207,21 +207,33 @@ class GroupChatDetailsView extends View { ); }); + function loadConvoDetails({ reload = false } = {}) { + return Promise.all([ + dataLayer.requests.loadConvo(convoId).catch((error) => { + console.error("Failed to load convo", error); + }), + dataLayer.requests + .loadConvoMembers(convoId, { reload }) + .catch((error) => { + console.error("Failed to load convo members", error); + }), + ]); + } + root.addEventListener("page-enter", () => { - dataLayer.requests.loadConvo(convoId).catch((error) => { - console.error("Failed to load convo", error); - }); - dataLayer.requests - .loadConvoMembers(convoId, { reload: true }) - .catch((error) => { - console.error("Failed to load convo members", error); - }); + loadConvoDetails({ reload: true }); }); - root.addEventListener("page-restore", (e) => { + root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - if (scrollY > 0) { - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + if (scrollY > 0) { + window.scrollTo(0, scrollY); + } + } else { + window.scrollTo(0, 0); + await loadConvoDetails({ reload: true }); } }); } diff --git a/src/js/views/listDetail.view.js b/src/js/views/listDetail.view.js index ae376291..f1640cec 100644 --- a/src/js/views/listDetail.view.js +++ b/src/js/views/listDetail.view.js @@ -352,9 +352,18 @@ class ListDetailView extends View { ]); }); - root.addEventListener("page-restore", (e) => { + root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + window.scrollTo(0, scrollY); + } else { + window.scrollTo(0, 0); + await Promise.all([ + loadFeed({ reload: true }), + loadMembers({ reload: true }), + ]); + } }); } } diff --git a/src/js/views/lists.view.js b/src/js/views/lists.view.js new file mode 100644 index 00000000..ca26fcb4 --- /dev/null +++ b/src/js/views/lists.view.js @@ -0,0 +1,67 @@ +import { View } from "/js/views/view.js"; +import { html, render } from "/js/lib/lit-html.js"; +import { auth } from "/js/auth.js"; +import { headerTemplate } from "/js/templates/header.template.js"; +import { listFeedTemplate } from "/js/templates/listFeed.template.js"; +import { bindToPage, pageEffect } from "/js/router.js"; + +class ListsView extends View { + async render({ root, layout, context: { dataLayer } }) { + await auth.requireAuth(); + + async function scrollAndReloadLists() { + if (window.scrollY > 0) { + window.scrollTo({ top: -1, behavior: "smooth" }); + } + await loadLists({ reload: true }); + } + + bindToPage(root, layout, "active-nav-click", (event) => { + event.preventDefault(); + scrollAndReloadLists(); + }); + + pageEffect(root, () => { + const currentUser = dataLayer.derived.$currentUser.get(); + const actorLists = currentUser + ? dataLayer.derived.$actorLists.get(currentUser.did) + : null; + + render( + html`
+ ${headerTemplate({ title: "Lists" })} +
+ ${listFeedTemplate({ + lists: actorLists?.lists, + cursor: actorLists?.cursor, + onLoadMore: () => loadLists(), + })} +
+
`, + root, + ); + }); + + async function loadLists({ reload = false } = {}) { + await dataLayer.requests.loadCurrentUserLists({ reload }); + } + + root.addEventListener("page-enter", async () => { + window.scrollTo(0, 0); + await loadLists(); + }); + + root.addEventListener("page-restore", async (e) => { + const scrollY = e.detail?.scrollY ?? 0; + const isBack = e.detail?.isBack ?? false; + if (isBack) { + window.scrollTo(0, scrollY); + } else { + window.scrollTo(0, 0); + await loadLists({ reload: true }); + } + }); + } +} + +export default new ListsView(); diff --git a/src/js/views/postLikes.view.js b/src/js/views/postLikes.view.js index 32c50a1d..b3645e84 100644 --- a/src/js/views/postLikes.view.js +++ b/src/js/views/postLikes.view.js @@ -97,8 +97,14 @@ class PostLikesView extends View { root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - if (scrollY > 0) { - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + if (scrollY > 0) { + window.scrollTo(0, scrollY); + } + } else { + window.scrollTo(0, 0); + await dataLayer.requests.loadPostLikes(postUri); } }); } diff --git a/src/js/views/postQuotes.view.js b/src/js/views/postQuotes.view.js index be355b41..37ba8246 100644 --- a/src/js/views/postQuotes.view.js +++ b/src/js/views/postQuotes.view.js @@ -102,8 +102,14 @@ class PostQuotesView extends View { root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - if (scrollY > 0) { - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + if (scrollY > 0) { + window.scrollTo(0, scrollY); + } + } else { + window.scrollTo(0, 0); + await dataLayer.requests.loadPostQuotes(postUri); } }); } diff --git a/src/js/views/postReposts.view.js b/src/js/views/postReposts.view.js index 3cf94408..334a42f3 100644 --- a/src/js/views/postReposts.view.js +++ b/src/js/views/postReposts.view.js @@ -96,8 +96,14 @@ class PostRepostsView extends View { root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - if (scrollY > 0) { - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + if (scrollY > 0) { + window.scrollTo(0, scrollY); + } + } else { + window.scrollTo(0, 0); + await dataLayer.requests.loadPostReposts(postUri); } }); } diff --git a/src/js/views/profileFollowers.view.js b/src/js/views/profileFollowers.view.js index bab0116b..d8825808 100644 --- a/src/js/views/profileFollowers.view.js +++ b/src/js/views/profileFollowers.view.js @@ -102,8 +102,14 @@ class ProfileFollowersView extends View { root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - if (scrollY > 0) { - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + if (scrollY > 0) { + window.scrollTo(0, scrollY); + } + } else { + window.scrollTo(0, 0); + await dataLayer.requests.loadProfileFollowers(profileDid); } }); } diff --git a/src/js/views/profileFollowing.view.js b/src/js/views/profileFollowing.view.js index c53504bd..083895ed 100644 --- a/src/js/views/profileFollowing.view.js +++ b/src/js/views/profileFollowing.view.js @@ -100,8 +100,14 @@ class ProfileFollowingView extends View { root.addEventListener("page-restore", async (e) => { const scrollY = e.detail?.scrollY ?? 0; - if (scrollY > 0) { - window.scrollTo(0, scrollY); + const isBack = e.detail?.isBack ?? false; + if (isBack) { + if (scrollY > 0) { + window.scrollTo(0, scrollY); + } + } else { + window.scrollTo(0, 0); + await dataLayer.requests.loadProfileFollows(profileDid); } }); } diff --git a/src/js/views/profileKnownFollowers.view.js b/src/js/views/profileKnownFollowers.view.js index 8276d2a5..0053759d 100644 --- a/src/js/views/profileKnownFollowers.view.js +++ b/src/js/views/profileKnownFollowers.view.js @@ -93,8 +93,14 @@ class ProfileKnownFollowersView extends View { root.addEventListener("page-restore", async (event) => { const scrollY = event.detail?.scrollY ?? 0; - if (scrollY > 0) { - window.scrollTo(0, scrollY); + const isBack = event.detail?.isBack ?? false; + if (isBack) { + if (scrollY > 0) { + window.scrollTo(0, scrollY); + } + } else { + window.scrollTo(0, 0); + await dataLayer.requests.loadKnownFollowers(profileDid); } }); } diff --git a/tests/e2e/specs/views/lists.view.test.js b/tests/e2e/specs/views/lists.view.test.js new file mode 100644 index 00000000..d554e975 --- /dev/null +++ b/tests/e2e/specs/views/lists.view.test.js @@ -0,0 +1,96 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { userProfile } from "../../testData.js"; +import { createList } from "../../../shared/factories.js"; + +test.describe("Lists view", () => { + test("should display header and the current user's lists", async ({ + page, + }) => { + const mockServer = new MockServer(); + const list1 = createList({ + uri: `at://${userProfile.did}/app.bsky.graph.list/list1`, + name: "My Curated List", + creatorHandle: userProfile.handle, + }); + const list2 = createList({ + uri: `at://${userProfile.did}/app.bsky.graph.list/list2`, + name: "Other List", + creatorHandle: userProfile.handle, + }); + mockServer.addLists([list1, list2]); + mockServer.addActorLists(userProfile.did, [list1, list2]); + await mockServer.setup(page); + + await login(page); + await page.goto("/lists"); + + const listsView = page.locator("#lists-view"); + await expect( + listsView.locator('[data-testid="header-title"]'), + ).toContainText("Lists", { timeout: 10000 }); + + await expect( + listsView.locator('[data-testid="feeds-list-item-list"]'), + ).toHaveCount(2, { timeout: 10000 }); + + await expect(listsView).toContainText("My Curated List"); + await expect(listsView).toContainText("Other List"); + }); + + test("should show an empty state when the user has no lists", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + + await login(page); + await page.goto("/lists"); + + const listsView = page.locator("#lists-view"); + await expect( + listsView.locator('[data-testid="header-title"]'), + ).toContainText("Lists", { timeout: 10000 }); + await expect(listsView).toContainText("No lists.", { timeout: 10000 }); + }); + + test("should navigate to list detail when clicking a list", async ({ + page, + }) => { + const mockServer = new MockServer(); + const list = createList({ + uri: `at://${userProfile.did}/app.bsky.graph.list/mylist`, + name: "My Curated List", + creatorHandle: userProfile.handle, + }); + mockServer.addLists([list]); + mockServer.addActorLists(userProfile.did, [list]); + await mockServer.setup(page); + + await login(page); + await page.goto("/lists"); + + const listsView = page.locator("#lists-view"); + await expect( + listsView.locator('[data-testid="feeds-list-item-list"]'), + ).toHaveCount(1, { timeout: 10000 }); + + await listsView.locator('[data-testid="feeds-list-item-list"]').click(); + + await expect(page).toHaveURL( + `/profile/${userProfile.handle}/lists/mylist`, + { timeout: 10000 }, + ); + }); + + test.describe("Logged-out behavior", () => { + test("should redirect to /login when not authenticated", async ({ + page, + }) => { + await page.goto("/lists"); + + await expect(page).toHaveURL(/\/login(\?|$)/, { timeout: 10000 }); + }); + }); +});