diff --git a/packages/ui/README.md b/packages/ui/README.md
index d41b2a3..1e884c7 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -51,6 +51,12 @@ repo, so the first time you open it by URI the app writes your own `join` record
from then on. Reading a space needs no account at all: the picker also accepts a bare space URI,
and includes the fixture (a built-in demo space) — both work signed out.
+Public links carry the space record's AT URI in a `space` query parameter, so a logged-out visitor
+can open the linked public space directly. Opening does not join the space: after sign-in, the
+existing join bookmark is written only for an active member. Goal links minted before this parameter
+remain recoverable because the public goal record names its space. Older project and space-wide list
+links are ambiguous and return to the picker instead of guessing from this browser's history.
+
Every read is `com.atproto.repo.listRecords` against a member's own PDS. Records are cached in
IndexedDB, so a warm reload costs one `getLatestCommit` per member and no rescan. While the tab is
visible it polls every ten seconds; hidden, it stops and catches up when it becomes visible again.
diff --git a/packages/ui/src/edge/target.test.ts b/packages/ui/src/edge/target.test.ts
index e4d3b39..62fcb8d 100644
--- a/packages/ui/src/edge/target.test.ts
+++ b/packages/ui/src/edge/target.test.ts
@@ -7,6 +7,7 @@ const GOAL = 'at://did:plc:n6ku5xddiuguwze3f356evla/com.disnetdev.radial.goal/3m
const ARTIFACT = 'at://did:plc:n6ku5xddiuguwze3f356evla/com.disnetdev.radial.artifact/plan-abc'
const REQUEST =
'at://did:plc:n6ku5xddiuguwze3f356evla/com.disnetdev.radial.artifactRequest/3mrvv5kltnp2o'
+const SPACE = 'at://did:plc:n6ku5xddiuguwze3f356evla/com.disnetdev.radial.space/3mrvspace'
describe('a goal deep link', () => {
it('reconstructs the AT URI the route was minted from', () => {
@@ -88,4 +89,15 @@ describe('the canonical URL', () => {
'https://radl.app/p/radial',
)
})
+
+ it('retains only valid space navigation context', () => {
+ const valid = at(`/p/radial?space=${encodeURIComponent(SPACE)}&v=2&utm_source=chat`)
+ expect(canonicalUrl(valid, previewTarget(valid))).toBe(
+ `https://radl.app/p/radial?space=${encodeURIComponent(SPACE)}`,
+ )
+ for (const value of ['nope', GOAL, 'https://example.com']) {
+ const malformed = at(`/p/radial?space=${encodeURIComponent(value)}`)
+ expect(canonicalUrl(malformed, previewTarget(malformed))).toBe('https://radl.app/p/radial')
+ }
+ })
})
diff --git a/packages/ui/src/edge/target.ts b/packages/ui/src/edge/target.ts
index 101b9a4..6134316 100644
--- a/packages/ui/src/edge/target.ts
+++ b/packages/ui/src/edge/target.ts
@@ -14,6 +14,7 @@
*/
import { COLLECTIONS } from '@radial/core'
+import { parseAtUri } from '@radial/atproto'
export type PreviewTarget =
| { kind: 'goal'; goal: string; unit?: string }
@@ -72,5 +73,11 @@ function decodeSegment(segment: string): string {
export function canonicalUrl(url: URL, target: PreviewTarget | undefined): string {
const canonical = new URL(url.pathname, url.origin)
if (target?.kind === 'goal' && target.unit) canonical.searchParams.set('unit', target.unit)
+ const space = url.searchParams.get('space')
+ try {
+ if (space && parseAtUri(space).collection === COLLECTIONS.space) canonical.searchParams.set('space', space)
+ } catch {
+ // Navigation context is untrusted input. A malformed value is not canonical.
+ }
return canonical.toString()
}
diff --git a/packages/ui/src/lib/asks.test.ts b/packages/ui/src/lib/asks.test.ts
index 7140a4c..da50966 100644
--- a/packages/ui/src/lib/asks.test.ts
+++ b/packages/ui/src/lib/asks.test.ts
@@ -158,9 +158,14 @@ describe('where a row goes', () => {
// drawer opens on what is being read rather than following the tip of whatever lands next.
const auto = ask(space.index, AUTO_REVIEW)
if (!auto.pinned) throw new Error('fixture ask lost its version')
- expect(unitHref(goalHref(goal(IGNORED)), auto.pinned.unit, auto.pinned.version)).toMatch(
- /^\/g\/[^/]+\/[^/]+\?unit=.+&v=1$/,
+ const href = new URL(
+ unitHref(goalHref(goal(IGNORED)), auto.pinned.unit, auto.pinned.version),
+ 'https://radl.app',
)
+ expect(href.pathname).toMatch(/^\/g\/[^/]+\/[^/]+$/)
+ expect(href.searchParams.get('space')).toBe(space.uri)
+ expect(href.searchParams.get('unit')).toBe(auto.pinned.unit.key)
+ expect(href.searchParams.get('v')).toBe('1')
const claimed = ask(space.index, CLAIMED_REVIEW)
const project = space.index.projects.find(
@@ -169,9 +174,14 @@ describe('where a row goes', () => {
if (!claimed.pinned || !project) throw new Error('fixture ask lost its version')
// Its third version, and the System page rather than a goal's.
expect(claimed.pinned.version.version).toBe(3)
- expect(unitHref(systemHref(project), claimed.pinned.unit, claimed.pinned.version)).toBe(
- `${systemHref(project)}?unit=${encodeURIComponent(claimed.pinned.unit.key)}&v=3`,
+ const systemUnit = new URL(
+ unitHref(systemHref(project), claimed.pinned.unit, claimed.pinned.version),
+ 'https://radl.app',
)
+ expect(systemUnit.pathname).toBe('/p/radial-ng/system')
+ expect(systemUnit.searchParams.get('space')).toBe(space.uri)
+ expect(systemUnit.searchParams.get('unit')).toBe(claimed.pinned.unit.key)
+ expect(systemUnit.searchParams.get('v')).toBe('3')
})
it('opens a reply in the thread, because that is where the box to answer it is', () => {
@@ -180,7 +190,7 @@ describe('where a row goes', () => {
const reply = ask(space.index, MESSAGE_ASK)
expect(reply.pinned).toBeUndefined()
expect(isGoalView(reply.target)).toBe(true)
- expect(goalHref({ uri: reply.target.target.uri })).toBe(goalHref(goal(QUEUE)))
+ expect(goalHref({ uri: reply.target.target.uri }, space.uri)).toBe(goalHref(goal(QUEUE)))
})
})
diff --git a/packages/ui/src/lib/components/NewGoal.svelte b/packages/ui/src/lib/components/NewGoal.svelte
index f172e8d..4ed6ac5 100644
--- a/packages/ui/src/lib/components/NewGoal.svelte
+++ b/packages/ui/src/lib/components/NewGoal.svelte
@@ -9,7 +9,7 @@
newGoalArgs,
rememberGoalProject,
} from '$lib/goal.js'
- import { goalUriHref, projectByUri, SPACE_HREF, type Space } from '$lib/space.js'
+ import { goalUriHref, projectByUri, spaceHref, type Space } from '$lib/space.js'
import { ui } from '$lib/ui.svelte.js'
import { write } from '$lib/write.js'
import Glyph from './Glyph.svelte'
@@ -96,7 +96,7 @@
closeDraft()
// `write` has already folded the PDS-acknowledged record, so the goal is in the index this
// navigates into — no empty page waiting for the next tick.
- await goto(goalUriHref(result.primary.uri))
+ await goto(goalUriHref(result.primary.uri, space.uri))
} catch (failure) {
error = failure instanceof Error ? failure.message : String(failure)
} finally {
@@ -125,12 +125,12 @@
{#if space.index.projects.length === 0}