Skip to content

Conversation

@sagzy
Copy link
Contributor

@sagzy sagzy commented Nov 20, 2025

ref https://linear.app/ghost/project/activitypub-45ebefb7b78b/overview#milestone-ad7b6573-7d09-4c69-930f-f8e50677f0ad

  • In Network > Explore, we can now display a much broader list of sites to discover and follow, categorised by topics

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 20, 2025

Walkthrough

This PR bumps activitypub to 1.1.0, removes the explore-topic feature flag from the feature flags registry, simplifies Layout header border logic to drop the feature-flag dependency, deletes the separate ExploreByFollowing and ExploreByTopic view files, and introduces a consolidated Explore view (with an exported ExploreProfile) that uses topic-based routing and paginated profile fetching with infinite scroll.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Review the new consolidated Explore flow: routing (useParams/navigate), pagination, IntersectionObserver trigger, and data fetch/mutation callbacks.
  • Verify removal of explore-topic flag and that no remaining code expects it.
  • Confirm deleted files' imports/usages are fully updated across the repo.
  • Validate the new exported ExploreProfile signature and its onOpenChange behavior.
  • Check Layout.tsx import cleanup and showBorder logic change for correctness.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title claims to add Explore by topic, but the changes actually remove the explore-topic feature flag and consolidate ExploreByTopic and ExploreByFollowing into a single implementation. Revise the title to accurately reflect the changes, such as 'Consolidate Explore views and remove explore-topic feature flag' or 'Refactor Explore to remove feature flag dependency'.
Description check ❓ Inconclusive No pull request description was provided by the author, making it impossible to evaluate whether the description relates to the changeset. Add a pull request description that explains the motivation, changes, and impact of removing the feature flag and consolidating the Explore views.
✅ Passed checks (1 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ship/explore-by-topic

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d968424 and fd67009.

📒 Files selected for processing (1)
  • apps/activitypub/src/components/layout/Layout.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/activitypub/src/components/layout/Layout.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Setup
  • GitHub Check: Setup
  • GitHub Check: Build & Push

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
apps/activitypub/src/views/Explore/Explore.tsx (4)

43-83: Check click bubbling from FollowButton and skeleton rows

Because the whole row is clickable and navigates to /profile/${profile.handle}, there are two small UX edge cases to verify:

  • Clicking the FollowButton may also trigger the row onClick and navigate to the profile unless FollowButton (or its internal button) stops propagation.
  • In the loading state, skeleton rows still have the same onClick, which will currently navigate to /profile/ (empty handle).

If this is not desired, consider guarding the row click, e.g.:

-        <div
-            className='flex w-full cursor-pointer items-start gap-3 pt-4 [&:last-of-type>:nth-child(2)]:border-none'
-            onClick={() => {
-                navigate(`/profile/${profile.handle}`);
-            }}
-        >
+        <div
+            className='flex w-full cursor-pointer items-start gap-3 pt-4 [&:last-of-type>:nth-child(2)]:border-none'
+            onClick={() => {
+                if (!isLoading && profile.handle) {
+                    navigate(`/profile/${profile.handle}`);
+                }
+            }}
+        >

or ensure FollowButton internally calls event.stopPropagation() on its clicks.


101-107: Normalize/validate the topic route param

const topic: Topic = (params.topic as Topic) || 'top'; trusts whatever is in the URL and casts it to Topic. For unexpected URLs (e.g. manual edits or old links), you can end up with an invalid topic string flowing into useExploreProfilesForUserByTopic and TopicFilter.

If you want a stricter behavior, consider validating against the known topics and falling back to 'top', for example (assuming you expose a topics array from TopicFilter):

const rawTopic = params.topic ?? 'top';
const topic: Topic = TOPICS.includes(rawTopic as Topic) ? (rawTopic as Topic) : 'top';

Not critical, but it would make the route handling more robust to bad URLs.


123-140: IntersectionObserver: optional guard for unsupported environments

The infinite‑scroll IntersectionObserver setup is clean and the cleanup is correct. If you ever run this component in environments without IntersectionObserver (older browsers, some test runners), a small defensive check could avoid runtime errors:

-        const observer = new IntersectionObserver(
+        if (typeof IntersectionObserver === 'undefined') {
+            return;
+        }
+        const observer = new IntersectionObserver(

Not required if your supported environments/polyfills already guarantee IntersectionObserver, but worth considering.


167-201: Loading / empty states for topics

The skeleton state and subsequent infinite‑scroll list look good. One small UX enhancement to consider: when !isLoadingExploreProfiles and profiles.length === 0, users currently see an empty area with just the invisible .load-more-trigger.

You might add an explicit “No accounts to show for this topic yet” style message in that case to make it clear that loading has finished. This can be layered on top of the existing logic without affecting the paging behavior.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4bbf9ab and d968424.

📒 Files selected for processing (6)
  • apps/activitypub/package.json (1 hunks)
  • apps/activitypub/src/components/layout/Layout.tsx (1 hunks)
  • apps/activitypub/src/lib/feature-flags.tsx (1 hunks)
  • apps/activitypub/src/views/Explore/Explore.tsx (1 hunks)
  • apps/activitypub/src/views/Explore/ExploreByFollowing.tsx (0 hunks)
  • apps/activitypub/src/views/Explore/ExploreByTopic.tsx (0 hunks)
💤 Files with no reviewable changes (2)
  • apps/activitypub/src/views/Explore/ExploreByFollowing.tsx
  • apps/activitypub/src/views/Explore/ExploreByTopic.tsx
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-06-10T11:07:10.800Z
Learnt from: kevinansfield
Repo: TryGhost/Ghost PR: 23770
File: apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModalLabs.tsx:435-450
Timestamp: 2025-06-10T11:07:10.800Z
Learning: In Ghost's newsletter customization features, when promoting a feature from alpha to beta status, the feature flag guards are updated to make the feature available under both the `emailCustomization` (beta) and `emailCustomizationAlpha` (alpha) flags. This is done by either removing conditional guards entirely when the component is already behind both flags, or by updating conditionals to check for either flag.

Applied to files:

  • apps/activitypub/src/lib/feature-flags.tsx
📚 Learning: 2025-03-13T09:00:20.205Z
Learnt from: mike182uk
Repo: TryGhost/Ghost PR: 22471
File: apps/admin-x-activitypub/src/utils/pending-activity.ts:13-71
Timestamp: 2025-03-13T09:00:20.205Z
Learning: The pending activity utilities in the Ghost ActivityPub module are covered by tests in the file `apps/admin-x-activitypub/test/unit/utils/pending-activity.ts`.

Applied to files:

  • apps/activitypub/package.json
📚 Learning: 2025-03-13T09:00:20.205Z
Learnt from: mike182uk
Repo: TryGhost/Ghost PR: 22471
File: apps/admin-x-activitypub/src/utils/pending-activity.ts:13-71
Timestamp: 2025-03-13T09:00:20.205Z
Learning: The pending activity utilities in Ghost's ActivityPub module are thoroughly tested in `apps/admin-x-activitypub/test/unit/utils/pending-activity.ts`, which covers `generatePendingActivityId`, `isPendingActivity`, and `generatePendingActivity` functions.

Applied to files:

  • apps/activitypub/package.json
🧬 Code graph analysis (1)
apps/activitypub/src/views/Explore/Explore.tsx (5)
apps/activitypub/src/api/activitypub.ts (2)
  • ExploreAccount (37-40)
  • Account (9-30)
apps/activitypub/src/hooks/use-activity-pub-queries.ts (2)
  • useAccountForUser (1616-1628)
  • useExploreProfilesForUserByTopic (2615-2673)
apps/activitypub/src/hooks/use-navigate-with-base-path.ts (1)
  • useNavigateWithBasePath (15-37)
apps/activitypub/src/utils/content-formatters.ts (2)
  • openLinksInNewTab (94-114)
  • stripHtml (1-53)
apps/activitypub/src/components/TopicFilter.tsx (1)
  • Topic (4-4)
🪛 ast-grep (0.40.0)
apps/activitypub/src/views/Explore/Explore.tsx

[warning] 90-90: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.1.2)
apps/activitypub/src/views/Explore/Explore.tsx

[error] 91-91: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Setup
  • GitHub Check: Setup
  • GitHub Check: Build & Push
🔇 Additional comments (4)
apps/activitypub/package.json (1)

3-3: Version bump aligns with feature scope

Bumping @tryghost/activitypub to 1.1.0 for the Explore consolidation looks appropriate; nothing else in this manifest raises concerns.

apps/activitypub/src/components/layout/Layout.tsx (1)

49-51: Header border condition simplification looks good

Removing the dependency on explore-topic and hiding the border on all explore and reader pages is consistent with the unified Explore flow; no behavioral regressions apparent from this file.

apps/activitypub/src/lib/feature-flags.tsx (1)

5-5: Clearing built‑in feature flags matches flag removal

Setting FEATURE_FLAGS to an empty array cleanly removes explore-topic while keeping the feature‑flag plumbing intact (localStorage + isEnabled). This is a reasonable baseline until new flags are introduced.

apps/activitypub/src/views/Explore/Explore.tsx (1)

21-39: ExploreProfile optimistic follow/unfollow wiring looks solid

Using the update(id, partialAccount) callback to optimistically toggle followedByMe keeps the Explore list in sync with mutations without extra refetches; the isCurrentUser guard prevents self‑follow. This structure looks good.

Comment on lines +86 to +93
{isLoading ?
<Skeleton className='w-full max-w-96' />
:
profile.bio &&
<div
dangerouslySetInnerHTML={{__html: openLinksInNewTab(stripHtml(profile.bio, ['a', 'br']))}}
className='ap-profile-content pointer-events-none mt-0 line-clamp-2 max-w-[460px] break-anywhere'
/>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Re‑evaluate XSS posture around dangerouslySetInnerHTML

You’re sensibly running profile.bio through stripHtml(..., ['a', 'br']) and openLinksInNewTab, which:

  • Strips all tags except <a>/<br>.
  • Normalizes link targets and blocks obviously dangerous protocols.

However, this pipeline does not remove all potentially unsafe attributes on <a> (for example, event handlers like onclick, onfocus, etc.), and the result is injected via dangerouslySetInnerHTML. That leaves you reliant on upstream sanitization for bios coming from ActivityPub.

Depending on your threat model, consider one of:

  • Passing the bio through a dedicated HTML sanitizer (e.g., DOMPurify) on the frontend before dangerouslySetInnerHTML, or
  • Ensuring/confirming that the backend already strips unsafe attributes from bios and documenting that assumption here (and potentially relaxing the linter rule only for this well‑audited case).

Right now, this is more of a security‑posture gap than a clear exploit, but it’s worth double‑checking.


I'll verify the XSS posture by examining the sanitization functions and their implementations.


---

🏁 Script executed:

```shell
#!/bin/bash
# Search for stripHtml function definition
rg -n "stripHtml\s*=" --type ts --type tsx --type js

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Search for openLinksInNewTab function definition
rg -n "openLinksInNewTab\s*=" --type ts --type tsx --type js

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Check package.json for sanitization libraries
cat package.json | grep -A 5 -B 5 -E "dompurify|sanitize|xss" || echo "No sanitizer found in main package.json"

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Find all package.json files
fd package.json

Length of output: 1165



---

🏁 Script executed:

```shell
#!/bin/bash
# Search for stripHtml function definition without type restriction
rg -n "stripHtml\s*=" --context 3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for openLinksInNewTab function definition
rg -n "openLinksInNewTab\s*=" --context 3

Length of output: 573


🏁 Script executed:

#!/bin/bash
# Check imports in Explore.tsx file
cat apps/activitypub/src/views/Explore/Explore.tsx | head -50

Length of output: 2092


🏁 Script executed:

#!/bin/bash
# Check activitypub package.json for sanitization dependencies
cat apps/activitypub/package.json | grep -E "dompurify|sanitize|xss|dependencies|devDependencies" -A 20

Length of output: 733


🏁 Script executed:

#!/bin/bash
# Search where these functions are imported from in all activitypub files
rg "stripHtml|openLinksInNewTab" apps/activitypub/src --context 2

Length of output: 12888


🏁 Script executed:

#!/bin/bash
# Search for backend ActivityPub profile or bio handling
rg -i "profile.*bio|bio.*sanitiz" --max-count 20

Length of output: 159155



---

🏁 Script executed:

```shell
#!/bin/bash
# View the complete content-formatters.ts file
cat apps/activitypub/src/utils/content-formatters.ts

Length of output: 4662


🏁 Script executed:

#!/bin/bash
# Search for any ActivityPub bio sanitization backend code
rg -i "bio" apps/activitypub/src --type ts -C 2 | head -100

Length of output: 8062


🏁 Script executed:

#!/bin/bash
# Check if there's any backend sanitization in ghost/core for ActivityPub
rg -i "activitypub.*bio\|profile.*sanitize" ghost/core --type ts --type js | head -20

Length of output: 0


Confirm: unsafe event handler attributes on whitelisted tags are not stripped

The review's concern is valid. The stripHtml function preserves excluded tags like <a> with all their attributes intact, and openLinksInNewTab only modifies the href, target, and rel attributes—it does not remove event handler attributes like onclick, onfocus, onerror, or onload. An attacker-controlled ActivityPub bio containing <a onclick="malicious()">text</a> would pass through the pipeline unchanged and execute when rendered. The pointer-events-none CSS class provides no XSS mitigation—it only prevents pointer events and can be overridden.

The pipeline is reliant on upstream ActivityPub sanitization, which should be documented and audited.

🧰 Tools
🪛 ast-grep (0.40.0)

[warning] 90-90: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.1.2)

[error] 91-91: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

@sagzy sagzy merged commit c8d3caa into main Nov 20, 2025
30 of 31 checks passed
@sagzy sagzy deleted the ship/explore-by-topic branch November 20, 2025 10:51
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.

3 participants