Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/activity-feed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# SolFoundry WebSocket Activity Feed

Production-oriented reference implementation for GitHub issue `#860`.

## Structure

- `server/`: Express + Socket.io backend with room-based subscriptions, throttled broadcasting, and polling endpoint.
- `client/`: React + TypeScript feed UI with resilient connection management.
- `shared/`: Common event contracts and payload types.
- `docs/`: API and architecture notes.

## Run locally

```bash
npm install
npm run build
npm run dev
```

Server defaults to `http://localhost:4000`.
Client defaults to `http://localhost:5173`.

## Key capabilities

- Real-time broadcasting for bounty, submission, review, and leaderboard events
- Room-based filtering by activity type, actor, and bounty
- Notification preference syncing
- Exponential backoff reconnect strategy with HTTP polling fallback
- Throttled event flush and lightweight rate limiting
12 changes: 12 additions & 0 deletions packages/activity-feed/client/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SolFoundry Activity Feed</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
27 changes: 27 additions & 0 deletions packages/activity-feed/client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@solfoundry/activity-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx"

Copy link
Copy Markdown

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

🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in packages/activity-feed/client/package.json packages/activity-feed/server/package.json packages/activity-feed/package.json; do
  echo "== $f =="
  jq -r '{
    lint_script: (.scripts.lint // "<none>"),
    eslint_dependency: (.devDependencies.eslint // .dependencies.eslint // "<missing>")
  }' "$f"
done

Repository: SolFoundry/solfoundry

Length of output: 477


Lint script references eslint, but the package does not declare eslint in devDependencies.

Line 11 executes eslint src --ext .ts,.tsx, but eslint is missing from the devDependencies section (lines 19-25). In a clean install, npm run lint -w client will fail during CI, blocking the root lint orchestration at packages/activity-feed/package.json line 13 which invokes workspace lint commands. The same issue affects packages/activity-feed/server/package.json line 11, which also references eslint without declaring the dependency.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/activity-feed/client/package.json` at line 11, The "lint" npm script
in packages/activity-feed/client/package.json (script name "lint": "eslint src
--ext .ts,.tsx") calls eslint but eslint is not declared in devDependencies; add
"eslint" (and any required peer/config plugins consistent with the repo root or
workspace versions) to the devDependencies of
packages/activity-feed/client/package.json so the script can run in a clean
install; do the same fix for packages/activity-feed/server/package.json which
also references eslint in its "lint" script, ensuring versions align with the
root workspace ESLint to avoid duplicates and then reinstall dependencies.

},
"dependencies": {
"@solfoundry/activity-shared": "1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"eslint": "^9.24.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.8.3",
"vite": "^6.2.1"
}
}
21 changes: 21 additions & 0 deletions packages/activity-feed/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ActivityFeed } from "./components/ActivityFeed";

export function App() {
const endpoint = import.meta.env.VITE_WS_ENDPOINT || "http://localhost:4000";
const initialUserId = import.meta.env.VITE_ACTIVITY_USER_ID || "anonymous";
const authToken = import.meta.env.VITE_WS_AUTH_TOKEN;

return (
<main className="shell">
<section className="hero">
<p className="eyebrow">SolFoundry</p>
<h1>Real-time activity feed</h1>
<p className="lede">
Track bounty posts, submissions, review outcomes, and leaderboard movement with live delivery,
resilient reconnection, and an HTTP polling fallback.
</p>
</section>
<ActivityFeed authToken={authToken} endpoint={endpoint} initialUserId={initialUserId} />
</main>
);
}
210 changes: 210 additions & 0 deletions packages/activity-feed/client/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { ChangeEvent } from "react";
import { ACTIVITY_TYPES, ActivityType } from "@solfoundry/activity-shared";
import { useActivityFeed } from "../hooks/useActivityFeed";

interface ActivityFeedProps {
authToken?: string;
endpoint: string;
initialUserId: string;
}

const statusLabel: Record<string, string> = {
connecting: "Connecting",
connected: "Live",
reconnecting: "Reconnecting",
polling: "Polling fallback",
disconnected: "Disconnected",
error: "Error",
};

export function ActivityFeed({ authToken, endpoint, initialUserId }: ActivityFeedProps) {
const {
activities,
error,
lastUpdatedAt,
retryConnection,
status,
subscription,
updateSubscription,
} = useActivityFeed({ authToken, endpoint, initialUserId });

const toggleType = (type: ActivityType) => {
updateSubscription((current) => {
const exists = current.filter.types.includes(type);
return {
...current,
filter: {
...current.filter,
types: exists
? current.filter.types.filter((value) => value !== type)
: [...current.filter.types, type],
},
};
});
};

const updateCommaSeparated = (
key: "userIds" | "bountyIds",
event: ChangeEvent<HTMLInputElement>
) => {
updateSubscription((current) => ({
...current,
filter: {
...current.filter,
[key]: event.target.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
},
}));
};

const toggleMutedType = (type: ActivityType) => {
updateSubscription((current) => {
const exists = current.notifications.mutedTypes.includes(type);
return {
...current,
notifications: {
...current.notifications,
mutedTypes: exists
? current.notifications.mutedTypes.filter((value) => value !== type)
: [...current.notifications.mutedTypes, type],
},
};
});
};
Comment on lines +31 to +75

Copy link
Copy Markdown

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

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Component call sites using snapshot updates =="
rg -n -C2 'updateSubscription\(\{' packages/activity-feed/client/src/components/ActivityFeed.tsx

echo
echo "== Hook return wiring (updateSubscription -> setSubscription) =="
rg -n -C3 'const \[subscription, setSubscription\]|updateSubscription:\s*setSubscription' packages/activity-feed/client/src/hooks/useActivityFeed.ts

Repository: SolFoundry/solfoundry

Length of output: 1855


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== All setSubscription calls in useActivityFeed hook =="
rg -n 'setSubscription' packages/activity-feed/client/src/hooks/useActivityFeed.ts

echo
echo "== Functional update pattern usage (prev => ...) in files =="
rg -n 'prev\s*=>\s*\(\{' packages/activity-feed/client/src/

echo
echo "== Check if subscription is updated from socket/polling events =="
rg -n -B2 -A5 'socket.on.*subscription|setSubscription' packages/activity-feed/client/src/hooks/useActivityFeed.ts | head -60

Repository: SolFoundry/solfoundry

Length of output: 389


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Full useActivityFeed hook to see all state update patterns =="
wc -l packages/activity-feed/client/src/hooks/useActivityFeed.ts
head -250 packages/activity-feed/client/src/hooks/useActivityFeed.ts | tail -100

Repository: SolFoundry/solfoundry

Length of output: 2808


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check concurrent activity in component (e.g., useEffect deps on subscription) =="
rg -n -A10 'useEffect.*subscription' packages/activity-feed/client/src/components/ActivityFeed.tsx | head -40

echo
echo "== Verify if socket updates arrive while user interacting (socket.on in different component context) =="
grep -n 'useActivityFeed\|updateSubscription' packages/activity-feed/client/src/components/ActivityFeed.tsx | head -5

Repository: SolFoundry/solfoundry

Length of output: 148


Snapshot-based state updates in handlers risk losing data under concurrent socket events.

Handlers at lines 30–70, 95–118, and 147–176 call updateSubscription with a spread of the captured subscription object. Since updateSubscription is the React state setter (from useActivityFeed hook line 202), when a socket event emits PREFERENCES_UPDATED (hook line 150) while handlers are batching, React's automatic batching will queue both updates, but the snapshot-based spread pattern causes concurrent writes to overwrite instead of merge.

Example: User toggles toggleType(B) at line 32, batching setSubscription({...subscription, filter: {types: [..., B]}}). Simultaneously, socket emits PREFERENCES_UPDATED with new preferences. React batches both, but the handler's snapshot-based update (using stale subscription reference) overwrites the socket-provided state, losing server-side changes.

Use functional updates (setSubscription(prev => ({...prev, ...}))) in all handlers to ensure updates compose correctly under batching.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/activity-feed/client/src/components/ActivityFeed.tsx` around lines
30 - 70, Handlers toggleType, updateCommaSeparated, and toggleMutedType use a
snapshot-based update of subscription which can overwrite concurrent updates
(e.g., from PREFERENCES_UPDATED); change each call to updateSubscription to use
the functional form (updateSubscription(prev => ({ ...prev, ... }))) so you base
changes on the latest state, updating nested fields (filter, notifications,
types, userIds, bountyIds, mutedTypes) by spreading prev and only modifying the
specific subfield; reference functions: toggleType, updateCommaSeparated,
toggleMutedType, updateSubscription, and the useActivityFeed hook where
PREFERENCES_UPDATED is handled.


return (
<section className="feed-card">
<header className="feed-header">
<div>
<p className={`status status-${status}`}>{statusLabel[status]}</p>
<h2>Activity stream</h2>
</div>
<div className="header-actions">
<button className="ghost-button" onClick={retryConnection} type="button">
Retry live sync
</button>
<p className="timestamp">
{lastUpdatedAt ? `Updated ${new Date(lastUpdatedAt).toLocaleTimeString()}` : "Waiting for updates"}
</p>
</div>
</header>

<section className="controls">
<div className="panel">
<h3>Notification preferences</h3>
<label className="switch">
<input
checked={subscription.notifications.enabled}
onChange={(event) =>
updateSubscription((current) => ({
...current,
notifications: {
...current.notifications,
enabled: event.target.checked,
},
}))
}
type="checkbox"
/>
Enable in-app notifications
</label>
<label className="switch">
<input
checked={subscription.notifications.inAppOnly}
onChange={(event) =>
updateSubscription((current) => ({
...current,
notifications: {
...current.notifications,
inAppOnly: event.target.checked,
},
}))
}
type="checkbox"
/>
Keep notifications in app only
</label>
<div>
<p className="subtle-label">Muted activity types</p>
<div className="chip-grid">
{ACTIVITY_TYPES.map((type) => {
const muted = subscription.notifications.mutedTypes.includes(type);
return (
<button
className={`chip ${muted ? "" : "chip-active"}`}
key={`mute-${type}`}
onClick={() => toggleMutedType(type)}
type="button"
>
{muted ? `Unmute ${type.replaceAll("_", " ")}` : `Mute ${type.replaceAll("_", " ")}`}
</button>
);
})}
</div>
</div>
</div>

<div className="panel">
<h3>Filter stream</h3>
<div className="chip-grid">
{ACTIVITY_TYPES.map((type) => {
const active = subscription.filter.types.includes(type);
return (
<button
className={`chip ${active ? "chip-active" : ""}`}
key={type}
onClick={() => toggleType(type)}
type="button"
>
{type.replaceAll("_", " ")}
</button>
);
})}
</div>
<label>
User IDs
<input
className="text-input"
onChange={(event) => updateCommaSeparated("userIds", event)}
placeholder="u-1, u-2"
type="text"
value={subscription.filter.userIds.join(", ")}
/>
</label>
<label>
Bounty IDs
<input
className="text-input"
onChange={(event) => updateCommaSeparated("bountyIds", event)}
placeholder="b-100, b-220"
type="text"
value={subscription.filter.bountyIds.join(", ")}
/>
</label>
</div>
</section>

{error ? <p className="error-banner">{error}</p> : null}

<ol aria-atomic="true" aria-live="polite" className="activity-list" role="log">
{activities.map((activity) => (
<li className="activity-item" key={activity.id}>
<div className="activity-meta">
<span>{activity.type.replaceAll("_", " ")}</span>
<time dateTime={activity.createdAt}>{new Date(activity.createdAt).toLocaleString()}</time>
</div>
<h3>{activity.metadata.title}</h3>
<p>{activity.metadata.message}</p>
<p className="activity-footer">
<strong>{activity.actor.displayName}</strong> @{activity.actor.handle}
{activity.metadata.bountyTitle ? ` Β· ${activity.metadata.bountyTitle}` : ""}
</p>
</li>
))}
{!activities.length ? <li className="activity-item empty">No matching activity yet.</li> : null}
</ol>
</section>
);
}
Loading
Loading