Skip to content

profile: add gated public profile controls and post expose actions#5501

Open
jamesacklin wants to merge 26 commits intodevelopfrom
jamesacklin/profile-expose
Open

profile: add gated public profile controls and post expose actions#5501
jamesacklin wants to merge 26 commits intodevelopfrom
jamesacklin/profile-expose

Conversation

@jamesacklin
Copy link
Copy Markdown
Member

@jamesacklin jamesacklin commented Feb 19, 2026

Summary

This PR adds public profile controls to the profile editor and wires them to %profile via pokes/scries. It also adds a message action for exposing individual posts to the public profile via the %expose agent, shows a "Publicly viewable" indicator on exposed posts in chat, notebook, and gallery views, and shows a toast notification with clipboard copy of the public URL on expose/hide. All controls are gated behind a Tlon-only experimental feature flag.

Fixes TLON-5380

Changes

  • Added packages/api/src/client/profileApi.ts (exported from @tloncorp/api):

    • getPublicProfileEnabled / setPublicProfileEnabled — bind/unbind the public route via the %profile app; on disable, clears all %expose cites via clearPublicExposeCites
    • getPublicProfileLayout / setPublicProfileWidgetEnabled — scry and poke layout widgets
    • getPublicProfilePostShown / setPublicProfilePostShown — check and toggle per-post expose state via %expose
    • getExposedPostCitesNormalized — fetches all exposed cite paths from the self-contact in one scry, expanding each to all path variants (UD, numeric, normalized)
  • Added contact_exposed_posts table to the DB schema with relations to contacts and posts:

    • getContactExposedCites / getContactExposedPostIds — read queries returning a set of exposed reference paths / post IDs for a given contact
    • setContactExposedCites — write query that replaces all exposed cites for a contact in a single transaction
  • Expose cites are now DB-backed, not live-scried per post render:

    • syncContacts fetches expose cites in parallel with getContacts() via Promise.all (no sequential waterfall)
    • handleContactUpdate re-syncs expose cites whenever the current user's self-contact changes via the /v1/news subscription (these events fire because %expose writes expose-cites back to the %contacts profile on every show/hide poke)
    • useExposedPostCites and useExposedPostIds in dbHooks.ts now read from the DB instead of scrying on every render
  • Extracted usePostExposeState hook (packages/app/hooks/usePostExposeState.ts) that derives expose state and public URL from the DB-backed query results — used by BaseScrollerItem, NotebookPost, and GalleryPost

  • Moved expose state computation out of ChatMessage and up to BaseScrollerItem in Scroller.tsx:

    • isExposed and publicPostUrl are now passed as props to the generic render component
    • Added isExposed to the memo() comparator so only messages whose expose state changed re-render
  • Implemented public profile controls in packages/app/ui/components/EditProfileScreenView.tsx:

    • Toggle public /profile on/off with a persistent public URL field below
    • Toggle widgets: Profile info, Bio, Featured posts, Message me
    • Loads current bound/layout state on mount and reconciles UI state from the layout scry
  • Added PublicProfileExposeAction to ChatMessageActions/MessageActions.tsx:

    • Appears as "Show on public profile" / "Hide from public profile" in the message action sheet
    • Only shown for top-level posts in non-DM channels when the public profile is enabled
    • On expose: calls onExposeSuccess(message, url) which copies the public URL to clipboard and shows a toast: "Post published; URL copied"
    • On hide: calls onExposeSuccess(message) which shows a toast: "Post hidden from public profile"
  • Toast + clipboard handled outside the React Native <Modal> context:

    • onExposeSuccess callback is threaded from Scroller.tsx, ChatMessage.tsx, and contextmenu.tsx down through ChatMessageActionsPropsMessageActionsPublicProfileExposeAction
    • useToast and Clipboard.setString are called at the parent level (outside the modal) where React context is accessible
  • Added "Publicly viewable" byline to exposed posts across all post types:

    • Shows an eye icon + "Publicly viewable" pressable that opens the public expose URL ({shipUrl}/expose/1/chan/...)
    • Chat messages (ChatMessageReplySummary): appears below any top-level exposed post in the message addendum section
    • Notebook posts (NotebookPost): appears in the reply summary footer on the index view and below the header divider in the detail view
    • Gallery posts (GalleryPost): appears in the card footer on the index view and below the author row in the detail view
    • getCurrentShipUrl() in packages/api/src/client/urbit.ts falls back to window.location.origin on web (where config.shipUrl is intentionally empty)
  • Added publicProfileControls feature flag in packages/app/lib/featureFlags.ts (onlyTlon: true, default off)

  • Fixed expose toggle poke payload and toast behavior; switched packages/app imports from @tloncorp/shared/api to @tloncorp/api

  • Added forward DB migration for the contact_exposed_posts table

  • Fixed shared tests for parallel contact scries

How did I test?

Public profile page:

  • Flip the feature flag on if you are a Tlon employee
  • Visit the profile editor
  • Enable your public profile
  • Visit https://your-ship-url.com/profile — confirm you see a page
  • Disable the public profile — confirm the page redirects
  • Re-enable and flip various section widgets on and off
  • Refresh the public profile page and confirm sections appear/disappear

Post expose action:

  • With the public profile enabled, long-press a top-level post in a group channel
  • Confirm "Show on public profile" appears in the action sheet
  • Tap it — confirm the post appears in the Featured posts section on your public profile
  • Long-press again — confirm it now shows "Hide from public profile"
  • Verify the action does not appear for DMs, group DMs, or thread replies

Toast and clipboard:

  • Tap "Show on public profile" — confirm a toast appears: "Post published; URL copied"
  • Confirm the clipboard contains a full absolute URL (e.g. https://yourship.tlon.network/expose/1/chan/...)
  • Tap "Hide from public profile" — confirm a toast appears: "Post hidden from public profile"
  • Test on both web and mobile to confirm the ship URL is correctly included

Publicly viewable byline:

  • With a post exposed, confirm an eye icon + "Publicly viewable" indicator appears:
    • Below a chat message in the addendum area
    • In the footer of a notebook card (index view) and below the header in the notebook detail view
    • In the footer of a gallery card (index view) and below the author row in the gallery detail view
  • Tap the indicator — confirm it opens the correct {shipUrl}/expose/1/chan/... URL
  • Confirm the indicator does not appear for unexposed posts, DMs, or thread replies

Risks and impact

  • Safe to rollback without consulting PR author? Yes
  • Affects important code area:
    • Onboarding
    • State / providers
    • Message sync
    • Channel display
    • Notifications
    • Other: Profile settings UI, message action sheet, and %profile/%expose poke/scry integration

Rollback plan

  • Revert this PR.
  • Confirm EditProfileScreenView no longer renders public profile controls.
  • Confirm MessageActions no longer renders the expose action or calls onExposeSuccess.
  • Confirm ChatMessageReplySummary, NotebookPost, and GalleryPost no longer render the "Publicly viewable" byline.
  • Confirm packages/api/src/client/profileApi.ts exports/usages are removed by revert.
  • Confirm contact_exposed_posts DB table and related queries are removed by revert.

Screenshots / videos

Exposé experience:

Screen.Recording.2026-02-25.at.3.35.36.PM.mov

Profile controls:

image

@jamesacklin jamesacklin changed the title profile: add gated public profile controls profile: add gated public profile controls and post expose actions Feb 25, 2026
@wca4a
Copy link
Copy Markdown

wca4a commented Feb 25, 2026

prob want to reveal the public link when they turn on the public profile - or just keep it persistent below so they always see it - i like the latter

@jamesacklin
Copy link
Copy Markdown
Member Author

prob want to reveal the public link when they turn on the public profile - or just keep it persistent below so they always see it - i like the latter

Done and updated the screenshot in the description. The IP address will be replaced by the user's real ship URL.

@latter-bolden
Copy link
Copy Markdown
Member

Functionality looks good in testing (iOS and web). Still reviewing the code...

Copy link
Copy Markdown
Member

@latter-bolden latter-bolden left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks awesome! I don't have any feedback on the UI code, but we should clean things up at the api, db, and sync layers before merging.

jamesacklin and others added 20 commits February 27, 2026 12:40
Shows a tappable "Publicly viewable" indicator (eye icon) in the
ChatMessageReplySummary addendum for any top-level channel post that
has been exposed on the user's public profile. Tapping opens the
public expose URL in the browser.

- Add getCurrentShipUrl() to urbit API config
- Add getExposedPostCitesNormalized() to profileApi (single self-contact
  scry returns all exposed cite paths at once)
- Add useExposedPostCites() React Query hook (5min stale time, shared
  across all message instances)
- ChatMessage derives isExposed and publicPostUrl, passes to
  ChatMessageReplySummary
- ChatMessageReplySummary renders EyeOpen icon + "Publicly viewable"
  pressable when showPubliclyViewable=true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two fixes:

1. Invalidate ['exposedPostCites'] React Query cache immediately after
   setPublicProfilePostShown succeeds, so the byline appears in the
   chat without waiting for the 5min stale time to expire.

2. Expand each expose cite to all path format variants (UD, numeric,
   normalized) when building the Set in getExposedPostCitesNormalized,
   matching the same variant logic used by getPublicProfilePostShown.
   This prevents mismatches when %expose stores IDs in UD dot-separated
   format while the lookup uses dots-removed numeric format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Show the eye icon + "Publicly viewable" pressable in both the notebook
index (list) view and the notebook detail view for posts that have been
exposed on the user's public profile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Show the eye icon + "Publicly viewable" pressable in both the gallery
index (card footer) view and the gallery detail view for posts that
have been exposed on the user's public profile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a post is exposed or hidden, show a toast message outside the
modal context and copy the public URL to clipboard on expose. Fixes
ship URL being empty on web by falling back to window.location.origin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the public profile toggle is enabled, shows a read-only URL field
with a Copy button inside the same bordered box, followed by the widget
toggles. All controls are now consolidated in a single bordered section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consolidates the expose reference path, isExposed, and publicPostUrl
computations from four separate components into a single reusable hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Relocate usePostExposeState hook from ui/hooks/ to hooks/ (correct location)
- Move hook call from ChatMessage to BaseScrollerItem in Scroller so expose
  state is computed once per item and passed down as props
- Add isExposed/publicPostUrl to RenderItemProps in componentsKits
- Accept isExposed/publicPostUrl as props in ChatMessage (with safe defaults)
- Remove usePostExposeState hook call from ChatMessage internals
- Add isExposed to ChatMessage memo comparison so memoization works correctly
  for messages whose expose state didn't change

This aligns with the reviewer's suggestion to pull the hook up higher in the
tree to gel with the input memoization, and ensures that when expose cites
change only the specific ChatMessage whose isExposed prop changed triggers a
full re-render of the component body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add exposedCites column to contacts table (JSON-encoded string[])
- Add getContactExposedCites read query backed by contacts table
- Add syncExposedCites() to sync.ts; called from syncContacts
- Update useExposedPostCites in dbHooks to read from DB via React Query
- Replace queryClient.invalidateQueries with store.syncExposedCites() in
  MessageActions after expose/unexpose to trigger DB sync

This addresses the reviewer's concern that expose cites data should be read
from the DB rather than fetched live, and ensures React Query can cache and
deduplicate the subscription across all subscribers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ents

- In syncContacts, fetch expose cites via getExposedPostCitesNormalized in
  parallel with getContacts() rather than sequentially inside the writer.
  Both ultimately read from %contacts so there is no extra agent round-trip,
  and the parallel fetch removes the sequential waterfall latency.
- In handleContactUpdate, re-sync expose cites when a self-contact update
  event arrives. %expose writes expose-cites back to %contacts on each
  show/hide poke, which triggers a /v1/news subscription event, so this
  keeps the DB current reactively without any additional polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jamesacklin jamesacklin force-pushed the jamesacklin/profile-expose branch from f1fa0ba to d8c320b Compare February 27, 2026 17:42
jamesacklin and others added 2 commits February 28, 2026 08:39
* Fix expose toggle payload and toast behavior

* Switch app imports to @tloncorp/api

* Add forward migration for exposed posts table

* Fix shared tests for parallel contact scries

---------

Co-authored-by: James Acklin <jamesacklin@Jamess-Mac-mini.local>
@jamesacklin
Copy link
Copy Markdown
Member Author

@latter-bolden should be g2g for a re-review

@linear
Copy link
Copy Markdown

linear bot commented Mar 2, 2026

jamesacklin and others added 2 commits March 3, 2026 21:41
- Extract getExposeReferencePath and useHandleExposeSuccess to shared postUtils
- Replace imperative useEffect in MessageActions with usePostExposeState hook + React Query caching
- Simplify try/catch towers in profileApi with tryScryPaths helper
- Revert Hoon v3 downgrade back to v4 in channel-utils

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Unify duplicate helpers and optimize expose caching
@jamesacklin jamesacklin requested a review from patosullivan March 4, 2026 02:43
jamesacklin and others added 2 commits March 3, 2026 21:50
The Clipboard import in postUtils.tsx broke postUtils.test.ts in CI
because react-native modules can't load in the Vitest environment.
Moved getExposeReferencePath and useHandleExposeSuccess to exposeUtils.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@patosullivan patosullivan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall, one question though.

export function getExposeReferencePath(post: db.Post) {
const [kind, host, channelName] = post.channelId.split('/');
const postId = post.id.replaceAll('.', '');
return `/1/chan/${kind}/${host}/${channelName}/msg/${postId}`;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only works for chat posts (msg, no curio or note), right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good catch - will address

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants