-
Notifications
You must be signed in to change notification settings - Fork 163
π‘ Real-time WebSocket Activity Feed - Bounty #860 (750K FNDRY) #869
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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> |
| 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" | ||
| }, | ||
| "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" | ||
| } | ||
| } | ||
| 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> | ||
| ); | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π§© 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.tsRepository: 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 -60Repository: 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 -100Repository: 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 -5Repository: 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 Example: User toggles Use functional updates ( π€ Prompt for AI Agents |
||
|
|
||
| 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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π§© Analysis chain
π Script executed:
Repository: SolFoundry/solfoundry
Length of output: 477
Lint script references
eslint, but the package does not declareeslintin devDependencies.Line 11 executes
eslint src --ext .ts,.tsx, buteslintis missing from thedevDependenciessection (lines 19-25). In a clean install,npm run lint -w clientwill fail during CI, blocking the root lint orchestration atpackages/activity-feed/package.jsonline 13 which invokes workspace lint commands. The same issue affectspackages/activity-feed/server/package.jsonline 11, which also referenceseslintwithout declaring the dependency.π€ Prompt for AI Agents