From ab90fecdadc0085e92a9cfbc37cf36a275b62b27 Mon Sep 17 00:00:00 2001 From: joe simon Date: Mon, 24 Aug 2026 12:08:56 +0200 Subject: [PATCH 1/9] fix activity query Update the raw event fetch to filter into the GraphQL query. - add per chip conditions (userActivityConds/globalActivityConds) - add events query - add useAutoLoadMore since teztok cannot express that, we need a new indexer Fixes profile and global activity feeds. --- src/data/swr.js | 87 +++++++-------------- src/hooks/use-auto-load-more.js | 20 +++++ src/pages/home/feeds/teia-activity-feed.jsx | 16 +++- src/pages/profile/activity.jsx | 11 ++- src/utils/activity.js | 54 +++++++++++-- 5 files changed, 117 insertions(+), 71 deletions(-) create mode 100644 src/hooks/use-auto-load-more.js diff --git a/src/data/swr.js b/src/data/swr.js index 53ebd4b65..c22c4e363 100644 --- a/src/data/swr.js +++ b/src/data/swr.js @@ -20,11 +20,7 @@ import { } from '@data/api' import laggy from '@utils/swr-laggy-middleware' import { useModerators } from '@data/roles' -import { - ACTIVITY_EVENT_TYPES, - MINT_TYPES, - TEIA_LISTING_TYPES, -} from '@utils/activity' +import { userActivityConds, globalActivityConds } from '@utils/activity' function reorderBigmapData(data, subKey, decode = false) { const bigmapData = data ? {} : undefined @@ -1115,45 +1111,11 @@ export function useOfficialTextPosts(limit = 100) { } const USER_ACTIVITY_QUERY = gql` - query UserActivity( - $address: String! - $types: [String!]! - $mintTypes: [String!]! - $limit: Int! - $offset: Int! - ) { + query UserActivity($conds: [events_bool_exp!]!, $limit: Int!, $offset: Int!) { events( - where: { - _or: [ - { - # Trades, listings and transfers the user is a direct party to. - _and: [ - { - _or: [ - { seller_address: { _eq: $address } } - { buyer_address: { _eq: $address } } - { from_address: { _eq: $address } } - { to_address: { _eq: $address } } - ] - } - { - _or: [ - { implements: { _eq: "SALE" } } - { type: { _in: $types } } - ] - } - ] - } - { - # Mints — the user is identified by artist_address, not by any of - # the trade/transfer address fields (which are null on a mint). - _and: [ - { artist_address: { _eq: $address } } - { type: { _in: $mintTypes } } - ] - } - ] - } + # One condition per event kind (see userActivityConds): the active + # filter chips are pushed into the query so pages stay full. + where: { _or: $conds } order_by: [{ level: desc }, { opid: desc }] limit: $limit offset: $offset @@ -1206,24 +1168,27 @@ const ACTIVITY_PAGE_SIZE = 50 * Note: events are pre-filtered to the relevant types here; category resolution * and chip filtering happen client-side in the Activity tab. */ -export function useUserActivity(address) { +export function useUserActivity(address, activeTypes = []) { + const filterKey = activeTypes.length + ? [...activeTypes].sort().join(',') + : 'all' + const conds = userActivityConds(address, activeTypes) + const getKey = (pageIndex, previousPageData) => { if (!address) return null if (previousPageData && previousPageData.length === 0) return null - return ['user-activity', address, pageIndex] + return ['user-activity', address, filterKey, pageIndex] } const { data, error, size, setSize, isValidating } = useSWRInfinite( getKey, // SWR v1 spreads array-key parts as separate fetcher args. - async (_ns, addr, pageIndex) => { + async (_ns, _addr, _filters, pageIndex) => { const res = await request( import.meta.env.VITE_TEIA_GRAPHQL_API, USER_ACTIVITY_QUERY, { - address: addr, - types: ACTIVITY_EVENT_TYPES, - mintTypes: MINT_TYPES, + conds, limit: ACTIVITY_PAGE_SIZE, offset: pageIndex * ACTIVITY_PAGE_SIZE, } @@ -1251,15 +1216,14 @@ export function useUserActivity(address) { } const GLOBAL_ACTIVITY_QUERY = gql` - query GlobalActivity($types: [String!]!, $limit: Int!, $offset: Int!) { + query GlobalActivity($conds: [events_bool_exp!]!, $limit: Int!, $offset: Int!) { events( + # One condition per event kind (see globalActivityConds): the active + # filter chips are pushed into the query so pages stay full. where: { token: { metadata_status: { _eq: "processed" } } fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } - _or: [ - { implements: { _eq: "SALE" } } - { type: { _in: $types } } - ] + _or: $conds } order_by: [{ level: desc }, { opid: desc }] limit: $limit @@ -1305,27 +1269,30 @@ const GLOBAL_ACTIVITY_QUERY = gql` } ` -const GLOBAL_FEED_TYPES = [...MINT_TYPES, ...TEIA_LISTING_TYPES] - /** * Platform-wide Teia/HEN activity feed (sales, listings, mints), newest first, * with "load more" paging. Not scoped to any address. */ -export function useGlobalActivity() { +export function useGlobalActivity(activeTypes = []) { + const filterKey = activeTypes.length + ? [...activeTypes].sort().join(',') + : 'all' + const conds = globalActivityConds(activeTypes) + const getKey = (pageIndex, previousPageData) => { if (previousPageData && previousPageData.length === 0) return null - return ['global-activity', pageIndex] + return ['global-activity', filterKey, pageIndex] } const { data, error, size, setSize, isValidating } = useSWRInfinite( getKey, // SWR v1 spreads array-key parts as separate fetcher args. - async (_ns, pageIndex) => { + async (_ns, _filters, pageIndex) => { const res = await request( import.meta.env.VITE_TEIA_GRAPHQL_API, GLOBAL_ACTIVITY_QUERY, { - types: GLOBAL_FEED_TYPES, + conds, limit: ACTIVITY_PAGE_SIZE, offset: pageIndex * ACTIVITY_PAGE_SIZE, } diff --git a/src/hooks/use-auto-load-more.js b/src/hooks/use-auto-load-more.js new file mode 100644 index 000000000..a8ed84d15 --- /dev/null +++ b/src/hooks/use-auto-load-more.js @@ -0,0 +1,20 @@ +import { useEffect } from 'react' + +const MIN_ROWS = 10 + +/** + * We need a new indexer + */ +export default function useAutoLoadMore({ + rowCount, + isLoadingInitial, + isReachingEnd, + isLoadingMore, + loadMore, +}) { + useEffect(() => { + if (isLoadingInitial || isReachingEnd || isLoadingMore) return + if (rowCount >= MIN_ROWS) return + loadMore() + }, [rowCount, isLoadingInitial, isReachingEnd, isLoadingMore, loadMore]) +} diff --git a/src/pages/home/feeds/teia-activity-feed.jsx b/src/pages/home/feeds/teia-activity-feed.jsx index 139f97f00..90e238eed 100644 --- a/src/pages/home/feeds/teia-activity-feed.jsx +++ b/src/pages/home/feeds/teia-activity-feed.jsx @@ -3,6 +3,7 @@ import { Loading } from '@atoms/loading' import { Button } from '@atoms/button' import useSettings from '@hooks/use-settings' import useActivityFilter from '@hooks/use-activity-filter' +import useAutoLoadMore from '@hooks/use-auto-load-more' import { useGlobalActivity } from '@data/swr' import { useSocialActivity } from '@data/messaging/useSocialActivity' import { useUserProfiles } from '@data/roles' @@ -20,8 +21,9 @@ import { import activityStyles from '@components/activity/index.module.scss' import styles from './teia-activity-feed.module.scss' -// Drop the "buy" filer, not needed here. -const FEED_FILTERS = ACTIVITY_FILTERS.filter((f) => f.key !== 'buy') +const FEED_FILTERS = ACTIVITY_FILTERS.filter( + (f) => !['buy', 'transfer'].includes(f.key) +) const VIEWS = [ { key: 'trades', label: 'Trades' }, @@ -42,7 +44,7 @@ function TradesFeed() { isLoadingMore, isReachingEnd, loadMore, - } = useGlobalActivity() + } = useGlobalActivity(type.active) const rows = useMemo( () => @@ -62,6 +64,14 @@ function TradesFeed() { [events, walletBlockMap, matchesType, matchesMarket] ) + useAutoLoadMore({ + rowCount: rows.length, + isLoadingInitial, + isReachingEnd, + isLoadingMore, + loadMore, + }) + if (error) { return (
diff --git a/src/pages/profile/activity.jsx b/src/pages/profile/activity.jsx index e567f48f1..f10790b16 100644 --- a/src/pages/profile/activity.jsx +++ b/src/pages/profile/activity.jsx @@ -3,6 +3,7 @@ import { useOutletContext } from 'react-router-dom' import { Loading } from '@atoms/loading' import { useUserActivity } from '@data/swr' import useActivityFilter from '@hooks/use-activity-filter' +import useAutoLoadMore from '@hooks/use-auto-load-more' import { resolveActivityEvent, MARKET_FILTERS } from '@utils/activity' import { ActivityList, ActivityFilters } from '@components/activity' import styles from './activity.module.scss' @@ -22,7 +23,7 @@ export default function Activity() { isLoadingMore, isReachingEnd, loadMore, - } = useUserActivity(address) + } = useUserActivity(address, type.active) const rows = useMemo( () => @@ -39,6 +40,14 @@ export default function Activity() { [events, address, matchesType, matchesMarket] ) + useAutoLoadMore({ + rowCount: rows.length, + isLoadingInitial, + isReachingEnd, + isLoadingMore, + loadMore, + }) + if (error) { return (
diff --git a/src/utils/activity.js b/src/utils/activity.js index 34baf2f49..86c3353b6 100644 --- a/src/utils/activity.js +++ b/src/utils/activity.js @@ -52,15 +52,55 @@ export function marketplaceForType(type) { } } +/** Wallet-to-wallet only. */ +const TRANSFER_COND = { + type: { _eq: 'FA2_TRANSFER' }, + from_address: { _nlike: 'KT1%' }, + to_address: { _nlike: 'KT1%' }, +} + /** - * Event `type` values fetched for the activity feed. SALE is matched on - * `implements` (its `type` is null) so it is not listed here. + * Server-side `events_bool_exp` conditions for the profile activity feed */ -export const ACTIVITY_EVENT_TYPES = [ - ...MINT_TYPES, - ...LISTING_TYPES, - 'FA2_TRANSFER', -] +export function userActivityConds(address, active = []) { + const conds = { + sale: { + implements: { _eq: 'SALE' }, + seller_address: { _eq: address }, + }, + buy: { + implements: { _eq: 'SALE' }, + buyer_address: { _eq: address }, + }, + list: { + type: { _in: LISTING_TYPES }, + seller_address: { _eq: address }, + }, + create: { + type: { _in: MINT_TYPES }, + artist_address: { _eq: address }, + }, + transfer: { + ...TRANSFER_COND, + _or: [ + { from_address: { _eq: address } }, + { to_address: { _eq: address } }, + ], + }, + } + const keys = active.length ? active : Object.keys(conds) + return keys.map((key) => conds[key]).filter(Boolean) +} + +export function globalActivityConds(active = []) { + const conds = { + sale: { implements: { _eq: 'SALE' } }, + list: { type: { _in: TEIA_LISTING_TYPES } }, + create: { type: { _in: MINT_TYPES } }, + } + const keys = active.length ? active : Object.keys(conds) + return keys.map((key) => conds[key]).filter(Boolean) +} /** Filter chips exposed in the UI, in display order. */ export const ACTIVITY_FILTERS = [ From f25873575340bf9d80644ac3ed1a2412ce2f5256 Mon Sep 17 00:00:00 2001 From: joe simon Date: Mon, 24 Aug 2026 15:32:51 +0200 Subject: [PATCH 2/9] activity feed update to dedicated routing Activity views now have their own URLs: - /activity redirects to /activity/trades - /activity/trades - /activity/social - /activity/text --- src/data/messaging/useTextCommentActivity.ts | 105 ++++++++++ src/data/swr.js | 186 ++++++++++------- src/index.jsx | 10 +- src/pages/home/feeds/teia-activity-feed.jsx | 27 ++- .../home/feeds/teia-activity-feed.module.scss | 7 + src/pages/home/feeds/text-activity-feed.jsx | 191 ++++++++++++++++++ 6 files changed, 439 insertions(+), 87 deletions(-) create mode 100644 src/data/messaging/useTextCommentActivity.ts create mode 100644 src/pages/home/feeds/text-activity-feed.jsx diff --git a/src/data/messaging/useTextCommentActivity.ts b/src/data/messaging/useTextCommentActivity.ts new file mode 100644 index 000000000..a3479867d --- /dev/null +++ b/src/data/messaging/useTextCommentActivity.ts @@ -0,0 +1,105 @@ +// Comments posted on text posts + +import useSWRInfinite from 'swr/infinite' +import { request, gql } from 'graphql-request' +import { HEN_CONTRACT_FA2 } from '@constants' +import { fetchRecentCommentsPage, RECENT_LIMIT } from './admin' +import type { SocialActivityItem } from './useSocialActivity' + +const PAGE_SIZE = RECENT_LIMIT + +const TEXT_TOKEN_IDS_QUERY = gql` + query TextTokenIds($ids: [String!]!) { + tokens( + where: { + fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } + token_id: { _in: $ids } + _or: [ + { mime_type: { _eq: "text/plain" } } + { mime_type: { _eq: "text/markdown" } } + ] + } + ) { + token_id + } + } +` + +interface TextCommentsPage { + rawCount: number + items: SocialActivityItem[] +} + +async function fetchTextCommentsPage(offset: number): Promise { + const page = await fetchRecentCommentsPage('token', { + limit: PAGE_SIZE, + offset, + }) + + const candidates = page.filter( + (c) => + !c.hidden && + c.tokenId && + (!c.fa2Address || c.fa2Address === HEN_CONTRACT_FA2) + ) + + let textIds = new Set() + const ids = [...new Set(candidates.map((c) => c.tokenId as string))] + if (ids.length) { + const res: any = await request( + import.meta.env.VITE_TEIA_GRAPHQL_API, + TEXT_TOKEN_IDS_QUERY, + { ids } + ) + textIds = new Set((res.tokens ?? []).map((t: any) => t.token_id)) + } + + return { + rawCount: page.length, + items: candidates + .filter((c) => textIds.has(c.tokenId as string)) + .map((c) => ({ + id: `token-${c.id}`, + kind: 'token_comment' as const, + sender: c.sender, + content: c.content, + timestamp: c.timestamp, + to: `/objkt/${c.tokenId}/comments`, + targetLabel: `OBJKT #${c.tokenId}`, + })), + } +} + +export function useTextCommentActivity() { + const getKey = (pageIndex: number, previous: TextCommentsPage | null) => { + if (previous && previous.rawCount === 0) return null + return ['text-comments', pageIndex] + } + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + // SWR v1 spreads array-key parts as separate fetcher args. + (_ns: string, pageIndex: number) => + fetchTextCommentsPage(pageIndex * PAGE_SIZE), + { revalidateFirstPage: false, revalidateOnFocus: false } + ) + + const items = data ? data.flatMap((p) => p.items) : [] + const isReachingEnd = Boolean( + error || (data && data[data.length - 1]?.rawCount < PAGE_SIZE) + ) + const isLoadingMore = Boolean( + isValidating && data && typeof data[size - 1] === 'undefined' + ) + + return { + items, + error, + isLoadingInitial: !data && !error, + isLoadingMore, + isReachingEnd, + loadMore: () => setSize(size + 1), + } +} + +export default useTextCommentActivity diff --git a/src/data/swr.js b/src/data/swr.js index c22c4e363..4afcb9ee2 100644 --- a/src/data/swr.js +++ b/src/data/swr.js @@ -1110,7 +1110,50 @@ export function useOfficialTextPosts(limit = 100) { ) } +/** Used by every activity feed query. */ +const ActivityEventFieldsFragment = gql` + fragment activityEventFields on events { + id + timestamp + ophash + implements + type + price + amount + editions + seller_address + seller_profile { + name + } + buyer_address + buyer_profile { + name + } + from_address + from_profile { + name + } + to_address + to_profile { + name + } + token { + token_id + fa2_address + name + display_uri + thumbnail_uri + mime_type + artist_address + artist_profile { + name + } + } + } +` + const USER_ACTIVITY_QUERY = gql` + ${ActivityEventFieldsFragment} query UserActivity($conds: [events_bool_exp!]!, $limit: Int!, $offset: Int!) { events( # One condition per event kind (see userActivityConds): the active @@ -1120,42 +1163,7 @@ const USER_ACTIVITY_QUERY = gql` limit: $limit offset: $offset ) { - id - timestamp - ophash - implements - type - price - amount - editions - seller_address - seller_profile { - name - } - buyer_address - buyer_profile { - name - } - from_address - from_profile { - name - } - to_address - to_profile { - name - } - token { - token_id - fa2_address - name - display_uri - thumbnail_uri - mime_type - artist_address - artist_profile { - name - } - } + ...activityEventFields } } ` @@ -1215,7 +1223,76 @@ export function useUserActivity(address, activeTypes = []) { } } +const TEXT_ACTIVITY_QUERY = gql` + ${ActivityEventFieldsFragment} + query TextActivity($conds: [events_bool_exp!]!, $limit: Int!, $offset: Int!) { + events( + where: { + token: { + metadata_status: { _eq: "processed" } + mime_type: { _in: ["text/plain", "text/markdown"] } + } + fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } + _or: $conds + } + order_by: [{ level: desc }, { opid: desc }] + limit: $limit + offset: $offset + ) { + ...activityEventFields + } + } +` + +//Platform-wide activity for text posts +export function useTextActivity(activeTypes = []) { + const filterKey = activeTypes.length + ? [...activeTypes].sort().join(',') + : 'all' + const conds = globalActivityConds(activeTypes) + + const getKey = (pageIndex, previousPageData) => { + if (previousPageData && previousPageData.length === 0) return null + return ['text-activity', filterKey, pageIndex] + } + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + // SWR v1 spreads array-key parts as separate fetcher args. + async (_ns, _filters, pageIndex) => { + const res = await request( + import.meta.env.VITE_TEIA_GRAPHQL_API, + TEXT_ACTIVITY_QUERY, + { + conds, + limit: ACTIVITY_PAGE_SIZE, + offset: pageIndex * ACTIVITY_PAGE_SIZE, + } + ) + return res.events || [] + }, + { revalidateFirstPage: false, revalidateOnFocus: false } + ) + + const events = data ? data.flat() : [] + const isLoadingInitial = !data && !error + const isLoadingMore = + isValidating && data && typeof data[size - 1] === 'undefined' + const isReachingEnd = + error || (data && data[data.length - 1]?.length < ACTIVITY_PAGE_SIZE) + + return { + events, + error, + isLoadingInitial, + isLoadingMore, + isReachingEnd, + loadMore: () => setSize(size + 1), + } +} + const GLOBAL_ACTIVITY_QUERY = gql` + ${ActivityEventFieldsFragment} query GlobalActivity($conds: [events_bool_exp!]!, $limit: Int!, $offset: Int!) { events( # One condition per event kind (see globalActivityConds): the active @@ -1229,42 +1306,7 @@ const GLOBAL_ACTIVITY_QUERY = gql` limit: $limit offset: $offset ) { - id - timestamp - ophash - implements - type - price - amount - editions - seller_address - seller_profile { - name - } - buyer_address - buyer_profile { - name - } - from_address - from_profile { - name - } - to_address - to_profile { - name - } - token { - token_id - fa2_address - name - display_uri - thumbnail_uri - mime_type - artist_address - artist_profile { - name - } - } + ...activityEventFields } } ` diff --git a/src/index.jsx b/src/index.jsx index 3a539d3fe..92b36e4fd 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -109,6 +109,7 @@ import ReactDOM from 'react-dom/client' import { createBrowserRouter, createRoutesFromElements, + Navigate, Route, RouterProvider, } from 'react-router-dom' @@ -121,6 +122,8 @@ import MintForm from '@components/form/MintForm' import { ListsFeed } from '@pages/home/feeds/lists-feed' import { MidiFeed } from '@pages/home/feeds/mime-type-feed' import TeiaActivity from '@pages/activity' +import { TradesFeed, SocialFeed } from '@pages/home/feeds/teia-activity-feed' +import TextActivityFeed from '@pages/home/feeds/text-activity-feed' import CopyrightForm from '@components/copyright/wizard/form/CopyrightForm' import CopyrightPage from '@pages/copyright' import { CopyrightPreview } from '@components/copyright/wizard/preview' @@ -293,7 +296,12 @@ const router = createBrowserRouter( } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> }> {display_routes} diff --git a/src/pages/home/feeds/teia-activity-feed.jsx b/src/pages/home/feeds/teia-activity-feed.jsx index 90e238eed..f8611ed72 100644 --- a/src/pages/home/feeds/teia-activity-feed.jsx +++ b/src/pages/home/feeds/teia-activity-feed.jsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from 'react' +import { useMemo } from 'react' +import { NavLink, Outlet } from 'react-router-dom' import { Loading } from '@atoms/loading' import { Button } from '@atoms/button' import useSettings from '@hooks/use-settings' @@ -28,10 +29,11 @@ const FEED_FILTERS = ACTIVITY_FILTERS.filter( const VIEWS = [ { key: 'trades', label: 'Trades' }, { key: 'social', label: 'Social' }, + { key: 'text', label: 'Text' }, ] /** Trade activity (sales/mints/listings/transfers) — the original feed. */ -function TradesFeed() { +export function TradesFeed() { const { walletBlockMap } = useSettings() const type = useActivityFilter() const market = useActivityFilter() @@ -116,7 +118,7 @@ function TradesFeed() { * Social activity: public channel posts + poll/token comments. * Mounted only when the Social view is active, so its hooks don't fetch up front. */ -function SocialFeed() { +export function SocialFeed() { const kind = useActivityFilter() const { matches } = kind const { @@ -199,29 +201,26 @@ function SocialFeed() { } /** - * Global Activity Tab — a Trades / Social view switch over the platform feed. + * Global Activity layout, each feed is a route now */ export function GlobalActivityFeed() { - const [view, setView] = useState('trades') - return (
{VIEWS.map((v) => ( - + ))}
- {view === 'trades' ? : } +
) } diff --git a/src/pages/home/feeds/teia-activity-feed.module.scss b/src/pages/home/feeds/teia-activity-feed.module.scss index 5b27feb12..c35f1afe7 100644 --- a/src/pages/home/feeds/teia-activity-feed.module.scss +++ b/src/pages/home/feeds/teia-activity-feed.module.scss @@ -11,6 +11,12 @@ color: var(--secondary-color); } +.section_heading { + margin: 2.5rem 0 0.75rem; + font-size: 1rem; + font-weight: 700; +} + .view_toggle { display: flex; gap: 0.5rem; @@ -20,6 +26,7 @@ .view_chip { appearance: none; cursor: pointer; + text-decoration: none; padding: 0.35rem 0.9rem; font-size: 0.85rem; font-weight: 600; diff --git a/src/pages/home/feeds/text-activity-feed.jsx b/src/pages/home/feeds/text-activity-feed.jsx new file mode 100644 index 000000000..1e7b0235e --- /dev/null +++ b/src/pages/home/feeds/text-activity-feed.jsx @@ -0,0 +1,191 @@ +import { useMemo } from 'react' +import { Loading } from '@atoms/loading' +import { Button } from '@atoms/button' +import useSettings from '@hooks/use-settings' +import useActivityFilter from '@hooks/use-activity-filter' +import useAutoLoadMore from '@hooks/use-auto-load-more' +import { useTextActivity } from '@data/swr' +import { useTextCommentActivity } from '@data/messaging/useTextCommentActivity' +import { useUserProfiles } from '@data/roles' +import { + resolveActivityEvent, + ACTIVITY_FILTERS, + MARKET_FILTERS, +} from '@utils/activity' +import { + ActivityList, + ActivityFilters, + SocialActivityRow, +} from '@components/activity' +import activityStyles from '@components/activity/index.module.scss' +import styles from './teia-activity-feed.module.scss' + +// Mint / listing / sale of text posts +const TEXT_FILTERS = ACTIVITY_FILTERS.filter((f) => + ['sale', 'list', 'create'].includes(f.key) +) + +/** Comments posted on text tokens */ +function TextComments() { + const { + items, + error, + isLoadingInitial, + isLoadingMore, + isReachingEnd, + loadMore, + } = useTextCommentActivity() + + const senders = useMemo( + () => [...new Set(items.map((i) => i.sender))], + [items] + ) + const { data: profiles = {} } = useUserProfiles(senders) + + useAutoLoadMore({ + rowCount: items.length, + isLoadingInitial, + isReachingEnd, + isLoadingMore, + loadMore, + }) + + if (error) { + return ( +
+

Error loading comments: {error.message}

+
+ ) + } + + if (isLoadingInitial) { + return + } + + if (items.length === 0) { + return ( +
+

No comments on text posts yet.

+
+ ) + } + + return ( + <> +
+
+ Type + Author + Content + Where + Time +
+ {items.map((item) => ( + + ))} +
+ + {!isReachingEnd && ( +
+ +
+ )} + + ) +} + +/** + * Text-post activity (/activity/text): mints, listings and sales of + * text/plain + text/markdown tokens, plus the comments posted on them. + */ +export function TextActivityFeed() { + const { walletBlockMap } = useSettings() + const type = useActivityFilter() + const market = useActivityFilter() + const { matches: matchesType } = type + const { matches: matchesMarket } = market + const { + events, + error, + isLoadingInitial, + isLoadingMore, + isReachingEnd, + loadMore, + } = useTextActivity(type.active) + + const rows = useMemo( + () => + events + .filter( + (event) => walletBlockMap?.get(event.token?.artist_address) !== 1 + ) + .map((event) => { + const meta = resolveActivityEvent(event, null) + return meta ? { event, meta } : null + }) + .filter(Boolean) + .filter( + ({ meta }) => + matchesType(meta.filterKey) && matchesMarket(meta.marketKey) + ), + [events, walletBlockMap, matchesType, matchesMarket] + ) + + useAutoLoadMore({ + rowCount: rows.length, + isLoadingInitial, + isReachingEnd, + isLoadingMore, + loadMore, + }) + + if (error) { + return ( +
+

Error loading activity: {error.message}

+
+ ) + } + + if (isLoadingInitial) { + return + } + + return ( + <> + + + + 0 || market.active.length > 0 + ? ' for this filter' + : '' + } yet.`} + /> + +

Comments

+ + + ) +} + +export default TextActivityFeed From efb1033eaa0ff674636eaefe0bfdce9d62158416 Mon Sep 17 00:00:00 2001 From: joe simon Date: Mon, 24 Aug 2026 16:42:45 +0200 Subject: [PATCH 3/9] Update Activity Calendar, Copyright and Wiki Feeds - /activity/calendar - /activity/wiki - /activity/copyright Testing a new event call for calendar/copyright/wiki events from tzkt --- src/components/activity/ActionRow.jsx | 49 +++ src/components/activity/index.js | 1 + src/components/activity/index.module.scss | 31 ++ src/data/chain-activity.ts | 301 +++++++++++++++++++ src/index.jsx | 8 + src/pages/home/feeds/chain-activity-feed.jsx | 277 +++++++++++++++++ src/pages/home/feeds/teia-activity-feed.jsx | 3 + 7 files changed, 670 insertions(+) create mode 100644 src/components/activity/ActionRow.jsx create mode 100644 src/data/chain-activity.ts create mode 100644 src/pages/home/feeds/chain-activity-feed.jsx diff --git a/src/components/activity/ActionRow.jsx b/src/components/activity/ActionRow.jsx new file mode 100644 index 000000000..459d0824f --- /dev/null +++ b/src/components/activity/ActionRow.jsx @@ -0,0 +1,49 @@ +import { Link } from 'react-router-dom' +import { getTimeAgo } from '@utils/time' +import { UserLink } from '@components/user-link' +import { ActivityBadge } from './ActivityBadge' +import styles from './index.module.scss' + +/** + * One row in a governance-style activity feed (calendar / wiki / copyright): + * action · actor · target · time. + * This might be merged with the other activity tabs later, Indexer... + */ +export function ActionRow({ + label, + color, + actor, + actorName, + to, + targetLabel, + timestamp, + href, +}) { + return ( +
+
+ +
+ +
+ +
+ +
+ {to ? {targetLabel} : {targetLabel}} +
+ +
+ {href ? ( + + {getTimeAgo(timestamp)} + + ) : ( + getTimeAgo(timestamp) + )} +
+
+ ) +} + +export default ActionRow diff --git a/src/components/activity/index.js b/src/components/activity/index.js index b2b282a62..0753de56e 100644 --- a/src/components/activity/index.js +++ b/src/components/activity/index.js @@ -3,3 +3,4 @@ export { ActivityBadge } from './ActivityBadge' export { ActivityList } from './ActivityList' export { ActivityFilters } from './ActivityFilters' export { SocialActivityRow } from './SocialActivityRow' +export { ActionRow } from './ActionRow' diff --git a/src/components/activity/index.module.scss b/src/components/activity/index.module.scss index fd8df22cf..f1f2bd1ae 100644 --- a/src/components/activity/index.module.scss +++ b/src/components/activity/index.module.scss @@ -162,6 +162,37 @@ $social-grid: 104px minmax(120px, 1fr) minmax(180px, 3fr) minmax(120px, 1fr) padding: 1.2rem 0; } +.action_head, +.action_row { + display: grid; + grid-template-columns: 110px 200px minmax(0, 1fr) 100px; + align-items: center; + gap: 1rem; + min-width: 560px; +} + +.action_head { + padding: 0 0 0.6rem; + color: var(--secondary-color); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + border-bottom: 1px solid var(--border-color); +} + +.action_row { + padding: 0.6rem 0; + border-bottom: 1px solid var(--border-color); + font-size: 0.9rem; +} + +.action_target { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .badge_channel { background: var(--gray-60); } diff --git a/src/data/chain-activity.ts b/src/data/chain-activity.ts new file mode 100644 index 000000000..2045fe948 --- /dev/null +++ b/src/data/chain-activity.ts @@ -0,0 +1,301 @@ +// Platform activity for the calendar and wiki contracts. +// Testing a new way to read chain data + +import useSWRInfinite from 'swr/infinite' +import { COPYRIGHT_CONTRACT } from '@constants' + +const TZKT_API = import.meta.env.VITE_TZKT_API +const PAGE_SIZE = 50 + +export type ChainAction = + | 'created' + | 'updated' + | 'hidden' + | 'proposed' + | 'accepted' + | 'declined' + +export interface ChainActivityConfig { + /** SWR namespace, e.g. 'calendar' | 'wiki'. */ + ns: string + contract: string + /** Payload field prefix: 'event' (calendar) or 'page' (wiki). */ + itemPrefix: 'event' | 'page' +} + +export interface ChainActivityItem { + id: number + action: ChainAction + hidden?: boolean + actor: string + targetId: number | null + proposalId: number | null + isNew: boolean + timestamp: string + ophash: string | null +} + +/** Filter chips for the chain feeds. */ +export const CHAIN_ACTIVITY_FILTERS = [ + { key: 'created', label: 'Created' }, + { key: 'updated', label: 'Edited' }, + { key: 'hidden', label: 'Hidden' }, + { key: 'proposed', label: 'Proposed' }, + { key: 'accepted', label: 'Accepted' }, + { key: 'declined', label: 'Declined' }, +] + +function actionTags(prefix: string): Record { + return { + created: [`${prefix}_created`], + updated: [`${prefix}_updated`], + hidden: [`${prefix}_hidden_updated`], + proposed: ['proposal_created'], + accepted: ['proposal_approved'], + declined: ['proposal_rejected'], + } +} + +interface RawTzktEvent { + id: number + timestamp: string + tag: string + payload: Record | null + transactionId?: number +} + +function parseItem( + row: RawTzktEvent, + config: ChainActivityConfig +): ChainActivityItem | null { + const prefix = config.itemPrefix + const tags = actionTags(prefix) + const action = (Object.keys(tags) as ChainAction[]).find((a) => + tags[a].includes(row.tag) + ) + if (!action) return null + + const pl = row.payload ?? {} + const num = (v: unknown) => (v == null ? null : Number(v)) + const actor = + pl.editor ?? pl.proposer ?? pl.approved_by ?? pl.rejected_by ?? '' + + return { + id: row.id, + action, + hidden: action === 'hidden' ? pl.hidden === true : undefined, + actor: String(actor), + targetId: num(pl[`${prefix}_id`]), + proposalId: num(pl.proposal_id), + isNew: pl[`is_new_${prefix}`] === true, + timestamp: (pl.timestamp as string) ?? row.timestamp, + ophash: null, + } +} + +/** Resolve the operation hashes for a page of TzKT transaction ids. */ +async function fetchTxHashes(txIds: number[]): Promise> { + const hashes = new Map() + if (!txIds.length) return hashes + + const res = await fetch( + `${TZKT_API}/v1/operations/transactions?id.in=${txIds.join( + ',' + )}&select=id,hash&limit=${txIds.length}` + ) + // Rows simply stay unlinked when the lookup fails. + if (!res.ok) return hashes + + const rows: { id: number; hash: string }[] = await res.json() + for (const row of rows) hashes.set(row.id, row.hash) + return hashes +} + +async function fetchChainActivityPage( + config: ChainActivityConfig, + tags: string[], + offset: number +): Promise { + const url = new URL(`${TZKT_API}/v1/contracts/events`) + url.searchParams.set('contract', config.contract) + url.searchParams.set('tag.in', tags.join(',')) + url.searchParams.set('sort.desc', 'id') + url.searchParams.set('limit', String(PAGE_SIZE)) + if (offset > 0) url.searchParams.set('offset', String(offset)) + + const res = await fetch(url.toString()) + if (!res.ok) throw new Error(`TzKT error: ${res.status}`) + const rows: RawTzktEvent[] = await res.json() + + const hashes = await fetchTxHashes([ + ...new Set( + rows.map((row) => row.transactionId).filter(Boolean) as number[] + ), + ]) + + return rows + .map((row) => { + const item = parseItem(row, config) + if (!item) return null + return { + ...item, + ophash: hashes.get(row.transactionId ?? -1) ?? null, + } + }) + .filter(Boolean) as ChainActivityItem[] +} + +/** + * Paginated activity feed for one chain contract. The active filter chips are + * pushed into the TzKT query (`tag.in`), so pages stay full and toggling a + * chip restarts pagination. + */ +export function useChainActivity( + config: ChainActivityConfig, + activeActions: string[] = [] +) { + const tagMap = actionTags(config.itemPrefix) + const actions = ( + activeActions.length ? activeActions : Object.keys(tagMap) + ) as ChainAction[] + const tags = actions.flatMap((a) => tagMap[a] ?? []) + const filterKey = activeActions.length + ? [...activeActions].sort().join(',') + : 'all' + + const getKey = ( + pageIndex: number, + previousPageData: ChainActivityItem[] | null + ) => { + if (previousPageData && previousPageData.length === 0) return null + return ['chain-activity', config.ns, filterKey, pageIndex] + } + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + // SWR v1 spreads array-key parts as separate fetcher args. + (_ns: string, _cns: string, _filters: string, pageIndex: number) => + fetchChainActivityPage(config, tags, pageIndex * PAGE_SIZE), + { revalidateFirstPage: false, revalidateOnFocus: false } + ) + + const items = data ? data.flat() : [] + const isReachingEnd = Boolean( + error || (data && data[data.length - 1]?.length < PAGE_SIZE) + ) + const isLoadingMore = Boolean( + isValidating && data && typeof data[size - 1] === 'undefined' + ) + + return { + items, + error, + isLoadingInitial: !data && !error, + isLoadingMore, + isReachingEnd, + loadMore: () => setSize(size + 1), + } +} + +// --- Copyright agreements (creation only) ---------------------------------- +// New keys in the contract's `copyrights` bigmap, timestamped via the TzKT +// bigmap-updates API. The key is { nat: agreement number, address: creator }. + +export interface CopyrightActivityItem { + id: number + actor: string + agreementId: number | null + timestamp: string + /** Block level of the update (bigmap updates carry no operation hash). */ + level: number | null +} + +let copyrightBigmapId: Promise | null = null + +function getCopyrightBigmapId(): Promise { + if (!copyrightBigmapId) { + copyrightBigmapId = fetch( + `${TZKT_API}/v1/contracts/${COPYRIGHT_CONTRACT}/bigmaps/copyrights` + ) + .then((res) => { + if (!res.ok) throw new Error(`TzKT error: ${res.status}`) + return res.json() + }) + .then((bigmap) => bigmap.ptr as number) + .catch((e) => { + // Forget the failed lookup so the next page can retry. + copyrightBigmapId = null + throw e + }) + } + return copyrightBigmapId +} + +async function fetchCopyrightPage( + offset: number +): Promise { + const bigmap = await getCopyrightBigmapId() + + const url = new URL(`${TZKT_API}/v1/bigmaps/updates`) + url.searchParams.set('bigmap', String(bigmap)) + url.searchParams.set('action', 'add_key') + url.searchParams.set('sort.desc', 'id') + url.searchParams.set('limit', String(PAGE_SIZE)) + if (offset > 0) url.searchParams.set('offset', String(offset)) + + const res = await fetch(url.toString()) + if (!res.ok) throw new Error(`TzKT error: ${res.status}`) + const rows: { + id: number + level?: number + timestamp: string + content?: { key?: { nat?: string; address?: string } } + }[] = await res.json() + + return rows.map((row) => ({ + id: row.id, + actor: String(row.content?.key?.address ?? ''), + agreementId: + row.content?.key?.nat != null ? Number(row.content.key.nat) : null, + timestamp: row.timestamp, + level: row.level ?? null, + })) +} + +/** New copyright agreements, newest first, with "load more" paging. */ +export function useCopyrightActivity() { + const getKey = ( + pageIndex: number, + previousPageData: CopyrightActivityItem[] | null + ) => { + if (previousPageData && previousPageData.length === 0) return null + return ['copyright-activity', pageIndex] + } + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + // SWR v1 spreads array-key parts as separate fetcher args. + (_ns: string, pageIndex: number) => + fetchCopyrightPage(pageIndex * PAGE_SIZE), + { revalidateFirstPage: false, revalidateOnFocus: false } + ) + + const items = data ? data.flat() : [] + const isReachingEnd = Boolean( + error || (data && data[data.length - 1]?.length < PAGE_SIZE) + ) + const isLoadingMore = Boolean( + isValidating && data && typeof data[size - 1] === 'undefined' + ) + + return { + items, + error, + isLoadingInitial: !data && !error, + isLoadingMore, + isReachingEnd, + loadMore: () => setSize(size + 1), + } +} + +export default useChainActivity diff --git a/src/index.jsx b/src/index.jsx index 92b36e4fd..976eff508 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -124,6 +124,11 @@ import { MidiFeed } from '@pages/home/feeds/mime-type-feed' import TeiaActivity from '@pages/activity' import { TradesFeed, SocialFeed } from '@pages/home/feeds/teia-activity-feed' import TextActivityFeed from '@pages/home/feeds/text-activity-feed' +import { + CalendarActivityFeed, + CopyrightActivityFeed, + WikiActivityFeed, +} from '@pages/home/feeds/chain-activity-feed' import CopyrightForm from '@components/copyright/wizard/form/CopyrightForm' import CopyrightPage from '@pages/copyright' import { CopyrightPreview } from '@components/copyright/wizard/preview' @@ -301,6 +306,9 @@ const router = createBrowserRouter( } /> } /> } /> + } /> + } /> + } /> } /> }> diff --git a/src/pages/home/feeds/chain-activity-feed.jsx b/src/pages/home/feeds/chain-activity-feed.jsx new file mode 100644 index 000000000..246faea0d --- /dev/null +++ b/src/pages/home/feeds/chain-activity-feed.jsx @@ -0,0 +1,277 @@ +import { useCallback, useMemo } from 'react' +import useSWR from 'swr' +import { Loading } from '@atoms/loading' +import { Button } from '@atoms/button' +import { CALENDAR_CONTRACT, WIKI_CONTRACT } from '@constants' +import useActivityFilter from '@hooks/use-activity-filter' +import { + useChainActivity, + useCopyrightActivity, + CHAIN_ACTIVITY_FILTERS, +} from '@data/chain-activity' +import { useUserProfiles } from '@data/roles' +import { useWiki } from '@data/wiki' +import { fetchEvents, fetchEventContent } from '@data/calendar-chain' +import { ActivityFilters, ActionRow } from '@components/activity' +import activityStyles from '@components/activity/index.module.scss' +import styles from './teia-activity-feed.module.scss' + +const ACTION_META = { + created: { label: 'Created', color: 'create' }, + updated: { label: 'Edited', color: 'list' }, + proposed: { label: 'Proposed', color: 'poll' }, + accepted: { label: 'Accepted', color: 'sale' }, + declined: { label: 'Declined', color: 'transfer' }, +} + +const CALENDAR = { + ns: 'calendar', + contract: CALENDAR_CONTRACT, + itemPrefix: 'event', + itemLabel: 'Event', + itemTo: (id) => `/calendar/event/chain-${id}`, +} + +const WIKI = { + ns: 'wiki', + contract: WIKI_CONTRACT, + itemPrefix: 'page', + itemLabel: 'Page', + itemTo: (id) => `/wiki/${id}`, +} + +function present(item, config, targetOf) { + const meta = + item.action === 'hidden' + ? { label: item.hidden ? 'Hidden' : 'Unhidden', color: 'burn' } + : ACTION_META[item.action] + + let to = null + let targetLabel + if (item.targetId != null) { + const custom = targetOf?.(item.targetId) + to = custom?.to ?? config.itemTo(item.targetId) + targetLabel = custom?.label ?? `${config.itemLabel} #${item.targetId}` + } else if (item.isNew) { + targetLabel = `New ${config.itemLabel.toLowerCase()} proposal` + } else if (item.proposalId != null) { + targetLabel = `Proposal #${item.proposalId}` + } else { + targetLabel = '—' + } + + return { ...meta, to, targetLabel } +} + +function ChainFeed({ config, targetOf, loadingMessage }) { + const action = useActivityFilter() + const { + items, + error, + isLoadingInitial, + isLoadingMore, + isReachingEnd, + loadMore, + } = useChainActivity(config, action.active) + + const actors = useMemo( + () => [...new Set(items.map((i) => i.actor).filter(Boolean))], + [items] + ) + const { data: profiles = {} } = useUserProfiles(actors) + + if (error) { + return ( +
+

Error loading activity: {error.message}

+
+ ) + } + + if (isLoadingInitial) { + return + } + + return ( + <> + + + {items.length === 0 ? ( +
+

+ No activity + {action.active.length > 0 ? ' for this filter' : ''} yet. +

+
+ ) : ( + <> +
+
+ Action + By + Item + Time +
+ {items.map((item) => ( + + ))} +
+ + {!isReachingEnd && ( +
+ +
+ )} + + )} + + ) +} + +function useCalendarTitles() { + const { data } = useSWR( + 'chain-activity:calendar-titles', + async () => { + const events = await fetchEvents() + const docs = await Promise.allSettled( + events.map((e) => fetchEventContent(e.cid)) + ) + const titles = {} + events.forEach((e, i) => { + if (docs[i].status === 'fulfilled' && docs[i].value?.title) { + titles[e.id] = docs[i].value.title + } + }) + return titles + }, + { revalidateOnFocus: false, dedupingInterval: 60_000 } + ) + return data +} + +export function CalendarActivityFeed() { + const titles = useCalendarTitles() + const targetOf = useCallback( + (id) => { + const title = titles?.[id] + return title ? { label: title, to: `/calendar/event/chain-${id}` } : null + }, + [titles] + ) + + return ( + + ) +} + +export function WikiActivityFeed() { + const { data: wiki } = useWiki() + const targetOf = useCallback( + (id) => { + const meta = wiki?.meta?.[id] + if (!meta) return null + return { label: meta.title, to: `/wiki/${meta.slug || id}` } + }, + [wiki] + ) + + return ( + + ) +} + +export function CopyrightActivityFeed() { + const { + items, + error, + isLoadingInitial, + isLoadingMore, + isReachingEnd, + loadMore, + } = useCopyrightActivity() + + const actors = useMemo( + () => [...new Set(items.map((i) => i.actor).filter(Boolean))], + [items] + ) + const { data: profiles = {} } = useUserProfiles(actors) + + if (error) { + return ( +
+

Error loading activity: {error.message}

+
+ ) + } + + if (isLoadingInitial) { + return + } + + if (items.length === 0) { + return ( +
+

No copyright agreements yet.

+
+ ) + } + + return ( + <> +
+
+ Action + By + Item + Time +
+ {items.map((item) => ( + + ))} +
+ + {!isReachingEnd && ( +
+ +
+ )} + + ) +} diff --git a/src/pages/home/feeds/teia-activity-feed.jsx b/src/pages/home/feeds/teia-activity-feed.jsx index f8611ed72..f8b46716a 100644 --- a/src/pages/home/feeds/teia-activity-feed.jsx +++ b/src/pages/home/feeds/teia-activity-feed.jsx @@ -30,6 +30,9 @@ const VIEWS = [ { key: 'trades', label: 'Trades' }, { key: 'social', label: 'Social' }, { key: 'text', label: 'Text' }, + { key: 'calendar', label: 'Calendar' }, + { key: 'copyright', label: 'Copyright' }, + { key: 'wiki', label: 'Wiki' }, ] /** Trade activity (sales/mints/listings/transfers) — the original feed. */ From 95af839c1f049acea2fa5d803d879776f54de959 Mon Sep 17 00:00:00 2001 From: joe simon Date: Mon, 24 Aug 2026 18:01:44 +0200 Subject: [PATCH 4/9] Add Indicator to Profile Comments Tab, Comments Count --- src/atoms/tab/Tabs.tsx | 5 +++++ src/atoms/tab/index.module.scss | 6 ++++++ src/pages/profile/index.jsx | 14 +++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/atoms/tab/Tabs.tsx b/src/atoms/tab/Tabs.tsx index b445e1b82..5c7ac7895 100644 --- a/src/atoms/tab/Tabs.tsx +++ b/src/atoms/tab/Tabs.tsx @@ -10,6 +10,8 @@ export interface TabOptions { restricted?: boolean /**only relevant when user is the owner*/ private?: boolean + /**small count indicator after the title*/ + count?: number } interface TabsProps { @@ -48,6 +50,9 @@ export const Tabs = ({ to={tab?.to} > {tab.title} + {tab.count != null && ( + {tab.count} + )} ) } diff --git a/src/atoms/tab/index.module.scss b/src/atoms/tab/index.module.scss index fdc069866..d971f3667 100644 --- a/src/atoms/tab/index.module.scss +++ b/src/atoms/tab/index.module.scss @@ -24,6 +24,12 @@ } } +.count { + margin-left: 6px; + font-size: 0.75em; + color: var(--gray-60); +} + .tabs { display: flex; align-self: flex-start; diff --git a/src/pages/profile/index.jsx b/src/pages/profile/index.jsx index 85f8050ec..985933aca 100644 --- a/src/pages/profile/index.jsx +++ b/src/pages/profile/index.jsx @@ -14,6 +14,7 @@ import { Tabs } from '@atoms/tab' import Button from '@atoms/button/Button' import { Warning } from './warning' import { useLocalSettings } from '@context/localSettingsStore' +import { useUserComments } from '@data/messaging/token-comments' import { create } from 'zustand' import { subscribeWithSelector } from 'zustand/middleware' @@ -109,6 +110,13 @@ export default function Display() { return underReviewMap.get(user.address) === 1 }, [user?.address, underReviewMap]) + // Exact comment count for the Comments tab badge (hidden excluded) + const { data: comments } = useUserComments(user?.address) + const commentCount = useMemo( + () => (comments ?? []).filter((c) => !c.hidden).length, + [comments] + ) + if (error) { throw error } @@ -128,7 +136,11 @@ export default function Display() { { title: 'Text', to: 'text' }, { title: 'Activity', to: 'activity' }, { title: 'Channels', to: 'channels' }, - { title: 'Comments', to: 'comments' }, + { + title: 'Comments', + to: 'comments', + count: commentCount > 0 ? commentCount : undefined, + }, { title: 'Copyrights', to: 'copyrights' }, { title: 'Curations', to: 'curations' }, ] From 1727edd211bc3e046d286af5af73a5a54a45c66a Mon Sep 17 00:00:00 2001 From: joe simon Date: Mon, 24 Aug 2026 23:51:40 +0200 Subject: [PATCH 5/9] Update Profile Comment Tab Design Gather Token Comments with thumbnail & title Add some spacing --- src/pages/profile/comments.jsx | 97 +++++++++++++++++++------- src/pages/profile/comments.module.scss | 58 +++++++++++---- 2 files changed, 116 insertions(+), 39 deletions(-) diff --git a/src/pages/profile/comments.jsx b/src/pages/profile/comments.jsx index 05a0ab9b0..2585e8917 100644 --- a/src/pages/profile/comments.jsx +++ b/src/pages/profile/comments.jsx @@ -3,12 +3,19 @@ import { Link, useOutletContext } from 'react-router-dom' import { Loading } from '@atoms/loading' import { useUserComments } from '@data/messaging/token-comments' import { useUserProfiles } from '@data/roles' +import { useObjktsByIds } from '@data/swr' +import { HashToURL } from '@utils' import { walletPreview } from '@utils/string' import { getTimeAgo } from '@utils/time' import { Identicon } from '@atoms/identicons' import styles from './comments.module.scss' import commentStyles from '@components/token-comments/index.module.scss' +const tokenThumb = (token) => { + const uri = token?.display_uri || token?.thumbnail_uri + return uri ? HashToURL(uri, 'CDN', { size: 'small' }) : null +} + export default function ProfileComments() { const { address } = useOutletContext() const { data: comments, isLoading } = useUserComments(address) @@ -20,6 +27,12 @@ export default function ProfileComments() { ) const { data: profiles = {} } = useUserProfiles(senderAddrs) + const tokenIds = useMemo( + () => (comments ?? []).map((c) => c.tokenId), + [comments] + ) + const tokens = useObjktsByIds(tokenIds) + if (isLoading) return const visibleComments = (comments ?? []).filter((c) => !c.hidden) @@ -64,32 +77,50 @@ export default function ProfileComments() {
{groupByToken && grouped ? ( - Object.values(grouped).map((group) => ( -
- { + const token = tokens?.[group.tokenId] + const thumb = tokenThumb(token) + return ( +
- OBJKT#{group.tokenId} - - {group.comments.length}{' '} - {group.comments.length === 1 ? 'comment' : 'comments'} - - -
- {group.comments.map((c) => ( - - ))} + + {thumb ? ( + + ) : ( + + )} + + + {token?.name || `OBJKT #${group.tokenId}`} + + + #{group.tokenId} · {group.comments.length}{' '} + {group.comments.length === 1 ? 'comment' : 'comments'} + + + +
+ {group.comments.map((c) => ( + + ))} +
-
- )) + ) + }) ) : (
{visibleComments.map((c) => ( @@ -97,6 +128,7 @@ export default function ProfileComments() { key={c.id} comment={c} profiles={profiles} + tokenName={tokens?.[c.tokenId]?.name} showTokenLink /> ))} @@ -106,7 +138,12 @@ export default function ProfileComments() { ) } -function ProfileCommentItem({ comment, profiles, showTokenLink = false }) { +function ProfileCommentItem({ + comment, + profiles, + tokenName, + showTokenLink = false, +}) { const alias = profiles[comment.sender]?.alias || walletPreview(comment.sender) const logo = profiles[comment.sender]?.logo const isEdited = comment.version > 1 @@ -135,9 +172,15 @@ function ProfileCommentItem({ comment, profiles, showTokenLink = false }) { - on OBJKT# - {comment.tokenId} + on{' '} + {tokenName || ( + <> + OBJKT# + {comment.tokenId} + + )} )}
diff --git a/src/pages/profile/comments.module.scss b/src/pages/profile/comments.module.scss index 6e3418536..895b4a866 100644 --- a/src/pages/profile/comments.module.scss +++ b/src/pages/profile/comments.module.scss @@ -5,7 +5,10 @@ display: flex; flex-direction: column; gap: 16px; - padding: 24px 0; + width: 100%; + max-width: var(--max-width-internal); + margin: 0 auto; + padding: 2rem 1rem 0; } .header { @@ -46,14 +49,15 @@ .commentList { display: flex; flex-direction: column; - gap: 4px; + gap: 12px; + max-width: 80ch; } .tokenGroup { display: flex; flex-direction: column; - gap: 8px; - padding: 12px 0; + gap: 12px; + padding: 16px 0 20px; border-bottom: 1px solid var(--border-color); &:last-child { @@ -61,22 +65,49 @@ } } +.groupComments { + @include respond-to('tablet') { + padding-left: 52px; + } +} + .tokenLink { - font-size: $font-body; - font-weight: 600; + display: flex; + align-items: center; + gap: 12px; + min-width: 0; color: var(--text-color); text-decoration: none; - display: flex; - align-items: baseline; - gap: 8px; - &:hover { + &:hover .tokenName { text-decoration: underline; } } -.tokenLinkCount { - font-weight: 400; +.tokenThumb { + flex-shrink: 0; + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 2px; + background: var(--border-color); +} + +.tokenTitle { + display: flex; + flex-direction: column; + min-width: 0; +} + +.tokenName { + font-size: $font-body; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tokenMeta { font-size: $font-small; opacity: 0.5; } @@ -87,6 +118,9 @@ text-decoration: none; opacity: 0.5; margin-left: auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; &:hover { From b9bc8e5c6e60e99213c69d09f73005b0b2a59597 Mon Sep 17 00:00:00 2001 From: joe simon Date: Mon, 31 Aug 2026 20:11:13 +0200 Subject: [PATCH 6/9] Add Comment Count to Objkt Comments Tab Show the number of comments as a badge on the Comments tab of the token display page. --- .../token-comments/TokenComments.jsx | 2 +- src/pages/objkt-display/index.tsx | 26 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/token-comments/TokenComments.jsx b/src/components/token-comments/TokenComments.jsx index 367fe344d..1cc72b32a 100644 --- a/src/components/token-comments/TokenComments.jsx +++ b/src/components/token-comments/TokenComments.jsx @@ -13,7 +13,7 @@ import styles from './index.module.scss' const POST_FEE_LABEL = '0.025 XTZ' -function buildTree(comments) { +export function buildTree(comments) { const visible = [] const hiddenIds = new Set() for (const c of comments) { diff --git a/src/pages/objkt-display/index.tsx b/src/pages/objkt-display/index.tsx index fa4b0997b..f828edac5 100644 --- a/src/pages/objkt-display/index.tsx +++ b/src/pages/objkt-display/index.tsx @@ -1,4 +1,5 @@ /* eslint-disable react-hooks/exhaustive-deps */ +import { useMemo } from 'react' import { Outlet, useOutletContext, useParams } from 'react-router-dom' import useSWR from 'swr' @@ -18,6 +19,8 @@ import { TabOptions, Tabs } from '@atoms/tab/Tabs' import { useUserStore } from '@context/userStore' import { useLocalSettings } from '@context/localSettingsStore' import { NFT } from '@types' +import { useTokenComments } from '@data/messaging/token-comments' +import { buildTree } from '@components/token-comments/TokenComments' type ObjktDisplayContext = { nft: NFT @@ -28,7 +31,7 @@ export const useObjktDisplayContext = () => { return useOutletContext() } -const TABS = [ +const BASE_TABS = [ { title: 'Info', to: '', @@ -136,6 +139,25 @@ export const ObjktDisplay = () => { } ) + // Comment count for the Comments tab badge (same visibility rules as the tab) + const { data: comments } = useTokenComments( + nft?.fa2_address, + nft?.token_id != null ? String(nft.token_id) : undefined + ) + const commentCount = useMemo( + () => Object.keys(buildTree(comments ?? []).byId).length, + [comments] + ) + const tabs = useMemo( + () => + BASE_TABS.map((tab) => + tab.to === 'comments' + ? { ...tab, count: commentCount > 0 ? commentCount : undefined } + : tab + ), + [commentCount] + ) + const loading = !nft && !error if (loading) { @@ -204,7 +226,7 @@ export const ObjktDisplay = () => {
{ // the baker tab is opt-in via local settings (default off) From c5ed900ddca83056b5b2d809521ec8af772c920a Mon Sep 17 00:00:00 2001 From: joe simon Date: Mon, 31 Aug 2026 20:45:55 +0200 Subject: [PATCH 7/9] Show Social first on activity tab --- src/index.jsx | 2 +- src/pages/home/feeds/teia-activity-feed.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.jsx b/src/index.jsx index 976eff508..9c1ad07f6 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -302,7 +302,7 @@ const router = createBrowserRouter( } /> }> - } /> + } /> } /> } /> } /> diff --git a/src/pages/home/feeds/teia-activity-feed.jsx b/src/pages/home/feeds/teia-activity-feed.jsx index f8b46716a..5ff8208da 100644 --- a/src/pages/home/feeds/teia-activity-feed.jsx +++ b/src/pages/home/feeds/teia-activity-feed.jsx @@ -27,8 +27,8 @@ const FEED_FILTERS = ACTIVITY_FILTERS.filter( ) const VIEWS = [ - { key: 'trades', label: 'Trades' }, { key: 'social', label: 'Social' }, + { key: 'trades', label: 'Trades' }, { key: 'text', label: 'Text' }, { key: 'calendar', label: 'Calendar' }, { key: 'copyright', label: 'Copyright' }, From b5769fe93bab6e3ac748126f47c777b2314dd6b5 Mon Sep 17 00:00:00 2001 From: joe simon Date: Tue, 1 Sep 2026 01:06:26 +0200 Subject: [PATCH 8/9] Mobile UI Update Fix Activity Tab Mobile UI --- src/components/activity/filters.module.scss | 9 +- src/components/activity/index.module.scss | 89 +++++++++++++++++++ src/components/activity/list.module.scss | 6 ++ .../home/feeds/teia-activity-feed.module.scss | 24 ++--- 4 files changed, 113 insertions(+), 15 deletions(-) diff --git a/src/components/activity/filters.module.scss b/src/components/activity/filters.module.scss index 40aba5ade..71fae500b 100644 --- a/src/components/activity/filters.module.scss +++ b/src/components/activity/filters.module.scss @@ -2,7 +2,7 @@ display: flex; flex-wrap: wrap; justify-content: center; - gap: 0.5rem; + gap: 0.4rem; margin-bottom: 1rem; // Primary & Secondary Sales row @@ -12,16 +12,17 @@ } .chip { - padding: 0.35rem 0.85rem; + padding: 0.2rem 0.7rem; border: 1px solid var(--border-color); border-radius: 0; background: transparent; - color: var(--text-color); + color: var(--secondary-color); cursor: pointer; - font-size: 0.85rem; + font-size: 0.78rem; transition: background 200ms ease; &:hover { + color: var(--text-color); background: var(--border-color); } } diff --git a/src/components/activity/index.module.scss b/src/components/activity/index.module.scss index f1f2bd1ae..3cd07b4de 100644 --- a/src/components/activity/index.module.scss +++ b/src/components/activity/index.module.scss @@ -202,3 +202,92 @@ $social-grid: 104px minmax(120px, 1fr) minmax(180px, 3fr) minmax(120px, 1fr) .badge_token { background: var(--gray-90); } + +// Mobile styling +@media (max-width: 600px) { + .social_head, + .action_head { + display: none; + } + + .badge { + min-width: 0; + } + + .row, + .social_row, + .action_row { + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.4rem 0.6rem; + min-width: 0; + padding: 0.8rem 0; + } + + .row { + grid-template-areas: + 'badge amount time' + 'item item item' + 'from to price'; + } + + .social_row { + grid-template-areas: + 'badge from time' + 'content content content' + 'target target target'; + } + + .action_row { + grid-template-areas: + 'badge from time' + 'target target target'; + } + + .event { + grid-area: badge; + } + .item { + grid-area: item; + } + .from { + grid-area: from; + } + .to { + grid-area: to; + } + .amount { + grid-area: amount; + } + .price { + grid-area: price; + } + .time { + grid-area: time; + } + .social_content { + grid-area: content; + white-space: normal; + } + .social_target, + .action_target { + grid-area: target; + } + + .amount, + .from, + .to { + text-align: left; + } + + .row .to:not(:empty) { + display: flex; + align-items: center; + gap: 0.3rem; + + &::before { + content: '→'; + flex-shrink: 0; + color: var(--secondary-color); + } + } +} diff --git a/src/components/activity/list.module.scss b/src/components/activity/list.module.scss index e41a9a304..ff3b91f16 100644 --- a/src/components/activity/list.module.scss +++ b/src/components/activity/list.module.scss @@ -21,6 +21,12 @@ } } +@media (max-width: 600px) { + .head { + display: none; + } +} + .more { display: flex; justify-content: center; diff --git a/src/pages/home/feeds/teia-activity-feed.module.scss b/src/pages/home/feeds/teia-activity-feed.module.scss index c35f1afe7..2e52c349d 100644 --- a/src/pages/home/feeds/teia-activity-feed.module.scss +++ b/src/pages/home/feeds/teia-activity-feed.module.scss @@ -19,29 +19,31 @@ .view_toggle { display: flex; - gap: 0.5rem; - margin-bottom: 1rem; + flex-wrap: wrap; + gap: 0 1.4rem; + margin-bottom: 1.25rem; + border-bottom: 1px solid var(--border-color); } .view_chip { appearance: none; cursor: pointer; text-decoration: none; - padding: 0.35rem 0.9rem; - font-size: 0.85rem; + padding: 0.4rem 0; + margin-bottom: -1px; + font-size: 1rem; font-weight: 600; - color: var(--text-color); + color: var(--secondary-color); background: transparent; - border: 1px solid var(--border-color); - border-radius: 0; + border: none; + border-bottom: 2px solid transparent; &:hover { - border-color: var(--text-color); + color: var(--text-color); } } .view_chip_active { - color: var(--gray-0); - background: var(--text-color); - border-color: var(--text-color); + color: var(--text-color); + border-bottom-color: var(--text-color); } From 70747fcc21d11416e2c3ca76c302d1c2583cfcae Mon Sep 17 00:00:00 2001 From: joe simon Date: Wed, 2 Sep 2026 09:07:53 +0200 Subject: [PATCH 9/9] Add newest/oldest sort to activity feeds Add newest / oldest sorting to the activity page - teztok "order_by" - tzkt `sort.asc`/`sort.desc` --- src/components/activity/ActivityControls.jsx | 32 +++++++++ src/components/activity/filters.module.scss | 72 ++++++++++++++++++++ src/components/activity/index.js | 1 + src/data/chain-activity.ts | 38 +++++++---- src/data/messaging/admin.ts | 19 ++++-- src/data/messaging/useSocialActivity.ts | 36 +++++++--- src/data/messaging/useTextCommentActivity.ts | 18 +++-- src/data/swr.js | 38 +++++++---- src/pages/home/feeds/chain-activity-feed.jsx | 29 +++++--- src/pages/home/feeds/teia-activity-feed.jsx | 43 +++++++----- src/pages/home/feeds/text-activity-feed.jsx | 34 +++++---- 11 files changed, 272 insertions(+), 88 deletions(-) create mode 100644 src/components/activity/ActivityControls.jsx diff --git a/src/components/activity/ActivityControls.jsx b/src/components/activity/ActivityControls.jsx new file mode 100644 index 000000000..beb29f2b5 --- /dev/null +++ b/src/components/activity/ActivityControls.jsx @@ -0,0 +1,32 @@ +import styles from './filters.module.scss' + +export const SORT_OPTIONS = [ + { key: 'newest', label: 'Newest first' }, + { key: 'oldest', label: 'Oldest first' }, +] + +export function ActivityControls({ sort, onSortChange, children }) { + return ( +
+
{children}
+ + +
+ ) +} + +export default ActivityControls diff --git a/src/components/activity/filters.module.scss b/src/components/activity/filters.module.scss index 71fae500b..71d924aa8 100644 --- a/src/components/activity/filters.module.scss +++ b/src/components/activity/filters.module.scss @@ -32,3 +32,75 @@ color: var(--background-color); border-color: var(--text-color); } + +.controls { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; +} + +.controls_filters { + flex: 1; + min-width: 0; + + > .filters { + margin-bottom: 0; + } + + > .filters + .filters { + margin-top: 0.4rem; + } +} + +.sort { + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; + color: var(--secondary-color); + font-size: 0.78rem; +} + +.sort_label { + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.sort_select { + appearance: none; + padding: 0.2rem 1.4rem 0.2rem 0.6rem; + border: 1px solid var(--border-color); + border-radius: 0; + background: transparent; + color: var(--text-color); + cursor: pointer; + font-family: inherit; + font-size: 0.78rem; + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: right 0.65rem center, right 0.4rem center; + background-size: 4px 4px, 4px 4px; + background-repeat: no-repeat; + + &:hover { + border-color: var(--text-color); + } + + option { + background: var(--background-color); + color: var(--text-color); + } +} + +@media (max-width: 600px) { + .controls { + flex-direction: column; + align-items: stretch; + gap: 0.6rem; + } + + .sort { + justify-content: center; + } +} diff --git a/src/components/activity/index.js b/src/components/activity/index.js index 0753de56e..7814bd262 100644 --- a/src/components/activity/index.js +++ b/src/components/activity/index.js @@ -2,5 +2,6 @@ export { ActivityRow, default } from './ActivityRow' export { ActivityBadge } from './ActivityBadge' export { ActivityList } from './ActivityList' export { ActivityFilters } from './ActivityFilters' +export { ActivityControls } from './ActivityControls' export { SocialActivityRow } from './SocialActivityRow' export { ActionRow } from './ActionRow' diff --git a/src/data/chain-activity.ts b/src/data/chain-activity.ts index 2045fe948..27d57a1f0 100644 --- a/src/data/chain-activity.ts +++ b/src/data/chain-activity.ts @@ -3,6 +3,7 @@ import useSWRInfinite from 'swr/infinite' import { COPYRIGHT_CONTRACT } from '@constants' +import type { ActivitySort } from '@data/messaging/useSocialActivity' const TZKT_API = import.meta.env.VITE_TZKT_API const PAGE_SIZE = 50 @@ -114,12 +115,13 @@ async function fetchTxHashes(txIds: number[]): Promise> { async function fetchChainActivityPage( config: ChainActivityConfig, tags: string[], - offset: number + offset: number, + sort: 'asc' | 'desc' = 'desc' ): Promise { const url = new URL(`${TZKT_API}/v1/contracts/events`) url.searchParams.set('contract', config.contract) url.searchParams.set('tag.in', tags.join(',')) - url.searchParams.set('sort.desc', 'id') + url.searchParams.set(`sort.${sort}`, 'id') url.searchParams.set('limit', String(PAGE_SIZE)) if (offset > 0) url.searchParams.set('offset', String(offset)) @@ -152,7 +154,8 @@ async function fetchChainActivityPage( */ export function useChainActivity( config: ChainActivityConfig, - activeActions: string[] = [] + activeActions: string[] = [], + sort: ActivitySort = 'newest' ) { const tagMap = actionTags(config.itemPrefix) const actions = ( @@ -162,20 +165,26 @@ export function useChainActivity( const filterKey = activeActions.length ? [...activeActions].sort().join(',') : 'all' + const dir = sort === 'oldest' ? 'asc' : 'desc' const getKey = ( pageIndex: number, previousPageData: ChainActivityItem[] | null ) => { if (previousPageData && previousPageData.length === 0) return null - return ['chain-activity', config.ns, filterKey, pageIndex] + return ['chain-activity', config.ns, filterKey, sort, pageIndex] } const { data, error, size, setSize, isValidating } = useSWRInfinite( getKey, // SWR v1 spreads array-key parts as separate fetcher args. - (_ns: string, _cns: string, _filters: string, pageIndex: number) => - fetchChainActivityPage(config, tags, pageIndex * PAGE_SIZE), + ( + _ns: string, + _cns: string, + _filters: string, + _sort: ActivitySort, + pageIndex: number + ) => fetchChainActivityPage(config, tags, pageIndex * PAGE_SIZE, dir), { revalidateFirstPage: false, revalidateOnFocus: false } ) @@ -232,14 +241,15 @@ function getCopyrightBigmapId(): Promise { } async function fetchCopyrightPage( - offset: number + offset: number, + sort: 'asc' | 'desc' = 'desc' ): Promise { const bigmap = await getCopyrightBigmapId() const url = new URL(`${TZKT_API}/v1/bigmaps/updates`) url.searchParams.set('bigmap', String(bigmap)) url.searchParams.set('action', 'add_key') - url.searchParams.set('sort.desc', 'id') + url.searchParams.set(`sort.${sort}`, 'id') url.searchParams.set('limit', String(PAGE_SIZE)) if (offset > 0) url.searchParams.set('offset', String(offset)) @@ -262,21 +272,23 @@ async function fetchCopyrightPage( })) } -/** New copyright agreements, newest first, with "load more" paging. */ -export function useCopyrightActivity() { +/** New copyright agreements, with "load more" paging. */ +export function useCopyrightActivity(sort: ActivitySort = 'newest') { + const dir = sort === 'oldest' ? 'asc' : 'desc' + const getKey = ( pageIndex: number, previousPageData: CopyrightActivityItem[] | null ) => { if (previousPageData && previousPageData.length === 0) return null - return ['copyright-activity', pageIndex] + return ['copyright-activity', sort, pageIndex] } const { data, error, size, setSize, isValidating } = useSWRInfinite( getKey, // SWR v1 spreads array-key parts as separate fetcher args. - (_ns: string, pageIndex: number) => - fetchCopyrightPage(pageIndex * PAGE_SIZE), + (_ns: string, _sort: ActivitySort, pageIndex: number) => + fetchCopyrightPage(pageIndex * PAGE_SIZE, dir), { revalidateFirstPage: false, revalidateOnFocus: false } ) diff --git a/src/data/messaging/admin.ts b/src/data/messaging/admin.ts index c617113ac..45792f656 100644 --- a/src/data/messaging/admin.ts +++ b/src/data/messaging/admin.ts @@ -96,10 +96,14 @@ interface CommentRow { token_id?: string } -/** Fetch one page of recent comments (newest first), normalized to AdminComment. */ +/** Fetch one page of comments (newest first by default), as AdminComment. */ export async function fetchRecentCommentsPage( kind: CommentKind, - { limit = RECENT_LIMIT, offset = 0 }: { limit?: number; offset?: number } = {} + { + limit = RECENT_LIMIT, + offset = 0, + sort = 'desc', + }: { limit?: number; offset?: number; sort?: 'asc' | 'desc' } = {} ): Promise { const contract = commentContractFor(kind) if (!contract) return [] @@ -109,7 +113,7 @@ export async function fetchRecentCommentsPage( contract, 'comment_posted', {}, - { limit, offset, sort: 'desc' } + { limit, offset, sort } ) const ids = posted.map((e) => e.payload.comment_id) @@ -179,7 +183,12 @@ interface MessageRow { export async function fetchRecentChannelMessagesPage({ limit = RECENT_LIMIT, offset = 0, -}: { limit?: number; offset?: number } = {}): Promise { + sort = 'desc', +}: { + limit?: number + offset?: number + sort?: 'asc' | 'desc' +} = {}): Promise { const contract = CHANNELS_V2_CONTRACT if (!contract) return [] @@ -187,7 +196,7 @@ export async function fetchRecentChannelMessagesPage({ contract, 'message_posted', {}, - { limit, offset, sort: 'desc' } + { limit, offset, sort } ) const ids = posted.map((e) => e.payload.message_id) diff --git a/src/data/messaging/useSocialActivity.ts b/src/data/messaging/useSocialActivity.ts index 0801cff93..102ea6db3 100644 --- a/src/data/messaging/useSocialActivity.ts +++ b/src/data/messaging/useSocialActivity.ts @@ -24,6 +24,8 @@ import { const PAGE_SIZE = RECENT_LIMIT +export type ActivitySort = 'newest' | 'oldest' + export type SocialActivityKind = 'channel' | 'poll_comment' | 'token_comment' export interface SocialActivityItem { @@ -41,18 +43,29 @@ export interface SocialActivityItem { /** One paginated source (one messaging contract / event tag). */ function useInfiniteSource( ns: string, - fetchPage: (opts: { limit: number; offset: number }) => Promise + sort: ActivitySort, + fetchPage: (opts: { + limit: number + offset: number + sort: 'asc' | 'desc' + }) => Promise ) { + const dir = sort === 'oldest' ? 'asc' : 'desc' + const getKey = (pageIndex: number, previous: T[] | null) => { if (previous && previous.length === 0) return null - return [ns, pageIndex] + return [ns, sort, pageIndex] } const { data, error, size, setSize, isValidating } = useSWRInfinite( getKey, // SWR v1 spreads array-key parts as separate fetcher args. - (_ns: string, pageIndex: number) => - fetchPage({ limit: PAGE_SIZE, offset: pageIndex * PAGE_SIZE }), + (_ns: string, _sort: ActivitySort, pageIndex: number) => + fetchPage({ + limit: PAGE_SIZE, + offset: pageIndex * PAGE_SIZE, + sort: dir, + }), { revalidateFirstPage: false, revalidateOnFocus: false } ) @@ -75,15 +88,16 @@ function useInfiniteSource( } } -export function useSocialActivity() { +export function useSocialActivity(sort: ActivitySort = 'newest') { const channels = useInfiniteSource( 'social:channels', + sort, fetchRecentChannelMessagesPage ) - const polls = useInfiniteSource('social:poll', (opts) => + const polls = useInfiniteSource('social:poll', sort, (opts) => fetchRecentCommentsPage('poll', opts) ) - const tokens = useInfiniteSource('social:token', (opts) => + const tokens = useInfiniteSource('social:token', sort, (opts) => fetchRecentCommentsPage('token', opts) ) @@ -129,9 +143,11 @@ export function useSocialActivity() { }) } - items.sort( - (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() - ) + items.sort((a, b) => { + const delta = + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + return sort === 'oldest' ? delta : -delta + }) const loadMore = () => { if (!channels.reachingEnd) channels.setSize(channels.size + 1) diff --git a/src/data/messaging/useTextCommentActivity.ts b/src/data/messaging/useTextCommentActivity.ts index a3479867d..4f7a5ef7b 100644 --- a/src/data/messaging/useTextCommentActivity.ts +++ b/src/data/messaging/useTextCommentActivity.ts @@ -4,7 +4,7 @@ import useSWRInfinite from 'swr/infinite' import { request, gql } from 'graphql-request' import { HEN_CONTRACT_FA2 } from '@constants' import { fetchRecentCommentsPage, RECENT_LIMIT } from './admin' -import type { SocialActivityItem } from './useSocialActivity' +import type { ActivitySort, SocialActivityItem } from './useSocialActivity' const PAGE_SIZE = RECENT_LIMIT @@ -30,10 +30,14 @@ interface TextCommentsPage { items: SocialActivityItem[] } -async function fetchTextCommentsPage(offset: number): Promise { +async function fetchTextCommentsPage( + offset: number, + sort: 'asc' | 'desc' +): Promise { const page = await fetchRecentCommentsPage('token', { limit: PAGE_SIZE, offset, + sort, }) const candidates = page.filter( @@ -70,17 +74,19 @@ async function fetchTextCommentsPage(offset: number): Promise } } -export function useTextCommentActivity() { +export function useTextCommentActivity(sort: ActivitySort = 'newest') { + const dir = sort === 'oldest' ? 'asc' : 'desc' + const getKey = (pageIndex: number, previous: TextCommentsPage | null) => { if (previous && previous.rawCount === 0) return null - return ['text-comments', pageIndex] + return ['text-comments', sort, pageIndex] } const { data, error, size, setSize, isValidating } = useSWRInfinite( getKey, // SWR v1 spreads array-key parts as separate fetcher args. - (_ns: string, pageIndex: number) => - fetchTextCommentsPage(pageIndex * PAGE_SIZE), + (_ns: string, _sort: ActivitySort, pageIndex: number) => + fetchTextCommentsPage(pageIndex * PAGE_SIZE, dir), { revalidateFirstPage: false, revalidateOnFocus: false } ) diff --git a/src/data/swr.js b/src/data/swr.js index 4afcb9ee2..b0beaf7d8 100644 --- a/src/data/swr.js +++ b/src/data/swr.js @@ -1225,7 +1225,12 @@ export function useUserActivity(address, activeTypes = []) { const TEXT_ACTIVITY_QUERY = gql` ${ActivityEventFieldsFragment} - query TextActivity($conds: [events_bool_exp!]!, $limit: Int!, $offset: Int!) { + query TextActivity( + $conds: [events_bool_exp!]! + $limit: Int! + $offset: Int! + $order: order_by! + ) { events( where: { token: { @@ -1235,7 +1240,7 @@ const TEXT_ACTIVITY_QUERY = gql` fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } _or: $conds } - order_by: [{ level: desc }, { opid: desc }] + order_by: [{ level: $order }, { opid: $order }] limit: $limit offset: $offset ) { @@ -1245,26 +1250,28 @@ const TEXT_ACTIVITY_QUERY = gql` ` //Platform-wide activity for text posts -export function useTextActivity(activeTypes = []) { +export function useTextActivity(activeTypes = [], sort = 'newest') { const filterKey = activeTypes.length ? [...activeTypes].sort().join(',') : 'all' const conds = globalActivityConds(activeTypes) + const order = sort === 'oldest' ? 'asc' : 'desc' const getKey = (pageIndex, previousPageData) => { if (previousPageData && previousPageData.length === 0) return null - return ['text-activity', filterKey, pageIndex] + return ['text-activity', filterKey, sort, pageIndex] } const { data, error, size, setSize, isValidating } = useSWRInfinite( getKey, // SWR v1 spreads array-key parts as separate fetcher args. - async (_ns, _filters, pageIndex) => { + async (_ns, _filters, _sort, pageIndex) => { const res = await request( import.meta.env.VITE_TEIA_GRAPHQL_API, TEXT_ACTIVITY_QUERY, { conds, + order, limit: ACTIVITY_PAGE_SIZE, offset: pageIndex * ACTIVITY_PAGE_SIZE, } @@ -1293,7 +1300,12 @@ export function useTextActivity(activeTypes = []) { const GLOBAL_ACTIVITY_QUERY = gql` ${ActivityEventFieldsFragment} - query GlobalActivity($conds: [events_bool_exp!]!, $limit: Int!, $offset: Int!) { + query GlobalActivity( + $conds: [events_bool_exp!]! + $limit: Int! + $offset: Int! + $order: order_by! + ) { events( # One condition per event kind (see globalActivityConds): the active # filter chips are pushed into the query so pages stay full. @@ -1302,7 +1314,7 @@ const GLOBAL_ACTIVITY_QUERY = gql` fa2_address: { _eq: "${HEN_CONTRACT_FA2}" } _or: $conds } - order_by: [{ level: desc }, { opid: desc }] + order_by: [{ level: $order }, { opid: $order }] limit: $limit offset: $offset ) { @@ -1312,29 +1324,31 @@ const GLOBAL_ACTIVITY_QUERY = gql` ` /** - * Platform-wide Teia/HEN activity feed (sales, listings, mints), newest first, - * with "load more" paging. Not scoped to any address. + * Platform-wide Teia/HEN activity feed (sales, listings, mints), with + * "load more" paging. Not scoped to any address. */ -export function useGlobalActivity(activeTypes = []) { +export function useGlobalActivity(activeTypes = [], sort = 'newest') { const filterKey = activeTypes.length ? [...activeTypes].sort().join(',') : 'all' const conds = globalActivityConds(activeTypes) + const order = sort === 'oldest' ? 'asc' : 'desc' const getKey = (pageIndex, previousPageData) => { if (previousPageData && previousPageData.length === 0) return null - return ['global-activity', filterKey, pageIndex] + return ['global-activity', filterKey, sort, pageIndex] } const { data, error, size, setSize, isValidating } = useSWRInfinite( getKey, // SWR v1 spreads array-key parts as separate fetcher args. - async (_ns, _filters, pageIndex) => { + async (_ns, _filters, _sort, pageIndex) => { const res = await request( import.meta.env.VITE_TEIA_GRAPHQL_API, GLOBAL_ACTIVITY_QUERY, { conds, + order, limit: ACTIVITY_PAGE_SIZE, offset: pageIndex * ACTIVITY_PAGE_SIZE, } diff --git a/src/pages/home/feeds/chain-activity-feed.jsx b/src/pages/home/feeds/chain-activity-feed.jsx index 246faea0d..1ee1cc8c1 100644 --- a/src/pages/home/feeds/chain-activity-feed.jsx +++ b/src/pages/home/feeds/chain-activity-feed.jsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import useSWR from 'swr' import { Loading } from '@atoms/loading' import { Button } from '@atoms/button' @@ -12,7 +12,11 @@ import { import { useUserProfiles } from '@data/roles' import { useWiki } from '@data/wiki' import { fetchEvents, fetchEventContent } from '@data/calendar-chain' -import { ActivityFilters, ActionRow } from '@components/activity' +import { + ActivityFilters, + ActivityControls, + ActionRow, +} from '@components/activity' import activityStyles from '@components/activity/index.module.scss' import styles from './teia-activity-feed.module.scss' @@ -65,6 +69,7 @@ function present(item, config, targetOf) { function ChainFeed({ config, targetOf, loadingMessage }) { const action = useActivityFilter() + const [sort, setSort] = useState('newest') const { items, error, @@ -72,7 +77,7 @@ function ChainFeed({ config, targetOf, loadingMessage }) { isLoadingMore, isReachingEnd, loadMore, - } = useChainActivity(config, action.active) + } = useChainActivity(config, action.active, sort) const actors = useMemo( () => [...new Set(items.map((i) => i.actor).filter(Boolean))], @@ -94,11 +99,13 @@ function ChainFeed({ config, targetOf, loadingMessage }) { return ( <> - + + + {items.length === 0 ? (
@@ -202,6 +209,7 @@ export function WikiActivityFeed() { } export function CopyrightActivityFeed() { + const [sort, setSort] = useState('newest') const { items, error, @@ -209,7 +217,7 @@ export function CopyrightActivityFeed() { isLoadingMore, isReachingEnd, loadMore, - } = useCopyrightActivity() + } = useCopyrightActivity(sort) const actors = useMemo( () => [...new Set(items.map((i) => i.actor).filter(Boolean))], @@ -239,6 +247,9 @@ export function CopyrightActivityFeed() { return ( <> + {/* No filter chips on this feed — the sort control stands alone. */} + +
Action diff --git a/src/pages/home/feeds/teia-activity-feed.jsx b/src/pages/home/feeds/teia-activity-feed.jsx index 5ff8208da..5bf45ff41 100644 --- a/src/pages/home/feeds/teia-activity-feed.jsx +++ b/src/pages/home/feeds/teia-activity-feed.jsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { NavLink, Outlet } from 'react-router-dom' import { Loading } from '@atoms/loading' import { Button } from '@atoms/button' @@ -17,6 +17,7 @@ import { import { ActivityList, ActivityFilters, + ActivityControls, SocialActivityRow, } from '@components/activity' import activityStyles from '@components/activity/index.module.scss' @@ -40,6 +41,7 @@ export function TradesFeed() { const { walletBlockMap } = useSettings() const type = useActivityFilter() const market = useActivityFilter() + const [sort, setSort] = useState('newest') const { matches: matchesType } = type const { matches: matchesMarket } = market const { @@ -49,7 +51,7 @@ export function TradesFeed() { isLoadingMore, isReachingEnd, loadMore, - } = useGlobalActivity(type.active) + } = useGlobalActivity(type.active, sort) const rows = useMemo( () => @@ -91,16 +93,18 @@ export function TradesFeed() { return ( <> - - + + + + [...new Set(items.map((i) => i.sender))], @@ -158,11 +163,13 @@ export function SocialFeed() { return ( <> - + + + {rows.length === 0 ? (
diff --git a/src/pages/home/feeds/text-activity-feed.jsx b/src/pages/home/feeds/text-activity-feed.jsx index 1e7b0235e..3ce0d8024 100644 --- a/src/pages/home/feeds/text-activity-feed.jsx +++ b/src/pages/home/feeds/text-activity-feed.jsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { Loading } from '@atoms/loading' import { Button } from '@atoms/button' import useSettings from '@hooks/use-settings' @@ -15,6 +15,7 @@ import { import { ActivityList, ActivityFilters, + ActivityControls, SocialActivityRow, } from '@components/activity' import activityStyles from '@components/activity/index.module.scss' @@ -26,7 +27,7 @@ const TEXT_FILTERS = ACTIVITY_FILTERS.filter((f) => ) /** Comments posted on text tokens */ -function TextComments() { +function TextComments({ sort }) { const { items, error, @@ -34,7 +35,7 @@ function TextComments() { isLoadingMore, isReachingEnd, loadMore, - } = useTextCommentActivity() + } = useTextCommentActivity(sort) const senders = useMemo( () => [...new Set(items.map((i) => i.sender))], @@ -108,6 +109,7 @@ export function TextActivityFeed() { const { walletBlockMap } = useSettings() const type = useActivityFilter() const market = useActivityFilter() + const [sort, setSort] = useState('newest') const { matches: matchesType } = type const { matches: matchesMarket } = market const { @@ -117,7 +119,7 @@ export function TextActivityFeed() { isLoadingMore, isReachingEnd, loadMore, - } = useTextActivity(type.active) + } = useTextActivity(type.active, sort) const rows = useMemo( () => @@ -159,16 +161,18 @@ export function TextActivityFeed() { return ( <> - - + + + +

Comments

- + ) }