From 91ed4dd7528cb65f6fa407ab2a6b52d289828ed9 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Tue, 1 Sep 2026 02:59:16 -0500 Subject: [PATCH] Redirect to full profile url from shortcut --- package.json | 2 +- src/js/app.js | 18 ++++++-- src/js/router.js | 33 ++++++++++---- src/js/views/profile.view.js | 12 ++--- tests/e2e/specs/views/profile.view.test.js | 16 +++++++ tests/unit/specs/router.test.js | 53 ++++++++++++++++++++++ 6 files changed, 114 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 321abc01..44ec0a70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.37", + "version": "0.20.38", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/app.js b/src/js/app.js index 583fa5ab..e5bcf4ba 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -60,6 +60,7 @@ import { effect, untrack } from "/js/signals.js"; import { dispatchNativeRefreshEnded } from "/js/nativeRefresh.js"; import { NOTIFICATIONS_PAGE_SIZE } from "/js/config.js"; import { isInAppLinkHostname } from "/js/dataHelpers.js"; +import { linkToProfile } from "/js/navigation.js"; import { setUpIdentityPrecaching } from "/js/identityPrecaching.js"; import { getAppViewConfig, @@ -334,8 +335,7 @@ export async function main() { "/profile/:handleOrDid/following", () => profileFollowingView, ); - // "/profile" with no param is the current user's profile - router.addRoute(["/profile/:handleOrDid", "/profile"], () => profileView, { + router.addRoute("/profile/:handleOrDid", () => profileView, { layoutOptions: { activeNavItem: (params) => (isOwnProfile(params) ? "profile" : null), isNavItemPage: (params) => isOwnProfile(params), @@ -403,6 +403,16 @@ export async function main() { pluginsRouteOptions, ); router.addRedirects({ + // Current user profile shortcut + "/profile": async () => { + if (!session) return "/"; + try { + const currentUser = await dataLayer.declarative.ensureCurrentUser(); + return linkToProfile(currentUser); + } catch { + return "/"; + } + }, // Old community plugin URLs "/settings/plugins/community": () => "/plugins/community", "/settings/plugins/community/:pluginId": (params) => @@ -494,7 +504,9 @@ export async function main() { } }); - await router.load(window.location.pathname ?? "/"); + router.load(window.location.pathname ?? "/").catch((error) => { + console.error("Error loading initial route:", error); + }); // Sometimes clicks get swallowed after swipe-back navigations on iOS safari // Adding a global touchstart listener fixes this for some reason diff --git a/src/js/router.js b/src/js/router.js index 7688b94e..a6a6b16e 100644 --- a/src/js/router.js +++ b/src/js/router.js @@ -110,6 +110,7 @@ export class Router extends EventEmitter { this.containers = { default: null, bare: null, layout: null }; this.currentPage = null; this.currentPath = null; + this._latestLoadToken = null; this.$currentRoute = new Signal.State(null); this.pages = new BoundedMap(MAX_PAGES, { policy: "lru", @@ -219,7 +220,14 @@ export class Router extends EventEmitter { const params = {}; for (let i = 0; i < routeParts.length; i++) { if (routeParts[i].startsWith(":")) { - params[routeParts[i].slice(1)] = decodeURIComponent(pathParts[i]); + let value; + try { + value = decodeURIComponent(pathParts[i]); + } catch { + // Fall back to the raw segment + value = pathParts[i]; + } + params[routeParts[i].slice(1)] = value; } else { if (pathParts[i] !== routeParts[i]) { return null; @@ -253,14 +261,23 @@ export class Router extends EventEmitter { } async load(path, { isRestore = false } = {}) { + this._latestLoadToken = Symbol("load"); const [pathnameForRedirect, query] = path.split("?"); - const redirectTarget = this.matchRedirect(pathnameForRedirect); - if (redirectTarget !== null) { - const redirectPath = query - ? `${redirectTarget}?${query}` - : redirectTarget; - window.history.replaceState(window.history.state, "", redirectPath); - return this.load(redirectPath, { isRestore }); + const redirectResult = this.matchRedirect(pathnameForRedirect); + if (redirectResult !== null) { + const loadToken = this._latestLoadToken; + // Resolvers can be async, so check that the load token matches after awaiting + const redirectTarget = await redirectResult; + if (this._latestLoadToken !== loadToken) { + return; + } + if (redirectTarget != null) { + const redirectPath = query + ? `${redirectTarget}?${query}` + : redirectTarget; + window.history.replaceState(window.history.state, "", redirectPath); + return this.load(redirectPath, { isRestore }); + } } // Save the scroll position of the page we're leaving before swapping it out if (this.currentPath != null) { diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js index 81df49a9..21f46009 100644 --- a/src/js/views/profile.view.js +++ b/src/js/views/profile.view.js @@ -50,14 +50,10 @@ export default async function profileView({ state.$activeTab = new Signal.State("posts"); const { handleOrDid } = params; - let profileDid = null; - // If no handle or did is provided, use the current user - if (!handleOrDid) { - const currentUser = await dataLayer.declarative.ensureCurrentUser(); - profileDid = currentUser.did; - } else { - profileDid = await resolveDidFromHandleOrDid(handleOrDid, identityResolver); - } + const profileDid = await resolveDidFromHandleOrDid( + handleOrDid, + identityResolver, + ); const { postInteractionHandler, profileInteractionHandler } = interactionHandlers; diff --git a/tests/e2e/specs/views/profile.view.test.js b/tests/e2e/specs/views/profile.view.test.js index 6365d122..127fb29b 100644 --- a/tests/e2e/specs/views/profile.view.test.js +++ b/tests/e2e/specs/views/profile.view.test.js @@ -84,6 +84,22 @@ const labelerView = createLabelerView({ }); test.describe("Profile view", () => { + test("should rewrite the URL to the user's handle when visiting /profile", async ({ + page, + }) => { + const mockServer = new MockServer(); + await mockServer.setup(page); + await login(page); + await page.goto("/profile"); + + const view = page.locator("#profile-view"); + await expect(view.locator('[data-testid="profile-name"]')).toContainText( + userProfile.displayName, + { timeout: 10000 }, + ); + await expect(page).toHaveURL(`/profile/${userProfile.handle}`); + }); + test("should display profile name, handle, and stats", async ({ page }) => { const mockServer = new MockServer(); mockServer.addProfile(otherUser); diff --git a/tests/unit/specs/router.test.js b/tests/unit/specs/router.test.js index e25a5f53..8fd7ecd3 100644 --- a/tests/unit/specs/router.test.js +++ b/tests/unit/specs/router.test.js @@ -207,6 +207,11 @@ describe("matchPath (static method)", () => { assert.deepEqual(params, { handle: "alice/evil", rkey: "abc123" }); }); + it("should fall back to the raw segment for malformed percent-encoding", () => { + const params = Router.matchPath("/profile/%zz", "/profile/:handle"); + assert.deepEqual(params, { handle: "%zz" }); + }); + it("should preserve colons in DID parameters", () => { const params = Router.matchPath( "/profile/did:plc:abc123/post/key456", @@ -236,6 +241,14 @@ describe("match", () => { assert.deepEqual(result.params, { id: "123" }); }); + it("should match with the raw segment for malformed percent-encoding", () => { + const router = new Router(); + router.addRoute("/profile/:handle", () => "view"); + const result = router.match("/profile/%zz"); + assert.deepEqual(result.route, "/profile/:handle"); + assert.deepEqual(result.params, { handle: "%zz" }); + }); + it("should return notFoundView for non-matching path", () => { const router = new Router(); const notFoundView = () => "404"; @@ -1013,6 +1026,46 @@ describe("redirect routes", () => { await router.load("/old"); assert.deepEqual(window.history.state?.previousRoute, "/prior"); }); + + it("awaits an async redirect resolver", async () => { + const router = createRouter(); + router.addRedirects({ "/async-old": async () => "/new" }); + + await router.load("/async-old"); + + assert.deepEqual(window.location.pathname, "/new"); + assert.deepEqual(router.$currentRoute.get().route, "/new"); + }); + + it("falls through to the matched route when an async resolver returns null", async () => { + const router = createRouter(); + router.addRedirects({ "/new": async () => null }); + + await router.load("/new"); + + assert.deepEqual(router.currentPath, "/new"); + assert.deepEqual(router.$currentRoute.get().route, "/new"); + }); + + it("abandons a pending async redirect when another load starts", async () => { + const router = createRouter(); + let resolveRedirect; + router.addRedirects({ + "/slow-old": () => + new Promise((resolve) => { + resolveRedirect = resolve; + }), + }); + + const slowLoad = router.load("/slow-old"); + await router.load("/new"); + resolveRedirect("/new/123"); + await slowLoad; + + assert.deepEqual(router.currentPath, "/new"); + assert.deepEqual(router.$currentRoute.get().route, "/new"); + assert.notDeepEqual(window.location.pathname, "/new/123"); + }); }); describe("route options", () => { -- knotmirror 0.0.1