diff --git a/package.json b/package.json
index 3001a197..92c7b022 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "impro",
- "version": "0.17.33",
+ "version": "0.17.34",
"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 2d25b1e8..bb01b8f7 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -1570,6 +1570,12 @@ button.sidebar-plugin-nav-item {
justify-content: center;
}
+container-link {
+ display: block;
+ color: inherit;
+ text-decoration: none;
+}
+
.post {
padding-top: 10px;
padding-left: 16px;
diff --git a/src/index.html b/src/index.html
index 9d6e4f52..a36eb8ab 100644
--- a/src/index.html
+++ b/src/index.html
@@ -379,14 +379,8 @@
if (e.defaultPrevented) {
return;
}
- // Let the browser handle modifier/middle clicks (open in new tab/window)
- if (
- e.metaKey ||
- e.ctrlKey ||
- e.shiftKey ||
- e.altKey ||
- e.button !== 0
- ) {
+ // Let the browser handle alt-click (download)
+ if (e.altKey) {
return;
}
if (anchor.href.startsWith("/")) {
diff --git a/src/js/components/container-link.js b/src/js/components/container-link.js
new file mode 100644
index 00000000..48a35995
--- /dev/null
+++ b/src/js/components/container-link.js
@@ -0,0 +1,58 @@
+import { Component } from "/js/components/component.js";
+
+class ContainerLink extends Component {
+ connectedCallback() {
+ if (this.initialized) return;
+ if (!this.hasAttribute("role")) this.setAttribute("role", "link");
+ if (!this.hasAttribute("tabindex")) this.tabIndex = 0;
+ this.addEventListener("click", this.onClick);
+ this.addEventListener("keydown", this.onKeydown);
+ this.addEventListener("mousedown", this.onMouseDown);
+ this.initialized = true;
+ }
+
+ isNestedInteractive(target) {
+ if (!target || target === this) return false;
+ const interactive = target.closest(
+ "a, button, input, textarea, select, [role=button], [role=link], [contenteditable]",
+ );
+ return interactive && interactive !== this && this.contains(interactive);
+ }
+
+ navigate(event) {
+ const href = this.getAttribute("href");
+ if (!href) return;
+ event.preventDefault();
+ event.stopPropagation();
+ window.router.go(href);
+ }
+
+ hasTextSelectionWithin() {
+ const selection = window.getSelection();
+ if (!selection || selection.isCollapsed) return false;
+ if (selection.toString().length === 0) return false;
+ const anchor = selection.anchorNode;
+ return anchor != null && this.contains(anchor);
+ }
+
+ onClick = (event) => {
+ if (event.defaultPrevented) return;
+ if (this.isNestedInteractive(event.target)) return;
+ if (this.hasTextSelectionWithin()) return;
+ this.navigate(event);
+ };
+
+ onKeydown = (event) => {
+ if (event.key !== "Enter") return;
+ if (event.target !== this) return;
+ this.navigate(event);
+ };
+
+ onMouseDown = (event) => {
+ // Suppress the middle-button autoscroll cursor; the global auxclick
+ // redispatch in router.js will fire our click handler on mouseup.
+ if (event.button === 1) event.preventDefault();
+ };
+}
+
+ContainerLink.register();
diff --git a/src/js/router.js b/src/js/router.js
index 4e84e5da..a718a923 100644
--- a/src/js/router.js
+++ b/src/js/router.js
@@ -208,22 +208,24 @@ export class Router extends EventEmitter {
}
_shouldOpenInNewTab() {
- // If last event was a click or Enter, check for meta key / middle button
+ // If last event was a click or Enter, check for modifier keys / middle button
const event = window.event;
if (!event) return false;
if (event instanceof MouseEvent) {
- return event.metaKey || event.ctrlKey || event.button === 1;
+ return (
+ event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1
+ );
}
return (
event instanceof KeyboardEvent &&
event.key === "Enter" &&
- (event.metaKey || event.ctrlKey)
+ (event.metaKey || event.ctrlKey || event.shiftKey)
);
}
async go(path) {
if (this._shouldOpenInNewTab()) {
- window.open(path, "_blank");
+ window.open(path, "_blank", "noopener");
return;
}
window.history.pushState(
@@ -237,7 +239,7 @@ export class Router extends EventEmitter {
async back() {
if (this._shouldOpenInNewTab()) {
- window.open(this.previousRoute ?? "/", "_blank");
+ window.open(this.previousRoute ?? "/", "_blank", "noopener");
return;
}
if (!!window.history.state?.previousRoute) {
diff --git a/src/js/templates/feedGeneratorListItem.template.js b/src/js/templates/feedGeneratorListItem.template.js
index 28e9b0dd..df3f2da9 100644
--- a/src/js/templates/feedGeneratorListItem.template.js
+++ b/src/js/templates/feedGeneratorListItem.template.js
@@ -1,14 +1,15 @@
import { html } from "/js/lib/lit-html.js";
import { linkToFeed } from "/js/navigation.js";
+import "/js/components/container-link.js";
export function feedGeneratorListItemTemplate({
feedGenerator,
currentUserDid,
}) {
return html`
-
window.router.go(linkToFeed(feedGenerator))}
+ href=${linkToFeed(feedGenerator)}
>
${feedGenerator.avatar
@@ -34,6 +35,6 @@ export function feedGeneratorListItemTemplate({
`
: ""}
-
+
`;
}
diff --git a/src/js/templates/knownFollowersSummary.template.js b/src/js/templates/knownFollowersSummary.template.js
index 057294c1..3d1f76bb 100644
--- a/src/js/templates/knownFollowersSummary.template.js
+++ b/src/js/templates/knownFollowersSummary.template.js
@@ -40,7 +40,6 @@ export function knownFollowersSummaryTemplate({
class="known-followers-summary"
data-testid="known-followers-summary"
href=${linkToProfileKnownFollowers(profile)}
- @click=${(event) => event.stopPropagation()}
>
${avatars.map(
diff --git a/src/js/templates/postEmbed.template.js b/src/js/templates/postEmbed.template.js
index 599dfe37..f108e7f1 100644
--- a/src/js/templates/postEmbed.template.js
+++ b/src/js/templates/postEmbed.template.js
@@ -12,6 +12,7 @@ import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js
import { postLabelsTemplate } from "/js/templates/postLabels.template.js";
import { linkToPost, linkToFeed } from "/js/navigation.js";
import { moderationWarningTemplate } from "/js/templates/moderationWarning.template.js";
+import "/js/components/container-link.js";
import {
OG_CARD_SERVICE_URL,
TENOR_GIF_PROXY_URL,
@@ -154,24 +155,9 @@ export function quotedPostTemplate({
}
}
const postText = quotedPost.value.text || "";
- return html`
{
- // if the click is on an anchor, don't go to the post, but let it bubble up so the router can handle it.
- if (e.target.closest("a")) {
- return;
- }
- e.stopPropagation();
- window.router.go(linkToPost(quotedPost));
- }}
- @keydown=${(e) => {
- if (e.key !== "Enter") return;
- if (e.target.closest("a")) return;
- e.preventDefault();
- window.router.go(linkToPost(quotedPost));
- }}
+ href=${linkToPost(quotedPost)}
>
${mutedWrapperTemplate({
@@ -216,7 +202,7 @@ export function quotedPostTemplate({
`,
})}
-
`;
+ `;
}
const MIN_POST_MEDIA_ASPECT_RATIO = 1 / 2;
diff --git a/src/js/templates/profileFeed.template.js b/src/js/templates/profileFeed.template.js
index 7fb6fd92..5c8c34fe 100644
--- a/src/js/templates/profileFeed.template.js
+++ b/src/js/templates/profileFeed.template.js
@@ -6,6 +6,7 @@ import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBad
import { richTextTemplate } from "/js/templates/richText.template.js";
import { getDisplayName } from "/js/dataHelpers.js";
import { classnames } from "/js/utils.js";
+import "/js/components/container-link.js";
export function profileListItemTemplate({
actor,
@@ -28,11 +29,8 @@ export function profileListItemTemplate({
!isBlocking &&
!isBlockedBy;
const description = actor.description?.trim();
- return html`
{
- if (event.target.closest("a,button")) return;
- window.router.go(linkToProfile(actor));
- }}
+ return html`
@@ -100,7 +98,7 @@ export function profileListItemTemplate({
${richTextTemplate({ text: description })}
`
: ""}
- `;
+ `;
}
export function profileListItemSkeletonTemplate() {
diff --git a/src/js/templates/smallPost.template.js b/src/js/templates/smallPost.template.js
index d1bd98cf..6053c24f 100644
--- a/src/js/templates/smallPost.template.js
+++ b/src/js/templates/smallPost.template.js
@@ -22,6 +22,7 @@ import { notFoundPostTemplate } from "/js/templates/notFoundPost.template.js";
import { unavailablePostTemplate } from "/js/templates/unavailablePost.template.js";
import { moderationWarningTemplate } from "/js/templates/moderationWarning.template.js";
import "/js/components/lightbox-image-group.js";
+import "/js/components/container-link.js";
function contentWarningTemplate({
post,
@@ -97,25 +98,10 @@ export function smallPostTemplate({
doHideAuthorOnUnauthenticated(post.author);
const postText = post.record.text || "";
const content = html`
-
{
- // if the click is on an anchor, don't go to the post, but let it bubble up so the router can handle it.
- if (e.target.closest("a")) {
- return;
- }
- e.stopPropagation();
- window.router.go(linkToPost(post));
- }}
- @keydown=${(e) => {
- if (e.key !== "Enter") return;
- if (e.target.closest("a")) return;
- e.preventDefault();
- window.router.go(linkToPost(post));
- }}
+ href=${linkToPost(post)}
>
@@ -227,7 +213,7 @@ export function smallPostTemplate({
})}
-
+
`;
return content;
diff --git a/src/js/views/chat.view.js b/src/js/views/chat.view.js
index 08a7d8f4..c5ca6fba 100644
--- a/src/js/views/chat.view.js
+++ b/src/js/views/chat.view.js
@@ -15,6 +15,7 @@ import {
import { avatarTemplate } from "/js/templates/avatar.template.js";
import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js";
import "/js/components/infinite-scroll-container.js";
+import "/js/components/container-link.js";
class ChatView extends View {
async render({ root, router, context: { dataLayer, mainLayout } }) {
@@ -37,12 +38,10 @@ class ChatView extends View {
: "";
const isUnread = convo.unreadCount > 0;
return html`
-
{
- router.go(`/messages/${convo.id}`);
- }}
+ href=${`/messages/${convo.id}`}
>
${(() => {
@@ -79,7 +78,7 @@ class ChatView extends View {
: "No messages yet"}
-
+
`;
}
@@ -109,17 +108,15 @@ class ChatView extends View {
(convo) => convo.unreadCount > 0,
);
return html`
- {
- router.go("/messages/inbox");
- }}
+ href="/messages/inbox"
>
→
-
+
`;
}
diff --git a/src/js/views/chatRequests.view.js b/src/js/views/chatRequests.view.js
index bcea379d..d28ed2ef 100644
--- a/src/js/views/chatRequests.view.js
+++ b/src/js/views/chatRequests.view.js
@@ -15,6 +15,7 @@ import { avatarTemplate } from "/js/templates/avatar.template.js";
import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js";
import { knownFollowersSummaryTemplate } from "/js/templates/knownFollowersSummary.template.js";
import { showToast } from "/js/toasts.js";
+import "/js/components/container-link.js";
class ChatRequestsView extends View {
async render({ root, router, context: { dataLayer, mainLayout } }) {
@@ -67,11 +68,9 @@ class ChatRequestsView extends View {
? "request-item-group"
: "request-item-direct"}
>
-
+
${canAccept
? html`
- window.router.go(linkToList(item.data))}
+ href=${linkToList(item.data)}
>
${item.data.avatar
@@ -86,7 +86,7 @@ class FeedsView extends View {
`
: ""}
-
+
`;
}
return feedGeneratorListItemTemplate({
diff --git a/src/js/views/notFound.view.js b/src/js/views/notFound.view.js
index 2f9cc225..a5a201b6 100644
--- a/src/js/views/notFound.view.js
+++ b/src/js/views/notFound.view.js
@@ -8,15 +8,7 @@ class NotFoundView extends View {
html``,
root,
diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js
index bbfaaf01..6fb51d4f 100644
--- a/src/js/views/notifications.view.js
+++ b/src/js/views/notifications.view.js
@@ -29,6 +29,20 @@ import { contactsIconTemplate } from "/js/templates/icons/contactsIcon.template.
import "/js/components/tab-bar.js";
import { NOTIFICATIONS_PAGE_SIZE } from "/js/config.js";
import "/js/components/infinite-scroll-container.js";
+import "/js/components/container-link.js";
+
+function notificationItemTemplate({ href, isUnread, children }) {
+ const unreadClass = isUnread ? "unread" : "";
+ if (href) {
+ return html`
+ ${children}
+ `;
+ }
+ return html`${children}
`;
+}
class NotificationsView extends View {
async render({
@@ -279,22 +293,10 @@ class NotificationsView extends View {
const timeAgo = displayRelativeTime(firstNotif.indexedAt);
const isUnread = !firstNotif.isRead;
const profileLink = linkToProfile(post.author);
- return html`
- {
- // if the click is on an anchor, don't go to the post, but let it bubble up so the router can handle it.
- if (e.target.closest("a")) {
- return;
- }
- if (isUnavailablePost(post)) {
- return;
- }
- window.router.go(linkToPost(post));
- }}
- class="notification-item notification-item-clickable ${isUnread
- ? "unread"
- : ""}"
- >
+ return notificationItemTemplate({
+ href: isUnavailablePost(post) ? null : linkToPost(post),
+ isUnread,
+ children: html`
${notificationsIconTemplate({ filled: true })}
@@ -311,8 +313,8 @@ class NotificationsView extends View {
${postPreviewTemplate({ post: post })}
-
- `;
+ `,
+ });
}
function likeNotificationTemplate({ notificationGroup, isRepost = false }) {
@@ -324,22 +326,10 @@ class NotificationsView extends View {
// Get the liked post for preview
const likedPost = firstNotif.subject;
- return html`
- {
- // if the click is on an anchor, don't go to the post, but let it bubble up so the router can handle it.
- if (e.target.closest("a")) {
- return;
- }
- if (isUnavailablePost(likedPost)) {
- return;
- }
- window.router.go(linkToPost(likedPost));
- }}
- class="notification-item notification-item-clickable ${isUnread
- ? "unread"
- : ""}"
- >
+ return notificationItemTemplate({
+ href: isUnavailablePost(likedPost) ? null : linkToPost(likedPost),
+ isUnread,
+ children: html`
${heartIconTemplate({ filled: true })}
@@ -352,8 +342,8 @@ class NotificationsView extends View {
${postPreviewTemplate({ post: likedPost })}
-
- `;
+ `,
+ });
}
function repostNotificationTemplate({
@@ -367,22 +357,10 @@ class NotificationsView extends View {
// Get the reposted post for preview
const repostedPost = firstNotif.subject;
- return html`
- {
- // if the click is on an anchor, don't go to the post, but let it bubble up so the router can handle it.
- if (e.target.closest("a")) {
- return;
- }
- if (isUnavailablePost(repostedPost)) {
- return;
- }
- window.router.go(linkToPost(repostedPost));
- }}
- class="notification-item notification-item-clickable ${isUnread
- ? "unread"
- : ""}"
- >
+ return notificationItemTemplate({
+ href: isUnavailablePost(repostedPost) ? null : linkToPost(repostedPost),
+ isUnread,
+ children: html`
${repostIconTemplate()}
${notificationAvatarsTemplate({ notifications })}
@@ -393,8 +371,8 @@ class NotificationsView extends View {
${postPreviewTemplate({ post: repostedPost })}
-
- `;
+ `,
+ });
}
function replyNotificationTemplate({ notificationGroup, currentUser }) {
@@ -430,16 +408,10 @@ class NotificationsView extends View {
const { repo, rkey } = subjectUri ? parseUri(subjectUri) : {};
const subjectLink = repo && rkey ? `/profile/${repo}/feed/${rkey}` : null;
- return html`
- {
- if (e.target.closest("a") || !subjectLink) return;
- window.router.go(subjectLink);
- }}
- class="notification-item ${subjectLink
- ? "notification-item-clickable"
- : ""} ${isUnread ? "unread" : ""}"
- >
+ return notificationItemTemplate({
+ href: subjectLink,
+ isUnread,
+ children: html`
${heartIconTemplate({ filled: true })}
@@ -451,8 +423,8 @@ class NotificationsView extends View {
· ${timeAgo}
-
- `;
+ `,
+ });
}
function starterpackJoinedNotificationTemplate({ notificationGroup }) {
@@ -465,16 +437,10 @@ class NotificationsView extends View {
const subjectLink =
repo && rkey ? `/profile/${repo}/starter-pack/${rkey}` : null;
- return html`
- {
- if (e.target.closest("a") || !subjectLink) return;
- window.router.go(subjectLink);
- }}
- class="notification-item ${subjectLink
- ? "notification-item-clickable"
- : ""} ${isUnread ? "unread" : ""}"
- >
+ return notificationItemTemplate({
+ href: subjectLink,
+ isUnread,
+ children: html`
${userIconTemplate({ filled: true })}
@@ -486,8 +452,8 @@ class NotificationsView extends View {
· ${timeAgo}
-
- `;
+ `,
+ });
}
function verifiedNotificationTemplate({ notificationGroup }) {
@@ -547,16 +513,10 @@ class NotificationsView extends View {
firstNotif.author.displayName || firstNotif.author.handle;
const profileLink = linkToProfile(firstNotif.author);
- return html`
- {
- if (e.target.closest("a")) return;
- window.router.go(profileLink);
- }}
- class="notification-item notification-item-clickable ${isUnread
- ? "unread"
- : ""}"
- >
+ return notificationItemTemplate({
+ href: profileLink,
+ isUnread,
+ children: html`
${contactsIconTemplate()}
${notificationAvatarsTemplate({ notifications })}
@@ -573,8 +533,8 @@ class NotificationsView extends View {
· ${timeAgo}
-
- `;
+ `,
+ });
}
function notificationGroupTemplate({ notificationGroup, currentUser }) {
diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js
index 99a804f9..5a312ba6 100644
--- a/src/js/views/profile.view.js
+++ b/src/js/views/profile.view.js
@@ -20,6 +20,7 @@ import { feedGeneratorListItemSkeletonTemplate } from "/js/templates/feedGenerat
import { linkToList } from "/js/navigation.js";
import "/js/components/edit-profile-dialog.js";
import "/js/components/add-to-lists-dialog.js";
+import "/js/components/container-link.js";
class ProfileView extends View {
async render({
@@ -211,10 +212,10 @@ class ProfileView extends View {
function actorListItemTemplate({ list }) {
return html`
- window.router.go(linkToList(list))}
+ href=${linkToList(list)}
>
`
: ""}
-
+
`;
}
diff --git a/src/js/views/search.view.js b/src/js/views/search.view.js
index 1afa092c..9b8ff692 100644
--- a/src/js/views/search.view.js
+++ b/src/js/views/search.view.js
@@ -8,6 +8,7 @@ import { linkToFeed } from "/js/navigation.js";
import { smallPostTemplate } from "/js/templates/smallPost.template.js";
import { pageEffect } from "/js/router.js";
import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js";
+import "/js/components/container-link.js";
import "/js/components/tab-bar.js";
import { profileFeedTemplate } from "/js/templates/profileFeed.template.js";
@@ -255,9 +256,9 @@ class SearchView extends View {
${feedSearchResults.map((feedGenerator) => {
const isPinned = preferences.isFeedPinned(feedGenerator.uri);
return html`
- window.router.go(linkToFeed(feedGenerator))}
+ href=${linkToFeed(feedGenerator)}
>
${feedGenerator.avatar
@@ -304,7 +305,7 @@ class SearchView extends View {
${isPinned ? "Unpin feed" : "Pin feed"}
-
+
`;
})}
${feedSearchHasMore
diff --git a/tests/e2e/specs/concerns/containerLink.test.js b/tests/e2e/specs/concerns/containerLink.test.js
new file mode 100644
index 00000000..16ec0e39
--- /dev/null
+++ b/tests/e2e/specs/concerns/containerLink.test.js
@@ -0,0 +1,79 @@
+import { test, expect } from "../../base.js";
+import { login } from "../../helpers.js";
+import { MockServer } from "../../mockServer.js";
+import { createProfile } from "../../factories.js";
+
+const profileUser = createProfile({
+ did: "did:plc:profileuser1",
+ handle: "profileuser.bsky.social",
+ displayName: "Profile User",
+ followersCount: 1,
+});
+
+const alice = createProfile({
+ did: "did:plc:alice1",
+ handle: "alice.bsky.social",
+ displayName: "Alice",
+});
+
+async function setupFollowersList(page) {
+ const mockServer = new MockServer();
+ mockServer.addProfile(profileUser);
+ mockServer.addProfile(alice);
+ mockServer.addProfileFollowers(profileUser.did, [alice]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto(`/profile/${profileUser.did}/followers`);
+
+ const view = page.locator("#profile-followers-view");
+ await expect(view.locator(".profile-list-item")).toHaveCount(1, {
+ timeout: 10000,
+ });
+ return view;
+}
+
+test.describe("container-link", () => {
+ test("should not navigate in-app on modifier-key click", async ({ page }) => {
+ const view = await setupFollowersList(page);
+ const startUrl = page.url();
+
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
+ await view
+ .locator(".profile-list-item")
+ .first()
+ .click({ modifiers: [modifier] });
+
+ // SPA navigation should have been skipped — URL stays on the followers list
+ await expect(page).toHaveURL(startUrl);
+ await expect(page.locator("#profile-followers-view")).toBeVisible();
+ });
+
+ test("should open a new tab on middle-click", async ({ page, context }) => {
+ const view = await setupFollowersList(page);
+ const startUrl = page.url();
+
+ const newPagePromise = context.waitForEvent("page");
+ await view
+ .locator(".profile-list-item")
+ .first()
+ .click({ button: "middle" });
+ const newPage = await newPagePromise;
+ await newPage.waitForLoadState("domcontentloaded");
+
+ expect(newPage.url()).toContain(`/profile/${alice.handle}`);
+ // The original page stays on the followers list
+ await expect(page).toHaveURL(startUrl);
+ await newPage.close();
+ });
+
+ test("should navigate in-app on a plain left-click", async ({ page }) => {
+ const view = await setupFollowersList(page);
+
+ await view.locator(".profile-list-item").first().click();
+
+ await expect(page).toHaveURL(`/profile/${alice.handle}`, {
+ timeout: 10000,
+ });
+ });
+});
diff --git a/tests/e2e/specs/concerns/postEmbeds.test.js b/tests/e2e/specs/concerns/postEmbeds.test.js
index b04ae227..4a50f18e 100644
--- a/tests/e2e/specs/concerns/postEmbeds.test.js
+++ b/tests/e2e/specs/concerns/postEmbeds.test.js
@@ -411,6 +411,29 @@ test.describe("Post embeds view — quoted post", () => {
await expect(view).toContainText("@author2.bsky.social");
});
+ test("clicking the quoted post navigates to the quoted post detail", async ({
+ page,
+ }) => {
+ const post = buildPost({
+ embed: {
+ $type: "app.bsky.embed.record#view",
+ record: quotedRecord({ text: "The quoted post body" }),
+ },
+ });
+ await setupSinglePostThread(page, post);
+
+ const view = page.locator("#post-detail-view");
+ await expect(view.locator(".quoted-post-link").first()).toBeVisible({
+ timeout: 10000,
+ });
+
+ await view.locator(".quoted-post-link").first().click();
+
+ await expect(page).toHaveURL("/profile/author2.bsky.social/post/quoted1", {
+ timeout: 10000,
+ });
+ });
+
test("renders blocked-quote indicator for a viewBlocked record", async ({
page,
}) => {
diff --git a/tests/e2e/specs/views/chatRequests.view.test.js b/tests/e2e/specs/views/chatRequests.view.test.js
index b42536c1..8e642449 100644
--- a/tests/e2e/specs/views/chatRequests.view.test.js
+++ b/tests/e2e/specs/views/chatRequests.view.test.js
@@ -111,6 +111,41 @@ test.describe("Chat requests view", () => {
).toHaveCount(0);
});
+ test("should navigate to the conversation when clicking the request header", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ const requester = createRequester();
+ const directRequest = createConvo({
+ id: "convo-req-1",
+ otherMember: requester,
+ status: "request",
+ lastMessage: createMessage({
+ id: "msg-req-1",
+ text: "Hey, can we chat?",
+ senderDid: requester.did,
+ }),
+ });
+ mockServer.addConvos([directRequest]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto("/messages/inbox");
+
+ const requestsView = page.locator("#chat-requests-view");
+ const directItem = requestsView.locator(
+ '[data-testid="request-item-direct"]',
+ );
+ await expect(directItem).toHaveCount(1, { timeout: 10000 });
+
+ await directItem.locator(".chat-request-header").click();
+
+ const chatDetailView = page.locator("#chat-detail-view");
+ await expect(
+ chatDetailView.locator('[data-testid="header-title"]'),
+ ).toContainText("Requester One", { timeout: 10000 });
+ });
+
test("should accept a group invite and navigate to the group conversation", async ({
page,
}) => {
diff --git a/tests/e2e/specs/views/profileFollowers.view.test.js b/tests/e2e/specs/views/profileFollowers.view.test.js
index 1541937d..b637070f 100644
--- a/tests/e2e/specs/views/profileFollowers.view.test.js
+++ b/tests/e2e/specs/views/profileFollowers.view.test.js
@@ -58,6 +58,30 @@ test.describe("Profile followers view", () => {
await expect(view).toContainText("Charlie");
});
+ test("should navigate to the profile when clicking a follower row", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.addProfile(profileUser);
+ mockServer.addProfile(alice);
+ mockServer.addProfileFollowers(profileUser.did, [alice]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto(`/profile/${profileUser.did}/followers`);
+
+ const view = page.locator("#profile-followers-view");
+ await expect(view.locator(".profile-list-item")).toHaveCount(1, {
+ timeout: 10000,
+ });
+
+ await view.locator(".profile-list-item").first().click();
+
+ await expect(page).toHaveURL(`/profile/${alice.handle}`, {
+ timeout: 10000,
+ });
+ });
+
test("should display singular 'follower' for count of 1", async ({
page,
}) => {
diff --git a/tests/e2e/specs/views/profileFollowing.view.test.js b/tests/e2e/specs/views/profileFollowing.view.test.js
index fa74ec6f..402dd939 100644
--- a/tests/e2e/specs/views/profileFollowing.view.test.js
+++ b/tests/e2e/specs/views/profileFollowing.view.test.js
@@ -58,6 +58,30 @@ test.describe("Profile following view", () => {
await expect(view).toContainText("Charlie");
});
+ test("should navigate to the profile when clicking a following row", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.addProfile(profileUser);
+ mockServer.addProfile(alice);
+ mockServer.addProfileFollows(profileUser.did, [alice]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto(`/profile/${profileUser.did}/following`);
+
+ const view = page.locator("#profile-following-view");
+ await expect(view.locator(".profile-list-item")).toHaveCount(1, {
+ timeout: 10000,
+ });
+
+ await view.locator(".profile-list-item").first().click();
+
+ await expect(page).toHaveURL(`/profile/${alice.handle}`, {
+ timeout: 10000,
+ });
+ });
+
test("should display empty state when not following anyone", async ({
page,
}) => {
diff --git a/tests/e2e/specs/views/profileKnownFollowers.view.test.js b/tests/e2e/specs/views/profileKnownFollowers.view.test.js
index 5bbdd533..0301701d 100644
--- a/tests/e2e/specs/views/profileKnownFollowers.view.test.js
+++ b/tests/e2e/specs/views/profileKnownFollowers.view.test.js
@@ -60,6 +60,30 @@ test.describe("Profile known followers view", () => {
await expect(view).toContainText("Charlie");
});
+ test("should navigate to the profile when clicking a known follower row", async ({
+ page,
+ }) => {
+ const mockServer = new MockServer();
+ mockServer.addProfile(profileUser);
+ mockServer.addProfile(alice);
+ mockServer.addKnownFollowers(profileUser.did, [alice]);
+ await mockServer.setup(page);
+
+ await login(page);
+ await page.goto(`/profile/${profileUser.did}/known-followers`);
+
+ const view = page.locator("#profile-known-followers-view");
+ await expect(view.locator(".profile-list-item")).toHaveCount(1, {
+ timeout: 10000,
+ });
+
+ await view.locator(".profile-list-item").first().click();
+
+ await expect(page).toHaveURL(`/profile/${alice.handle}`, {
+ timeout: 10000,
+ });
+ });
+
test("should display empty state when no known followers", async ({
page,
}) => {
diff --git a/tests/unit/specs/router.test.js b/tests/unit/specs/router.test.js
index 2420e5e1..948a5d9f 100644
--- a/tests/unit/specs/router.test.js
+++ b/tests/unit/specs/router.test.js
@@ -383,7 +383,7 @@ t.describe("modifier-click navigation", (it, { beforeEach, afterEach }) => {
click({ metaKey: true });
assertEquals(openMock.calls.length, 1);
- assertEquals(openMock.calls[0], ["/meta-test", "_blank"]);
+ assertEquals(openMock.calls[0], ["/meta-test", "_blank", "noopener"]);
assertEquals(navigated, false);
assertEquals(window.location.pathname, "/starting-path");
});
@@ -394,7 +394,7 @@ t.describe("modifier-click navigation", (it, { beforeEach, afterEach }) => {
click({ ctrlKey: true });
- assertEquals(openMock.calls, [["/ctrl-test", "_blank"]]);
+ assertEquals(openMock.calls, [["/ctrl-test", "_blank", "noopener"]]);
});
it("should navigate normally on unmodified click", () => {
@@ -418,7 +418,7 @@ t.describe("modifier-click navigation", (it, { beforeEach, afterEach }) => {
new KeyboardEvent("keydown", { key: "Enter", metaKey: true }),
);
- assertEquals(openMock.calls, [["/enter-test", "_blank"]]);
+ assertEquals(openMock.calls, [["/enter-test", "_blank", "noopener"]]);
});
it("should navigate normally when metaKey is held on a non-Enter key", () => {
@@ -443,7 +443,7 @@ t.describe("modifier-click navigation", (it, { beforeEach, afterEach }) => {
click({ metaKey: true });
- assertEquals(openMock.calls, [["/prior-path", "_blank"]]);
+ assertEquals(openMock.calls, [["/prior-path", "_blank", "noopener"]]);
assertEquals(window.location.pathname, "/here");
});
@@ -454,7 +454,7 @@ t.describe("modifier-click navigation", (it, { beforeEach, afterEach }) => {
click({ metaKey: true });
- assertEquals(openMock.calls, [["/", "_blank"]]);
+ assertEquals(openMock.calls, [["/", "_blank", "noopener"]]);
});
});
@@ -498,7 +498,7 @@ t.describe("middle-click navigation", (it, { beforeEach, afterEach }) => {
middleClick(button);
- assertEquals(openMock.calls, [["/middle-test", "_blank"]]);
+ assertEquals(openMock.calls, [["/middle-test", "_blank", "noopener"]]);
assertEquals(navigated, false);
assertEquals(window.location.pathname, "/starting-path");
});
@@ -510,7 +510,7 @@ t.describe("middle-click navigation", (it, { beforeEach, afterEach }) => {
middleClick(button);
- assertEquals(openMock.calls, [["/prior-path", "_blank"]]);
+ assertEquals(openMock.calls, [["/prior-path", "_blank", "noopener"]]);
assertEquals(window.location.pathname, "/here");
});