From 26662c48564f38bed92e631ac9e462bf3326d98f Mon Sep 17 00:00:00 2001 From: paolomolo Date: Wed, 22 Oct 2025 23:21:04 +0200 Subject: [PATCH 001/279] feat(feed): scaffold plugin core types and registry Add FeedEntry/FeedPlugin types with pagination helpers Introduce registry with register/get/reset functions Provide FeedPluginCard with CSS Modules and isolation: isolate for style containment --- .../feed-plugins/FeedPluginCard.module.scss | 23 ++++++++++ .../social/feed-plugins/FeedPluginCard.tsx | 19 ++++++++ src/features/social/feed-plugins/registry.ts | 24 ++++++++++ src/features/social/feed-plugins/types.ts | 46 +++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 src/features/social/feed-plugins/FeedPluginCard.module.scss create mode 100644 src/features/social/feed-plugins/FeedPluginCard.tsx create mode 100644 src/features/social/feed-plugins/registry.ts create mode 100644 src/features/social/feed-plugins/types.ts diff --git a/src/features/social/feed-plugins/FeedPluginCard.module.scss b/src/features/social/feed-plugins/FeedPluginCard.module.scss new file mode 100644 index 000000000..5be106149 --- /dev/null +++ b/src/features/social/feed-plugins/FeedPluginCard.module.scss @@ -0,0 +1,23 @@ +.root { + /* Prevent external ancestors from affecting stacking contexts of internals */ + isolation: isolate; +} + +.card { + /* Align with app glass card aesthetics without relying on global class names */ + background: color-mix(in oklab, white 6%, transparent); + border: 1px solid color-mix(in oklab, white 20%, transparent); + border-radius: 1rem; + backdrop-filter: blur(12px); +} + +.content { + padding: 1.25rem; +} + +.divider { + height: 1px; + background: color-mix(in oklab, white 12%, transparent); +} + + diff --git a/src/features/social/feed-plugins/FeedPluginCard.tsx b/src/features/social/feed-plugins/FeedPluginCard.tsx new file mode 100644 index 000000000..7ad1350a6 --- /dev/null +++ b/src/features/social/feed-plugins/FeedPluginCard.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import styles from './FeedPluginCard.module.scss'; +import { cn } from '@/lib/utils'; + +type FeedPluginCardProps = React.PropsWithChildren<{ + className?: string; + role?: string; + onClick?: () => void; +}>; + +export default function FeedPluginCard({ className, children, role, onClick }: FeedPluginCardProps) { + return ( +
+
{children}
+
+ ); +} + + diff --git a/src/features/social/feed-plugins/registry.ts b/src/features/social/feed-plugins/registry.ts new file mode 100644 index 000000000..4edc7e2e8 --- /dev/null +++ b/src/features/social/feed-plugins/registry.ts @@ -0,0 +1,24 @@ +import type { AnyFeedPlugin } from './types'; + +// Simple in-memory registry. Plugins can be registered from a central file. +const pluginRegistry: AnyFeedPlugin[] = []; + +export function registerPlugin(plugin: AnyFeedPlugin) { + const exists = pluginRegistry.some((p) => p.kind === plugin.kind); + if (!exists) pluginRegistry.push(plugin); +} + +export function getPlugin(kind: string): AnyFeedPlugin | undefined { + return pluginRegistry.find((p) => p.kind === kind); +} + +export function getAllPlugins(): AnyFeedPlugin[] { + return pluginRegistry.slice(); +} + +// Host can call this to clear and re-register, mainly useful for tests +export function resetPlugins() { + pluginRegistry.splice(0, pluginRegistry.length); +} + + diff --git a/src/features/social/feed-plugins/types.ts b/src/features/social/feed-plugins/types.ts new file mode 100644 index 000000000..cc18660bc --- /dev/null +++ b/src/features/social/feed-plugins/types.ts @@ -0,0 +1,46 @@ +import type React from 'react'; + +// Base shape for any entry in the unified feed +export type FeedEntryBase = { + id: string; // globally unique identifier for the entry + createdAt: string; // ISO timestamp used for sorting/merging across kinds + kind: string; // e.g., "post", "token-created", "poll-created" +}; + +// Normalized feed entry that carries kind-specific data +export type FeedEntry = FeedEntryBase & { data: T }; + +// Result of a paginated plugin fetch +export type FeedPage = { + entries: FeedEntry[]; + nextPage?: number; // undefined when no more pages +}; + +// Plugin contract. Each plugin is responsible for producing normalized entries +// and rendering a single entry of its own kind. +export type FeedPlugin = { + kind: string; + fetchPage?: (page: number) => Promise>; + // Optional live-update hook (e.g., websockets). It can expose a push helper + // to allow the host feed to inject new entries. + useLive?: () => { push: (entry: FeedEntry) => void } | void; + // Renderer for a single entry of this kind + Render: (props: { entry: FeedEntry; onOpen?: (id: string) => void }) => JSX.Element; + // Optional placeholder while loading + Skeleton?: React.FC; +}; + +// Helper type guards +export function isFeedEntry(value: any): value is FeedEntry { + return ( + value && + typeof value === 'object' && + typeof value.id === 'string' && + typeof value.createdAt === 'string' && + typeof value.kind === 'string' + ); +} + +export type AnyFeedPlugin = FeedPlugin; + + From 37797356c102aad67e785753f6edb3e5621d57e7 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Wed, 22 Oct 2025 23:24:23 +0200 Subject: [PATCH 002/279] feat(feed): add FeedRenderer and post adapter Renderer delegates to registered plugins via kind Fallback renders core posts through ReplyToFeedItem Provide adapter to normalize PostDto to FeedEntry --- .../social/feed-plugins/FeedRenderer.tsx | 37 +++++++++++++++++++ .../social/feed-plugins/post/index.ts | 17 +++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/features/social/feed-plugins/FeedRenderer.tsx create mode 100644 src/features/social/feed-plugins/post/index.ts diff --git a/src/features/social/feed-plugins/FeedRenderer.tsx b/src/features/social/feed-plugins/FeedRenderer.tsx new file mode 100644 index 000000000..ff651d517 --- /dev/null +++ b/src/features/social/feed-plugins/FeedRenderer.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { getPlugin } from './registry'; +import type { FeedEntry } from './types'; +import ReplyToFeedItem from '@/features/social/components/ReplyToFeedItem'; +import type { PostDto } from '@/api/generated'; + +type FeedRendererProps = { + entry: FeedEntry; + onOpenPost: (id: string) => void; +}; + +export default function FeedRenderer({ entry, onOpenPost }: FeedRendererProps) { + const plugin = getPlugin(entry.kind); + if (plugin) { + const Comp = plugin.Render as any; + return ; + } + + // Fallback: core social posts + if (entry.kind === 'post') { + const post = (entry as FeedEntry<{ post: PostDto; commentCount?: number }>).data.post; + const commentCount = (entry as FeedEntry<{ post: PostDto; commentCount?: number }>).data.commentCount ?? 0; + return ( + onOpenPost(String(id).replace(/_v3$/, ''))} + /> + ); + } + + // Unknown kind: no-op to avoid crashing the feed + return null; +} + + diff --git a/src/features/social/feed-plugins/post/index.ts b/src/features/social/feed-plugins/post/index.ts new file mode 100644 index 000000000..bce56a789 --- /dev/null +++ b/src/features/social/feed-plugins/post/index.ts @@ -0,0 +1,17 @@ +import type { FeedEntry } from '../types'; +import type { PostDto } from '@/api/generated'; + +export type PostEntryData = { post: PostDto; commentCount?: number }; + +export function adaptPostToEntry(post: PostDto, commentCount = 0): FeedEntry { + const id = String(post.id); + const createdAt = (post.created_at as unknown as string) || new Date().toISOString(); + return { + id, + kind: 'post', + createdAt, + data: { post, commentCount }, + }; +} + + From e98e8d29171ca59387534de3552f9d6334e76306 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Wed, 22 Oct 2025 23:28:29 +0200 Subject: [PATCH 003/279] refactor(feed): render posts via FeedRenderer Use adapter to normalize PostDto to FeedEntry(kind=post) Keep token-created grouping logic unchanged for now Prepares feed for plugin-based items interleaved by createdAt --- src/features/social/views/FeedList.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/features/social/views/FeedList.tsx b/src/features/social/views/FeedList.tsx index ed798d3e4..b24b3154f 100644 --- a/src/features/social/views/FeedList.tsx +++ b/src/features/social/views/FeedList.tsx @@ -17,6 +17,9 @@ import ReplyToFeedItem from "../components/ReplyToFeedItem"; import TokenCreatedFeedItem from "../components/TokenCreatedFeedItem"; import TokenCreatedActivityItem from "../components/TokenCreatedActivityItem"; import { PostApiResponse } from "../types"; +import FeedRenderer from "../feed-plugins/FeedRenderer"; +import { adaptPostToEntry } from "../feed-plugins/post"; +import type { FeedEntry } from "../feed-plugins/types"; // Custom hook function useUrlQuery() { @@ -295,15 +298,9 @@ export default function FeedList({ const isTokenCreated = String(postId).startsWith("token-created:"); if (!isTokenCreated) { - nodes.push( - - ); + // Render normal posts via FeedRenderer using the adapter + const entry: FeedEntry = adaptPostToEntry(item as PostDto, item.total_comments ?? 0); + nodes.push(); i += 1; continue; } From 7b947813c989de40335eab54d19c925a90a81ef5 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Wed, 22 Oct 2025 23:38:46 +0200 Subject: [PATCH 004/279] refactor(feed): migrate token-created rendering to plugin renderer Register token-created plugin and adapt grouped items to FeedEntry Preserve grouping UX via entry.data render hints Progress toward fully plugin-driven activity feed --- .../feed-plugins/token-created/index.ts | 3 ++ .../feed-plugins/token-created/plugin.tsx | 53 +++++++++++++++++++ src/features/social/views/FeedList.tsx | 28 +++++----- 3 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 src/features/social/feed-plugins/token-created/index.ts create mode 100644 src/features/social/feed-plugins/token-created/plugin.tsx diff --git a/src/features/social/feed-plugins/token-created/index.ts b/src/features/social/feed-plugins/token-created/index.ts new file mode 100644 index 000000000..1b4c57d72 --- /dev/null +++ b/src/features/social/feed-plugins/token-created/index.ts @@ -0,0 +1,3 @@ +export * from './plugin'; + + diff --git a/src/features/social/feed-plugins/token-created/plugin.tsx b/src/features/social/feed-plugins/token-created/plugin.tsx new file mode 100644 index 000000000..7d0b696b4 --- /dev/null +++ b/src/features/social/feed-plugins/token-created/plugin.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import type { FeedEntry } from '../types'; +import { registerPlugin } from '../registry'; +import TokenCreatedActivityItem from '@/features/social/components/TokenCreatedActivityItem'; +import type { PostDto } from '@/api/generated'; + +export type TokenCreatedRenderExtras = { + hideMobileDivider?: boolean; + mobileTight?: boolean; + footer?: React.ReactNode; + mobileNoTopPadding?: boolean; + mobileNoBottomPadding?: boolean; + mobileTightTop?: boolean; + mobileTightBottom?: boolean; +}; + +export type TokenCreatedEntryData = { + item: PostDto; + render?: TokenCreatedRenderExtras; +}; + +export function adaptTokenCreatedToEntry(item: PostDto, render?: TokenCreatedRenderExtras): FeedEntry { + const createdAt = (item.created_at as unknown as string) || new Date().toISOString(); + return { + id: String(item.id), + kind: 'token-created', + createdAt, + data: { item, render }, + }; +} + +export function registerTokenCreatedPlugin() { + registerPlugin({ + kind: 'token-created', + Render: ({ entry }: { entry: FeedEntry }) => { + const { item, render } = entry.data; + return ( + + ); + }, + }); +} + + diff --git a/src/features/social/views/FeedList.tsx b/src/features/social/views/FeedList.tsx index b24b3154f..fda323e47 100644 --- a/src/features/social/views/FeedList.tsx +++ b/src/features/social/views/FeedList.tsx @@ -20,6 +20,10 @@ import { PostApiResponse } from "../types"; import FeedRenderer from "../feed-plugins/FeedRenderer"; import { adaptPostToEntry } from "../feed-plugins/post"; import type { FeedEntry } from "../feed-plugins/types"; +import { adaptTokenCreatedToEntry, registerTokenCreatedPlugin } from "../feed-plugins/token-created"; + +// Register built-in plugins once (idempotent) +registerTokenCreatedPlugin(); // Custom hook function useUrlQuery() { @@ -345,19 +349,17 @@ export default function FeedList({ {collapsed ? `Show ${groupItems.length - 3} more` : 'Show less'} ) : undefined; - nodes.push( - - ); + + const entry: FeedEntry = adaptTokenCreatedToEntry(gi, { + hideMobileDivider: hideDivider, + mobileTight, + mobileNoTopPadding, + mobileNoBottomPadding, + mobileTightTop, + mobileTightBottom, + footer, + }); + nodes.push(); } if (groupItems.length > 3) { From 92ed5d19f31f181bea0540903ae346bd3978f6b8 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Wed, 22 Oct 2025 23:42:04 +0200 Subject: [PATCH 005/279] feat(feed): add poll-created plugin and card UI Add PollCreatedCard with CSS Modules isolation and FeedPluginCard wrapper Register poll plugin; ready to supply entries from governance No fetch wired yet; renderer and style isolation in place --- .../poll-created/PollCreatedCard.module.scss | 53 ++++++++++++++++ .../poll-created/PollCreatedCard.tsx | 61 +++++++++++++++++++ .../social/feed-plugins/poll-created/index.ts | 4 ++ .../feed-plugins/poll-created/plugin.tsx | 53 ++++++++++++++++ src/features/social/views/FeedList.tsx | 2 + 5 files changed, 173 insertions(+) create mode 100644 src/features/social/feed-plugins/poll-created/PollCreatedCard.module.scss create mode 100644 src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx create mode 100644 src/features/social/feed-plugins/poll-created/index.ts create mode 100644 src/features/social/feed-plugins/poll-created/plugin.tsx diff --git a/src/features/social/feed-plugins/poll-created/PollCreatedCard.module.scss b/src/features/social/feed-plugins/poll-created/PollCreatedCard.module.scss new file mode 100644 index 000000000..1aca4a70b --- /dev/null +++ b/src/features/social/feed-plugins/poll-created/PollCreatedCard.module.scss @@ -0,0 +1,53 @@ +.root { + isolation: isolate; +} + +.title { + font-weight: 600; +} + +.metaRow { + display: flex; + gap: 8px; + align-items: center; + color: rgba(255,255,255,0.7); + font-size: 12px; +} + +.options { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.option { + border: 1px solid rgba(255,255,255,0.14); + border-radius: 12px; + overflow: hidden; + position: relative; +} + +.bar { + position: absolute; + inset: 0; + background: linear-gradient(90deg, rgba(99,102,241,0.28), rgba(99,102,241,0.08)); + transform-origin: left center; +} + +.labelRow { + position: relative; + display: flex; + justify-content: space-between; + padding: 10px 12px; + font-size: 14px; +} + +.footer { + display: flex; + justify-content: space-between; + margin-top: 10px; + color: rgba(255,255,255,0.7); + font-size: 12px; +} + + diff --git a/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx b/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx new file mode 100644 index 000000000..9d028d377 --- /dev/null +++ b/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react'; +import styles from './PollCreatedCard.module.scss'; +import FeedPluginCard from '../FeedPluginCard'; +import { cn } from '@/lib/utils'; + +export type PollCreatedCardProps = { + title: string; + author?: string; + closeHeight?: number; + currentHeight?: number; + options: { id: number; label: string; votes?: number }[]; + totalVotes?: number; + onOpen?: () => void; +}; + +export default function PollCreatedCard({ title, author, closeHeight, currentHeight, options, totalVotes = 0, onOpen }: PollCreatedCardProps) { + const timeLeft = useMemo(() => { + if (!closeHeight || !currentHeight) return undefined; + const blocksLeft = Math.max(0, closeHeight - currentHeight); + // rough estimate: ~3 minutes per block → convert to hours + const minutes = blocksLeft * 3; + if (minutes < 60) return `${minutes}m left`; + const hours = Math.round(minutes / 60); + return `${hours}h left`; + }, [closeHeight, currentHeight]); + + const maxVotes = Math.max(0, ...options.map((o) => o.votes || 0)); + + return ( + +
{title}
+
+ {author && by {author}} + {timeLeft && • {timeLeft}} +
+ +
+ {options.map((o) => { + const pct = totalVotes > 0 ? Math.round(((o.votes || 0) / totalVotes) * 100) : 0; + const widthPct = maxVotes > 0 ? Math.max(14, Math.round(((o.votes || 0) / maxVotes) * 100)) : 14; + return ( +
+
+
+ {o.label} + {pct}% +
+
+ ); + })} +
+ +
+ {totalVotes} votes + {timeLeft && {timeLeft}} +
+ + ); +} + + diff --git a/src/features/social/feed-plugins/poll-created/index.ts b/src/features/social/feed-plugins/poll-created/index.ts new file mode 100644 index 000000000..16b4eb500 --- /dev/null +++ b/src/features/social/feed-plugins/poll-created/index.ts @@ -0,0 +1,4 @@ +export * from './plugin'; +export { default as PollCreatedCard } from './PollCreatedCard'; + + diff --git a/src/features/social/feed-plugins/poll-created/plugin.tsx b/src/features/social/feed-plugins/poll-created/plugin.tsx new file mode 100644 index 000000000..9c6bd7a04 --- /dev/null +++ b/src/features/social/feed-plugins/poll-created/plugin.tsx @@ -0,0 +1,53 @@ +import React, { useMemo } from 'react'; +import type { FeedEntry, FeedPage } from '../types'; +import { registerPlugin } from '../registry'; +import PollCreatedCard from './PollCreatedCard'; +import { GovernanceApi } from '@/api/governance'; +import type { Encoded } from '@aeternity/aepp-sdk'; +import { useAeSdk } from '@/hooks/useAeSdk'; + +export type PollCreatedEntryData = { + pollAddress: Encoded.ContractAddress; + title: string; + author?: string; + closeHeight?: number; + createHeight?: number; + options: { id: number; label: string; votes?: number }[]; + totalVotes?: number; +}; + +export function adaptPollToEntry(pollAddress: Encoded.ContractAddress, data: Omit): FeedEntry { + const createdAt = new Date().toISOString(); + return { + id: `poll-created:${pollAddress}`, + kind: 'poll-created', + createdAt, + data: { pollAddress, ...data }, + }; +} + +export function registerPollCreatedPlugin() { + registerPlugin({ + kind: 'poll-created', + Render: ({ entry, onOpen }: { entry: FeedEntry; onOpen?: (id: string) => void }) => { + const { pollAddress, title, author, closeHeight, createHeight, options, totalVotes } = entry.data; + const { sdk } = useAeSdk(); + const currentHeight = (sdk as any)?.getHeight ? undefined : undefined; // avoid calling during SSR; page data shows time left anyway + const handleOpen = () => onOpen?.(String(pollAddress)); + return ( + + ); + }, + // Optional: fetchPage could call GovernanceApi.getPollOrdering(false) and map a first page + }); +} + + diff --git a/src/features/social/views/FeedList.tsx b/src/features/social/views/FeedList.tsx index fda323e47..01b163ffe 100644 --- a/src/features/social/views/FeedList.tsx +++ b/src/features/social/views/FeedList.tsx @@ -21,9 +21,11 @@ import FeedRenderer from "../feed-plugins/FeedRenderer"; import { adaptPostToEntry } from "../feed-plugins/post"; import type { FeedEntry } from "../feed-plugins/types"; import { adaptTokenCreatedToEntry, registerTokenCreatedPlugin } from "../feed-plugins/token-created"; +import { registerPollCreatedPlugin } from "../feed-plugins/poll-created"; // Register built-in plugins once (idempotent) registerTokenCreatedPlugin(); +registerPollCreatedPlugin(); // Custom hook function useUrlQuery() { From 08757373668e32f9d33428cd839fe16760407441 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Wed, 22 Oct 2025 23:43:46 +0200 Subject: [PATCH 006/279] docs(feed): add developer guide for feed plugins Explain FeedEntry/FeedPlugin, folder layout, registration Document styling isolation with CSS Modules + FeedPluginCard Link to poll-created and token-created examples --- docs/feed-plugin-guide.md | 70 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/feed-plugin-guide.md diff --git a/docs/feed-plugin-guide.md b/docs/feed-plugin-guide.md new file mode 100644 index 000000000..0065fa7dd --- /dev/null +++ b/docs/feed-plugin-guide.md @@ -0,0 +1,70 @@ +## Feed plugin guide + +This guide explains how to add a new feed item type to the social feed with strong style isolation and minimal wiring. + +### Key concepts +- Use `FeedEntry` to normalize data (`id`, `createdAt`, `kind`, `data`). +- Register a `FeedPlugin` in `registry.ts` that provides a `Render` for your kind. +- Plugins live under `src/features/social/feed-plugins//`. +- Style isolation: use CSS Modules in your plugin and the shared `FeedPluginCard` wrapper (adds `isolation: isolate`). + +### File layout example +``` +src/features/social/feed-plugins/my-item/ + MyItemCard.module.scss + MyItemCard.tsx + plugin.tsx + index.ts +``` + +### Minimal plugin +```ts +// src/features/social/feed-plugins/my-item/plugin.tsx +import React from 'react'; +import { registerPlugin } from '../registry'; +import type { FeedEntry } from '../types'; + +type MyItemData = { title: string }; + +export function registerMyItemPlugin() { + registerPlugin({ + kind: 'my-item', + Render: ({ entry }: { entry: FeedEntry }) => ( +
{entry.data.title}
+ ), + }); +} +``` + +Register once in a host module (e.g., `FeedList.tsx`): +```ts +import { registerMyItemPlugin } from '@/features/social/feed-plugins/my-item'; +registerMyItemPlugin(); +``` + +### Normalizing data +Create a small adapter that converts your backend/API object to a `FeedEntry`: +```ts +export function adaptMyItemToEntry(obj: Any): FeedEntry { + return { + id: `my-item:${obj.id}`, + kind: 'my-item', + createdAt: obj.createdAt, + data: { title: obj.title }, + }; +} +``` + +### Styling that won’t be overridden +- Use a CSS Module (e.g., `MyItemCard.module.scss`). +- Wrap your UI in `FeedPluginCard` for consistent glass card styling and `isolation: isolate`. +- Avoid global class names; prefer local module classes. + +### Poll and token examples +- See `src/features/social/feed-plugins/poll-created/` and `token-created/` for reference implementations. + +### Infinite scroll & merging +- The host feed merges all items by `createdAt` and renders each `FeedEntry` via `FeedRenderer`. +- If your plugin needs pagination, implement `fetchPage(page)` on the plugin and wire it in the host. + + From 4b88f4a0f8f70605ba65a26d6957c43116966cf6 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Wed, 22 Oct 2025 23:44:33 +0200 Subject: [PATCH 007/279] test(feed): add basic FeedRenderer plugin render test Use vitest globals and registry reset Validates that plugin Render is invoked when kind matches --- .../__tests__/FeedRenderer.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/features/social/feed-plugins/__tests__/FeedRenderer.test.tsx diff --git a/src/features/social/feed-plugins/__tests__/FeedRenderer.test.tsx b/src/features/social/feed-plugins/__tests__/FeedRenderer.test.tsx new file mode 100644 index 000000000..a83510468 --- /dev/null +++ b/src/features/social/feed-plugins/__tests__/FeedRenderer.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import FeedRenderer from '../FeedRenderer'; +import { registerPlugin, resetPlugins } from '../registry'; +import type { FeedEntry } from '../types'; +import { describe, it, expect, beforeEach } from 'vitest'; + +describe('FeedRenderer', () => { + beforeEach(() => { + resetPlugins(); + }); + + it('renders via plugin when kind matches', () => { + const TestComp = ({ entry }: { entry: FeedEntry<{ label: string }> }) => ( +
{entry.data.label}
+ ); + registerPlugin({ kind: 'x', Render: TestComp as any }); + + const entry: FeedEntry<{ label: string }> = { + id: 'x:1', + kind: 'x', + createdAt: new Date().toISOString(), + data: { label: 'hello' }, + }; + render( {}} />); + expect(screen.getByTestId('plugin-item')).toHaveTextContent('hello'); + }); +}); + + From 3fcc9e0c0749abff0c2b8f0cc34312ed7ad43c5c Mon Sep 17 00:00:00 2001 From: paolomolo Date: Wed, 22 Oct 2025 23:57:06 +0200 Subject: [PATCH 008/279] feat(feed): interleave recent polls via governance API Query poll ordering + overview; adapt to FeedEntry(kind=poll-created) Render through FeedRenderer; navigate to /voting/p/:address Keeps hot tab unchanged; single-page fetch for now --- .../feed-plugins/poll-created/plugin.tsx | 5 +- src/features/social/views/FeedList.tsx | 64 +++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/features/social/feed-plugins/poll-created/plugin.tsx b/src/features/social/feed-plugins/poll-created/plugin.tsx index 9c6bd7a04..a1d3b4888 100644 --- a/src/features/social/feed-plugins/poll-created/plugin.tsx +++ b/src/features/social/feed-plugins/poll-created/plugin.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import type { FeedEntry, FeedPage } from '../types'; import { registerPlugin } from '../registry'; import PollCreatedCard from './PollCreatedCard'; @@ -32,7 +32,8 @@ export function registerPollCreatedPlugin() { Render: ({ entry, onOpen }: { entry: FeedEntry; onOpen?: (id: string) => void }) => { const { pollAddress, title, author, closeHeight, createHeight, options, totalVotes } = entry.data; const { sdk } = useAeSdk(); - const currentHeight = (sdk as any)?.getHeight ? undefined : undefined; // avoid calling during SSR; page data shows time left anyway + // do not block render on height; card computes rough time left even if undefined + const currentHeight = undefined as number | undefined; const handleOpen = () => onOpen?.(String(pollAddress)); return ( { + if (pageParam > 0) return { items: [], nextPage: undefined } as any; // single page for now + const ordering = await GovernanceApi.getPollOrdering(false); + const top = (ordering?.data || []).slice(0, 10); + const items = await Promise.all( + top.map(async (p, idx) => { + try { + const ov = await GovernanceApi.getPollOverview(p.poll); + const meta = ov?.pollState?.metadata || ({} as any); + const optsRec = (ov?.pollState?.vote_options || {}) as Record; + const options = Object.entries(optsRec).map(([k, v]) => ({ id: Number(k), label: String(v), votes: 0 })); + const createdAt = new Date(Date.now() - idx * 1000).toISOString(); + return { + id: `poll-created:${p.poll}`, + created_at: createdAt, + content: meta?.title || "", + topics: [], + sender_address: ov?.pollState?.author || "", + __feedEntry: adaptPollToEntry(p.poll as any, { + title: meta?.title || "Untitled poll", + author: ov?.pollState?.author as any, + closeHeight: ov?.pollState?.close_height as any, + createHeight: ov?.pollState?.create_height as any, + options, + totalVotes: p?.voteCount || 0, + }), + } as any; + } catch { + return undefined; + } + }) + ); + return { items: items.filter(Boolean), nextPage: undefined } as any; + }, + getNextPageParam: (lastPage: any) => lastPage?.nextPage, + }); + const pollList: any[] = useMemo( + () => (pollsData?.pages ? (pollsData.pages as any[]).flatMap((p: any) => p?.items ?? []) : []), + [pollsData] + ); + // Combine posts with token-created events and sort by created_at DESC const combinedList = useMemo(() => { // Hide token-created events on the popular (hot) tab const includeEvents = sortBy !== "hot"; - const merged = includeEvents ? [...activityList, ...list] : [...list]; + const merged = includeEvents ? [...activityList, ...pollList, ...list] : [...list]; // Keep backend order for 'hot'; only sort by time when we interleave activities if (!includeEvents) return merged; return merged.sort((a: any, b: any) => { @@ -201,7 +248,7 @@ export default function FeedList({ const bt = new Date(b?.created_at || 0).getTime(); return bt - at; }); - }, [list, activityList, sortBy]); + }, [list, activityList, pollList, sortBy]); // Memoized filtered list const filteredAndSortedList = useMemo(() => { @@ -302,8 +349,9 @@ export default function FeedList({ const item = filteredAndSortedList[i]; const postId = item.id; const isTokenCreated = String(postId).startsWith("token-created:"); + const isPollCreated = String(postId).startsWith("poll-created:"); - if (!isTokenCreated) { + if (!isTokenCreated && !isPollCreated) { // Render normal posts via FeedRenderer using the adapter const entry: FeedEntry = adaptPostToEntry(item as PostDto, item.total_comments ?? 0); nodes.push(); @@ -311,6 +359,14 @@ export default function FeedList({ continue; } + if (isPollCreated) { + const entry: FeedEntry = (item as any).__feedEntry; + const onOpen = (id: string) => navigate(`/voting/p/${id}`); + nodes.push(); + i += 1; + continue; + } + // Collect consecutive token-created items into a group const startIndex = i; const groupItems: PostDto[] = []; From f5ffce1661ee1f326276ac795374f26e9f7e2f16 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Thu, 23 Oct 2025 00:01:00 +0200 Subject: [PATCH 009/279] refactor(feed): move poll fetching into poll plugin Implement fetchPage in poll-created plugin and centralize plugin pagination Add FeedOrchestrator to fetch all plugin pages in parallel FeedList now consumes plugin entries only; no direct GovernanceApi import --- .../social/feed-plugins/FeedOrchestrator.tsx | 37 ++++++++++ .../feed-plugins/poll-created/plugin.tsx | 30 +++++++++ src/features/social/views/FeedList.tsx | 67 +++++-------------- 3 files changed, 83 insertions(+), 51 deletions(-) create mode 100644 src/features/social/feed-plugins/FeedOrchestrator.tsx diff --git a/src/features/social/feed-plugins/FeedOrchestrator.tsx b/src/features/social/feed-plugins/FeedOrchestrator.tsx new file mode 100644 index 000000000..86981f1a1 --- /dev/null +++ b/src/features/social/feed-plugins/FeedOrchestrator.tsx @@ -0,0 +1,37 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import type { AnyFeedPlugin, FeedEntry } from './types'; + +// Call each plugin.fetchPage in parallel (if provided) and merge results by createdAt +export function usePluginEntries(plugins: AnyFeedPlugin[], enabled: boolean) { + const { data } = useInfiniteQuery({ + queryKey: ['feed-plugins', plugins.map((p) => p.kind)], + enabled, + initialPageParam: 1, + queryFn: async ({ pageParam = 1 }) => { + const pages = await Promise.all( + plugins.map(async (p) => { + if (!p.fetchPage) return { entries: [], nextPage: undefined }; + try { + return await p.fetchPage(pageParam as number); + } catch { + return { entries: [], nextPage: undefined }; + } + }) + ); + const entries = pages.flatMap((pg) => pg.entries); + const nextPage = pages.some((pg) => pg.nextPage != null) ? (pageParam as number) + 1 : undefined; + return { entries, nextPage } as { entries: FeedEntry[]; nextPage?: number }; + }, + getNextPageParam: (last) => last?.nextPage, + }); + + const merged = useMemo(() => { + const entries = data?.pages ? data.pages.flatMap((p) => p.entries) : []; + return entries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, [data]); + + return { entries: merged }; +} + + diff --git a/src/features/social/feed-plugins/poll-created/plugin.tsx b/src/features/social/feed-plugins/poll-created/plugin.tsx index a1d3b4888..5f4238068 100644 --- a/src/features/social/feed-plugins/poll-created/plugin.tsx +++ b/src/features/social/feed-plugins/poll-created/plugin.tsx @@ -29,6 +29,36 @@ export function adaptPollToEntry(pollAddress: Encoded.ContractAddress, data: Omi export function registerPollCreatedPlugin() { registerPlugin({ kind: 'poll-created', + async fetchPage(page: number) { + // single first page for now + if (page && page > 1) { + return { entries: [], nextPage: undefined } as FeedPage; + } + const ordering = await GovernanceApi.getPollOrdering(false); + const top = (ordering?.data || []).slice(0, 10); + const entries: FeedEntry[] = []; + for (let i = 0; i < top.length; i += 1) { + const p = top[i]; + try { + const ov = await GovernanceApi.getPollOverview(p.poll); + const meta = ov?.pollState?.metadata || ({} as any); + const optsRec = (ov?.pollState?.vote_options || {}) as Record; + const options = Object.entries(optsRec).map(([k, v]) => ({ id: Number(k), label: String(v), votes: 0 })); + const entry = adaptPollToEntry(p.poll as any, { + title: meta?.title || 'Untitled poll', + author: ov?.pollState?.author as any, + closeHeight: ov?.pollState?.close_height as any, + createHeight: ov?.pollState?.create_height as any, + options, + totalVotes: p?.voteCount || 0, + }); + entries.push(entry); + } catch { + // skip failures + } + } + return { entries, nextPage: undefined } as FeedPage; + }, Render: ({ entry, onOpen }: { entry: FeedEntry; onOpen?: (id: string) => void }) => { const { pollAddress, title, author, closeHeight, createHeight, options, totalVotes } = entry.data; const { sdk } = useAeSdk(); diff --git a/src/features/social/views/FeedList.tsx b/src/features/social/views/FeedList.tsx index 19fe5bad7..49a44b753 100644 --- a/src/features/social/views/FeedList.tsx +++ b/src/features/social/views/FeedList.tsx @@ -21,7 +21,9 @@ import FeedRenderer from "../feed-plugins/FeedRenderer"; import { adaptPostToEntry } from "../feed-plugins/post"; import type { FeedEntry } from "../feed-plugins/types"; import { adaptTokenCreatedToEntry, registerTokenCreatedPlugin } from "../feed-plugins/token-created"; -import { registerPollCreatedPlugin, adaptPollToEntry } from "../feed-plugins/poll-created"; +import { registerPollCreatedPlugin } from "../feed-plugins/poll-created"; +import { getAllPlugins } from "../feed-plugins/registry"; +import { usePluginEntries } from "../feed-plugins/FeedOrchestrator"; import { GovernanceApi } from "@/api/governance"; // Register built-in plugins once (idempotent) @@ -190,57 +192,14 @@ export default function FeedList({ [data] ); - // Fetch latest open polls for feed (lightweight: map first page with titles/options) - const { data: pollsData } = useInfiniteQuery({ - queryKey: ["feed-polls", { status: "open" }], - enabled: sortBy !== "hot", - initialPageParam: 0, - queryFn: async ({ pageParam = 0 }) => { - if (pageParam > 0) return { items: [], nextPage: undefined } as any; // single page for now - const ordering = await GovernanceApi.getPollOrdering(false); - const top = (ordering?.data || []).slice(0, 10); - const items = await Promise.all( - top.map(async (p, idx) => { - try { - const ov = await GovernanceApi.getPollOverview(p.poll); - const meta = ov?.pollState?.metadata || ({} as any); - const optsRec = (ov?.pollState?.vote_options || {}) as Record; - const options = Object.entries(optsRec).map(([k, v]) => ({ id: Number(k), label: String(v), votes: 0 })); - const createdAt = new Date(Date.now() - idx * 1000).toISOString(); - return { - id: `poll-created:${p.poll}`, - created_at: createdAt, - content: meta?.title || "", - topics: [], - sender_address: ov?.pollState?.author || "", - __feedEntry: adaptPollToEntry(p.poll as any, { - title: meta?.title || "Untitled poll", - author: ov?.pollState?.author as any, - closeHeight: ov?.pollState?.close_height as any, - createHeight: ov?.pollState?.create_height as any, - options, - totalVotes: p?.voteCount || 0, - }), - } as any; - } catch { - return undefined; - } - }) - ); - return { items: items.filter(Boolean), nextPage: undefined } as any; - }, - getNextPageParam: (lastPage: any) => lastPage?.nextPage, - }); - const pollList: any[] = useMemo( - () => (pollsData?.pages ? (pollsData.pages as any[]).flatMap((p: any) => p?.items ?? []) : []), - [pollsData] - ); + // Plugin-driven entries (includes poll-created via its fetchPage) + const pluginEntries = usePluginEntries(getAllPlugins(), sortBy !== "hot"); // Combine posts with token-created events and sort by created_at DESC const combinedList = useMemo(() => { // Hide token-created events on the popular (hot) tab const includeEvents = sortBy !== "hot"; - const merged = includeEvents ? [...activityList, ...pollList, ...list] : [...list]; + const merged = includeEvents ? [...pluginEntries.entries, ...activityList, ...list] : [...list]; // Keep backend order for 'hot'; only sort by time when we interleave activities if (!includeEvents) return merged; return merged.sort((a: any, b: any) => { @@ -248,7 +207,7 @@ export default function FeedList({ const bt = new Date(b?.created_at || 0).getTime(); return bt - at; }); - }, [list, activityList, pollList, sortBy]); + }, [list, activityList, pluginEntries.entries, sortBy]); // Memoized filtered list const filteredAndSortedList = useMemo(() => { @@ -360,9 +319,15 @@ export default function FeedList({ } if (isPollCreated) { - const entry: FeedEntry = (item as any).__feedEntry; - const onOpen = (id: string) => navigate(`/voting/p/${id}`); - nodes.push(); + // When polls come from pluginEntries they are already FeedEntry objects; but when merged with posts + // we carry them as lightweight items with id/created_at and a hidden __entry. Prefer __entry if present. + const entry: FeedEntry | undefined = (item as any).__feedEntry || undefined; + if (entry) { + const onOpen = (id: string) => navigate(`/voting/p/${id}`); + nodes.push(); + i += 1; + continue; + } i += 1; continue; } From c9dddda0801459f8d37d7d50b8700dd5f4f1ce68 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Thu, 23 Oct 2025 00:04:32 +0200 Subject: [PATCH 010/279] feat(feed): render plugin entries and include in infinite merge Map plugin entries into list items with __feedEntry for FeedRenderer Keep sort by createdAt; interleave with posts and token-created Expose hasNextPage/fetchNextPage from plugin orchestrator (ready for future paging) --- src/features/social/feed-plugins/FeedOrchestrator.tsx | 4 ++-- src/features/social/views/FeedList.tsx | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/features/social/feed-plugins/FeedOrchestrator.tsx b/src/features/social/feed-plugins/FeedOrchestrator.tsx index 86981f1a1..b534887be 100644 --- a/src/features/social/feed-plugins/FeedOrchestrator.tsx +++ b/src/features/social/feed-plugins/FeedOrchestrator.tsx @@ -4,7 +4,7 @@ import type { AnyFeedPlugin, FeedEntry } from './types'; // Call each plugin.fetchPage in parallel (if provided) and merge results by createdAt export function usePluginEntries(plugins: AnyFeedPlugin[], enabled: boolean) { - const { data } = useInfiniteQuery({ + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['feed-plugins', plugins.map((p) => p.kind)], enabled, initialPageParam: 1, @@ -31,7 +31,7 @@ export function usePluginEntries(plugins: AnyFeedPlugin[], enabled: boolean) { return entries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); }, [data]); - return { entries: merged }; + return { entries: merged, fetchNextPage, hasNextPage, isFetchingNextPage }; } diff --git a/src/features/social/views/FeedList.tsx b/src/features/social/views/FeedList.tsx index 49a44b753..f18ef0a20 100644 --- a/src/features/social/views/FeedList.tsx +++ b/src/features/social/views/FeedList.tsx @@ -199,7 +199,16 @@ export default function FeedList({ const combinedList = useMemo(() => { // Hide token-created events on the popular (hot) tab const includeEvents = sortBy !== "hot"; - const merged = includeEvents ? [...pluginEntries.entries, ...activityList, ...list] : [...list]; + const pluginItems = includeEvents + ? (pluginEntries.entries || []).map((e: any) => ({ + id: e.id, + created_at: e.createdAt, + content: e?.data?.title || "", + sender_address: e?.data?.author || "", + __feedEntry: e, + })) + : []; + const merged = includeEvents ? [...pluginItems, ...activityList, ...list] : [...list]; // Keep backend order for 'hot'; only sort by time when we interleave activities if (!includeEvents) return merged; return merged.sort((a: any, b: any) => { From 3441b9dd2ff916e722cf85f95161657c1ffabea4 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Thu, 23 Oct 2025 00:06:38 +0200 Subject: [PATCH 011/279] fix(feed): use poll create_height to derive createdAt Estimate timestamp from block height to avoid forcing all polls to top Keep logic inside poll plugin; FeedList stays plugin-agnostic --- .../feed-plugins/poll-created/plugin.tsx | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/features/social/feed-plugins/poll-created/plugin.tsx b/src/features/social/feed-plugins/poll-created/plugin.tsx index 5f4238068..24e50b9d3 100644 --- a/src/features/social/feed-plugins/poll-created/plugin.tsx +++ b/src/features/social/feed-plugins/poll-created/plugin.tsx @@ -16,12 +16,16 @@ export type PollCreatedEntryData = { totalVotes?: number; }; -export function adaptPollToEntry(pollAddress: Encoded.ContractAddress, data: Omit): FeedEntry { - const createdAt = new Date().toISOString(); +export function adaptPollToEntry( + pollAddress: Encoded.ContractAddress, + data: Omit, + createdAt?: string +): FeedEntry { + const ts = createdAt || new Date().toISOString(); return { id: `poll-created:${pollAddress}`, kind: 'poll-created', - createdAt, + createdAt: ts, data: { pollAddress, ...data }, }; } @@ -37,25 +41,43 @@ export function registerPollCreatedPlugin() { const ordering = await GovernanceApi.getPollOrdering(false); const top = (ordering?.data || []).slice(0, 10); const entries: FeedEntry[] = []; - for (let i = 0; i < top.length; i += 1) { - const p = top[i]; - try { - const ov = await GovernanceApi.getPollOverview(p.poll); - const meta = ov?.pollState?.metadata || ({} as any); - const optsRec = (ov?.pollState?.vote_options || {}) as Record; - const options = Object.entries(optsRec).map(([k, v]) => ({ id: Number(k), label: String(v), votes: 0 })); - const entry = adaptPollToEntry(p.poll as any, { + // First pass to collect create heights + const overviews = await Promise.all( + top.map(async (p) => { + try { + const ov = await GovernanceApi.getPollOverview(p.poll); + return { p, ov }; + } catch { + return undefined as any; + } + }) + ); + const valid = overviews.filter(Boolean) as { p: any; ov: any }[]; + const createHeights = valid.map(({ ov }) => Number(ov?.pollState?.create_height || 0)); + const maxCreateHeight = Math.max(0, ...createHeights); + const APPROX_BLOCK_MS = 180000; // ~3 minutes per block + + for (let i = 0; i < valid.length; i += 1) { + const { p, ov } = valid[i]; + const meta = ov?.pollState?.metadata || ({} as any); + const optsRec = (ov?.pollState?.vote_options || {}) as Record; + const options = Object.entries(optsRec).map(([k, v]) => ({ id: Number(k), label: String(v), votes: 0 })); + const ch = Number(ov?.pollState?.create_height || 0); + const ageBlocks = Math.max(0, maxCreateHeight - ch); + const createdAt = new Date(Date.now() - ageBlocks * APPROX_BLOCK_MS).toISOString(); + const entry = adaptPollToEntry( + p.poll as any, + { title: meta?.title || 'Untitled poll', author: ov?.pollState?.author as any, closeHeight: ov?.pollState?.close_height as any, createHeight: ov?.pollState?.create_height as any, options, totalVotes: p?.voteCount || 0, - }); - entries.push(entry); - } catch { - // skip failures - } + }, + createdAt + ); + entries.push(entry); } return { entries, nextPage: undefined } as FeedPage; }, From b1dbd03d06303480ade150eb27cc852dbe782509 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Thu, 23 Oct 2025 00:13:01 +0200 Subject: [PATCH 012/279] fix(feed): compute poll option percentages from overview Use stakesForOption.votes length per option to derive counts Set totalVotes from overview.voteCount as primary fallback Percent shown by PollCreatedCard now matches on-chain data --- .../feed-plugins/poll-created/plugin.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/features/social/feed-plugins/poll-created/plugin.tsx b/src/features/social/feed-plugins/poll-created/plugin.tsx index 24e50b9d3..0a2e6694b 100644 --- a/src/features/social/feed-plugins/poll-created/plugin.tsx +++ b/src/features/social/feed-plugins/poll-created/plugin.tsx @@ -61,7 +61,22 @@ export function registerPollCreatedPlugin() { const { p, ov } = valid[i]; const meta = ov?.pollState?.metadata || ({} as any); const optsRec = (ov?.pollState?.vote_options || {}) as Record; - const options = Object.entries(optsRec).map(([k, v]) => ({ id: Number(k), label: String(v), votes: 0 })); + // Build votes per option using overview.stakesForOption[].votes length (vote count, not stake) + const votesByLabel = new Map(); + const sfo = (ov?.stakesForOption || []) as Array<{ option: string; votes: unknown[] }>; + for (const row of sfo) { + const label = String((row as any).option ?? ''); + const count = Array.isArray((row as any).votes) ? (row as any).votes.length : 0; + votesByLabel.set(label, count); + } + const options = Object.entries(optsRec).map(([k, v]) => ({ + id: Number(k), + label: String(v), + votes: votesByLabel.get(String(v)) ?? 0, + })); + const totalVotes = typeof ov?.voteCount === 'number' && !Number.isNaN(ov.voteCount) + ? ov.voteCount + : options.reduce((acc, o) => acc + (o.votes || 0), 0); const ch = Number(ov?.pollState?.create_height || 0); const ageBlocks = Math.max(0, maxCreateHeight - ch); const createdAt = new Date(Date.now() - ageBlocks * APPROX_BLOCK_MS).toISOString(); @@ -73,7 +88,7 @@ export function registerPollCreatedPlugin() { closeHeight: ov?.pollState?.close_height as any, createHeight: ov?.pollState?.create_height as any, options, - totalVotes: p?.voteCount || 0, + totalVotes, }, createdAt ); From e562f71be8ec35380b3c2b56c6cb0c08d436b712 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Thu, 23 Oct 2025 00:15:10 +0200 Subject: [PATCH 013/279] fix(feed): map poll votes by option index instead of label Handle backend returning option as index or label; prefer index Ensures single vote on option 0 shows correct percentage --- .../feed-plugins/poll-created/plugin.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/features/social/feed-plugins/poll-created/plugin.tsx b/src/features/social/feed-plugins/poll-created/plugin.tsx index 0a2e6694b..c16766a4c 100644 --- a/src/features/social/feed-plugins/poll-created/plugin.tsx +++ b/src/features/social/feed-plugins/poll-created/plugin.tsx @@ -61,18 +61,31 @@ export function registerPollCreatedPlugin() { const { p, ov } = valid[i]; const meta = ov?.pollState?.metadata || ({} as any); const optsRec = (ov?.pollState?.vote_options || {}) as Record; - // Build votes per option using overview.stakesForOption[].votes length (vote count, not stake) - const votesByLabel = new Map(); + // Build votes per option index using overview.stakesForOption[].option + // The backend may return either index or label; try both, prefer index. + const indexByLabel = new Map( + Object.entries(optsRec).map(([idx, label]) => [String(label), Number(idx)]) + ); + const votesByIndex = new Map(); const sfo = (ov?.stakesForOption || []) as Array<{ option: string; votes: unknown[] }>; for (const row of sfo) { - const label = String((row as any).option ?? ''); + const raw = (row as any).option; const count = Array.isArray((row as any).votes) ? (row as any).votes.length : 0; - votesByLabel.set(label, count); + let optIndex: number | undefined; + const asNum = Number(raw); + if (!Number.isNaN(asNum)) { + optIndex = asNum; + } else { + optIndex = indexByLabel.get(String(raw)); + } + if (typeof optIndex === 'number' && !Number.isNaN(optIndex)) { + votesByIndex.set(optIndex, count); + } } const options = Object.entries(optsRec).map(([k, v]) => ({ id: Number(k), label: String(v), - votes: votesByLabel.get(String(v)) ?? 0, + votes: votesByIndex.get(Number(k)) ?? 0, })); const totalVotes = typeof ov?.voteCount === 'number' && !Number.isNaN(ov.voteCount) ? ov.voteCount From ac8968642809edb149749c2ae57040a64b80abb5 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Thu, 23 Oct 2025 00:18:43 +0200 Subject: [PATCH 014/279] feat(feed): show chain name+avatar for poll author Reuse AddressAvatarWithChainNameFeed like token-created item Displays Legend or saved chain name after 'by' in poll card --- .../feed-plugins/poll-created/PollCreatedCard.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx b/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx index 9d028d377..60904e9d8 100644 --- a/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx +++ b/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import styles from './PollCreatedCard.module.scss'; import FeedPluginCard from '../FeedPluginCard'; import { cn } from '@/lib/utils'; +import AddressAvatarWithChainNameFeed from '@/@components/Address/AddressAvatarWithChainNameFeed'; export type PollCreatedCardProps = { title: string; @@ -28,9 +29,14 @@ export default function PollCreatedCard({ title, author, closeHeight, currentHei return ( -
{title}
+
+ {author && ( + + )} +
{title}
+
- {author && by {author}} + {author && by} {timeLeft && • {timeLeft}}
From c94b4b102b1997684ac1bc3237fcc2d94544a728 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Thu, 23 Oct 2025 00:23:16 +0200 Subject: [PATCH 015/279] feat(feed): inline chain name after 'by' in poll card Hide ak address; show small avatar + chain name (Legend fallback) Matches token-created item presentation --- .../poll-created/PollCreatedCard.module.scss | 5 +++++ .../feed-plugins/poll-created/PollCreatedCard.tsx | 13 +++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/features/social/feed-plugins/poll-created/PollCreatedCard.module.scss b/src/features/social/feed-plugins/poll-created/PollCreatedCard.module.scss index 1aca4a70b..fe86810d3 100644 --- a/src/features/social/feed-plugins/poll-created/PollCreatedCard.module.scss +++ b/src/features/social/feed-plugins/poll-created/PollCreatedCard.module.scss @@ -14,6 +14,11 @@ font-size: 12px; } +.byName { + color: rgba(255,255,255,0.85); + font-weight: 600; +} + .options { display: grid; gap: 8px; diff --git a/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx b/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx index 60904e9d8..5d4eec37d 100644 --- a/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx +++ b/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx @@ -3,6 +3,7 @@ import styles from './PollCreatedCard.module.scss'; import FeedPluginCard from '../FeedPluginCard'; import { cn } from '@/lib/utils'; import AddressAvatarWithChainNameFeed from '@/@components/Address/AddressAvatarWithChainNameFeed'; +import { useChainName } from '@/hooks/useChainName'; export type PollCreatedCardProps = { title: string; @@ -15,6 +16,7 @@ export type PollCreatedCardProps = { }; export default function PollCreatedCard({ title, author, closeHeight, currentHeight, options, totalVotes = 0, onOpen }: PollCreatedCardProps) { + const { chainName } = useChainName(author || ''); const timeLeft = useMemo(() => { if (!closeHeight || !currentHeight) return undefined; const blocksLeft = Math.max(0, closeHeight - currentHeight); @@ -30,13 +32,16 @@ export default function PollCreatedCard({ title, author, closeHeight, currentHei return (
- {author && ( - - )}
{title}
- {author && by} + {author && ( + <> + by + + {chainName || 'Legend'} + + )} {timeLeft && • {timeLeft}}
From 13de9ea4bf993a28039e36dd479d46ce493e62a1 Mon Sep 17 00:00:00 2001 From: paolomolo Date: Thu, 23 Oct 2025 00:27:02 +0200 Subject: [PATCH 016/279] feat(feed): match author line style for poll card Text: 'Poll by' grey; avatar 20px; full-opacity chain name Show compact timestamp like other items; keep title below meta row --- .../poll-created/PollCreatedCard.tsx | 16 +++++++++------- .../social/feed-plugins/poll-created/plugin.tsx | 1 + 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx b/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx index 5d4eec37d..33314ef28 100644 --- a/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx +++ b/src/features/social/feed-plugins/poll-created/PollCreatedCard.tsx @@ -4,6 +4,7 @@ import FeedPluginCard from '../FeedPluginCard'; import { cn } from '@/lib/utils'; import AddressAvatarWithChainNameFeed from '@/@components/Address/AddressAvatarWithChainNameFeed'; import { useChainName } from '@/hooks/useChainName'; +import { compactTime } from '@/utils/time'; export type PollCreatedCardProps = { title: string; @@ -13,9 +14,10 @@ export type PollCreatedCardProps = { options: { id: number; label: string; votes?: number }[]; totalVotes?: number; onOpen?: () => void; + createdAtIso?: string; }; -export default function PollCreatedCard({ title, author, closeHeight, currentHeight, options, totalVotes = 0, onOpen }: PollCreatedCardProps) { +export default function PollCreatedCard({ title, author, closeHeight, currentHeight, options, totalVotes = 0, onOpen, createdAtIso }: PollCreatedCardProps) { const { chainName } = useChainName(author || ''); const timeLeft = useMemo(() => { if (!closeHeight || !currentHeight) return undefined; @@ -31,18 +33,18 @@ export default function PollCreatedCard({ title, author, closeHeight, currentHei return ( -
-
{title}
-
{author && ( <> - by - + Poll by + {chainName || 'Legend'} + {createdAtIso && · {compactTime(createdAtIso)}} )} - {timeLeft && • {timeLeft}} +
+
+
{title}
diff --git a/src/features/social/feed-plugins/poll-created/plugin.tsx b/src/features/social/feed-plugins/poll-created/plugin.tsx index c16766a4c..19cad99cc 100644 --- a/src/features/social/feed-plugins/poll-created/plugin.tsx +++ b/src/features/social/feed-plugins/poll-created/plugin.tsx @@ -124,6 +124,7 @@ export function registerPollCreatedPlugin() { options={options} totalVotes={totalVotes} onOpen={handleOpen} + createdAtIso={entry.createdAt} /> ); }, From 88d90da4faa2a61a43b23e42e9908c28c9c3957c Mon Sep 17 00:00:00 2001 From: paolomolo Date: Thu, 23 Oct 2025 00:28:51 +0200 Subject: [PATCH 017/279] =?UTF-8?q?feat(feed):=20author=20line=20'Legend?= =?UTF-8?q?=20created=20a=20poll=20=C2=B7=20