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 });
+ });
+ });
+});