diff --git a/__tests__/components/home/explore-waves/ExploreWaveCard.test.tsx b/__tests__/components/home/explore-waves/ExploreWaveCard.test.tsx new file mode 100644 index 0000000000..49bde4f956 --- /dev/null +++ b/__tests__/components/home/explore-waves/ExploreWaveCard.test.tsx @@ -0,0 +1,203 @@ +import { render, screen } from "@testing-library/react"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { ExploreWaveCard } from "@/components/home/explore-waves/ExploreWaveCard"; +import { getTimeAgoShort } from "@/helpers/Helpers"; +import type { ImgHTMLAttributes, ReactNode } from "react"; + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ + href, + children, + ...rest + }: { + href: string; + children: ReactNode; + }) => ( + + {children} + + ), +})); + +jest.mock("next/image", () => ({ + __esModule: true, + default: (props: ImgHTMLAttributes) => ( + // eslint-disable-next-line @next/next/no-img-element + + ), +})); + +const mockContentDisplay = jest.fn(); + +jest.mock("@/components/waves/drops/ContentDisplay", () => ({ + __esModule: true, + default: (props: unknown) => { + mockContentDisplay(props); + return
; + }, +})); + +jest.mock("@/helpers/Helpers", () => ({ + getRandomColorWithSeed: jest.fn(() => "#123456"), + getTimeAgoShort: jest.fn(() => "2m"), +})); + +describe("ExploreWaveCard", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockContentDisplay.mockClear(); + }); + + it("uses ApiWave last_drop_time and description_drop preview content", () => { + render( + + ); + + expect(getTimeAgoShort).toHaveBeenCalledWith(2_000); + expect(screen.getByText(/2m ยท 7/i)).toBeInTheDocument(); + expect(screen.getByTestId("content-display")).toBeInTheDocument(); + + const lastContentDisplayProps = + mockContentDisplay.mock.calls[ + mockContentDisplay.mock.calls.length - 1 + ][0]; + + expect(lastContentDisplayProps).toMatchObject({ + linkify: false, + content: { + segments: [{ type: "text", content: "Description preview" }], + apiMedia: [], + }, + }); + }); + + it("does not render the preview container when description_drop is empty", () => { + render( + + ); + + expect(screen.queryByTestId("content-display")).not.toBeInTheDocument(); + }); +}); + +function createWave(overrides: Partial = {}): ApiWave { + const baseWave = { + id: "wave-1", + serial_no: 1, + author: { + handle: "alice", + banner1_color: null, + banner2_color: null, + }, + name: "Wave One", + picture: null, + created_at: 1, + last_drop_time: 1_000, + description_drop: { + id: "drop-1", + serial_no: 1, + drop_type: "CHAT", + rank: null, + wave: { + id: "wave-1", + }, + author: { + handle: "alice", + }, + created_at: 1, + updated_at: null, + title: null, + parts: [ + { + part_id: 1, + content: "Description preview", + media: [], + quoted_drop: null, + }, + ], + parts_count: 1, + referenced_nfts: [], + mentioned_users: [], + mentioned_waves: [], + metadata: [], + rating: 0, + realtime_rating: 0, + rating_prediction: 0, + top_raters: [], + raters_count: 0, + context_profile_context: null, + subscribed_actions: [], + is_signed: false, + reactions: [], + boosts: 0, + hide_link_preview: false, + }, + voting: {}, + visibility: {}, + participation: {}, + chat: { + scope: { + group: { + is_direct_message: false, + }, + }, + enabled: true, + }, + wave: { + type: "CHAT", + }, + contributors_overview: [], + subscribed_actions: [], + metrics: { + drops_count: 3, + latest_drop_timestamp: 1_000, + }, + pauses: [], + pinned: false, + } as any; + + return { + ...baseWave, + ...overrides, + metrics: { + ...baseWave.metrics, + ...(overrides.metrics as object | undefined), + }, + description_drop: { + ...baseWave.description_drop, + ...(overrides.description_drop as object | undefined), + }, + } as ApiWave; +} diff --git a/components/home/explore-waves/ExploreWaveCard.tsx b/components/home/explore-waves/ExploreWaveCard.tsx index 6c3bef0a0a..b25912d1a9 100644 --- a/components/home/explore-waves/ExploreWaveCard.tsx +++ b/components/home/explore-waves/ExploreWaveCard.tsx @@ -3,13 +3,15 @@ import type { ApiWave } from "@/generated/models/ApiWave"; import { getRandomColorWithSeed, getTimeAgoShort } from "@/helpers/Helpers"; import ContentDisplay from "@/components/waves/drops/ContentDisplay"; -import type { ProcessedContent } from "@/components/waves/drops/media-utils"; +import { + buildProcessedContent, + type ProcessedContent, +} from "@/components/waves/drops/media-utils"; import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; import { getWaveRoute } from "@/helpers/navigation.helpers"; import Image from "next/image"; import Link from "next/link"; import { ChatBubbleBottomCenterIcon } from "@heroicons/react/24/outline"; -import { extractDropPreview, useWaveLatestDrop } from "./useWaveLatestDrop"; interface ExploreWaveCardProps { readonly wave: ApiWave; @@ -33,15 +35,9 @@ export function ExploreWaveCard({ wave }: ExploreWaveCardProps) { } : undefined; - const lastMessageTime = wave.metrics.latest_drop_timestamp; + const lastMessageTime = wave.last_drop_time; const hasDrops = wave.metrics.drops_count > 0; - - // Fetch latest drop for message preview - const { data: latestDrop, isLoading: isLoadingDrop } = useWaveLatestDrop( - wave.id, - hasDrops - ); - const latestMessagePreview = extractDropPreview(latestDrop ?? null); + const descriptionPreview = getWavePreviewContent(wave); return ( )} - {hasDrops && ( + {descriptionPreview && (
- +
)}
@@ -108,18 +101,10 @@ export function ExploreWaveCard({ wave }: ExploreWaveCardProps) { } function MessagePreviewContent({ - isLoading, previewContent, }: { - readonly isLoading: boolean; readonly previewContent: ProcessedContent | null; }) { - if (isLoading) { - return ( -
- ); - } - if (!previewContent) { return null; } @@ -134,3 +119,18 @@ function MessagePreviewContent({ /> ); } + +function getWavePreviewContent(wave: ApiWave): ProcessedContent | null { + const descriptionParts = wave.description_drop.parts; + const combinedText = descriptionParts + .map((part) => part.content?.trim()) + .filter((content): content is string => Boolean(content)) + .join("\n\n"); + const media = descriptionParts.flatMap((part) => part.media); + + if (!combinedText && media.length === 0) { + return null; + } + + return buildProcessedContent(combinedText || null, media); +} diff --git a/components/home/explore-waves/useWaveLatestDrop.ts b/components/home/explore-waves/useWaveLatestDrop.ts deleted file mode 100644 index 7da6e5fbb8..0000000000 --- a/components/home/explore-waves/useWaveLatestDrop.ts +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { commonApiFetch } from "@/services/api/common-api"; -import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed"; -import type { ApiDropWithoutWave } from "@/generated/models/ApiDropWithoutWave"; -import { buildProcessedContent } from "@/components/waves/drops/media-utils"; -import type { ProcessedContent } from "@/components/waves/drops/media-utils"; - -export function useWaveLatestDrop(waveId: string, enabled: boolean = true) { - return useQuery({ - queryKey: ["wave-latest-drop", waveId], - queryFn: async () => { - const data = await commonApiFetch({ - endpoint: `waves/${waveId}/drops`, - params: { limit: "1" }, - }); - return data.drops[0] ?? null; - }, - enabled, - staleTime: 2 * 60 * 1000, // 2 minutes - }); -} - -/** - * Extracts a text preview from drop parts. - * Concatenates text content from all parts and truncates to maxLength. - */ -export function extractDropPreview( - drop: ApiDropWithoutWave | null -): ProcessedContent | null { - if (!drop) return null; - - const textParts = drop.parts - .map((part) => part.content?.trim()) - .filter((content): content is string => !!content); - - const combinedText = textParts.join("\n\n"); - const media = drop.parts.flatMap((part) => part.media); - - if (!combinedText && media.length === 0) { - return null; - } - - return buildProcessedContent(combinedText || null, media, "View drop..."); -} diff --git a/components/waves/memes/submission/utils/buildPreviewDrop.ts b/components/waves/memes/submission/utils/buildPreviewDrop.ts index 06e8320f69..b21f650803 100644 --- a/components/waves/memes/submission/utils/buildPreviewDrop.ts +++ b/components/waves/memes/submission/utils/buildPreviewDrop.ts @@ -97,6 +97,7 @@ export const buildPreviewDrop = ({ name: wave.name, picture: wave.picture, description_drop_id: wave.description_drop.id, + last_drop_time: wave.last_drop_time, authenticated_user_eligible_to_vote: wave.voting.authenticated_user_eligible, authenticated_user_eligible_to_participate: diff --git a/components/waves/utils/getOptimisticDrop.ts b/components/waves/utils/getOptimisticDrop.ts index 5cca88d76d..efbed52ed5 100644 --- a/components/waves/utils/getOptimisticDrop.ts +++ b/components/waves/utils/getOptimisticDrop.ts @@ -47,6 +47,7 @@ export const getOptimisticDrop = ( pinned: wave.pinned, picture: wave.picture ?? "", description_drop_id: wave.description_drop.id, + last_drop_time: wave.last_drop_time, authenticated_user_eligible_to_participate: wave.participation.authenticated_user_eligible, authenticated_user_eligible_to_vote: diff --git a/generated/models/ApiWave.ts b/generated/models/ApiWave.ts index e634808e19..f7308538af 100644 --- a/generated/models/ApiWave.ts +++ b/generated/models/ApiWave.ts @@ -43,6 +43,10 @@ export class ApiWave { */ 'picture': string | null; 'created_at': number; + /** + * Unix timestamp in milliseconds of the most recent drop in this wave + */ + 'last_drop_time': number; 'description_drop': ApiDrop; 'voting': ApiWaveVotingConfig; 'visibility': ApiWaveVisibilityConfig; @@ -96,6 +100,12 @@ export class ApiWave { "type": "number", "format": "int64" }, + { + "name": "last_drop_time", + "baseName": "last_drop_time", + "type": "number", + "format": "int64" + }, { "name": "description_drop", "baseName": "description_drop", diff --git a/generated/models/ApiWaveMin.ts b/generated/models/ApiWaveMin.ts index b421bab267..32fe9316b6 100644 --- a/generated/models/ApiWaveMin.ts +++ b/generated/models/ApiWaveMin.ts @@ -19,6 +19,10 @@ export class ApiWaveMin { 'name': string; 'picture': string | null; 'description_drop_id': string; + /** + * Unix timestamp in milliseconds of the most recent drop in this wave + */ + 'last_drop_time': number; 'authenticated_user_eligible_to_vote': boolean; 'authenticated_user_eligible_to_participate': boolean; 'authenticated_user_eligible_to_chat': boolean; @@ -64,6 +68,12 @@ export class ApiWaveMin { "type": "string", "format": "" }, + { + "name": "last_drop_time", + "baseName": "last_drop_time", + "type": "number", + "format": "int64" + }, { "name": "authenticated_user_eligible_to_vote", "baseName": "authenticated_user_eligible_to_vote", diff --git a/generated/models/ObjectSerializer.ts b/generated/models/ObjectSerializer.ts index 87ff2addb8..b2b5ad118f 100644 --- a/generated/models/ObjectSerializer.ts +++ b/generated/models/ObjectSerializer.ts @@ -478,7 +478,7 @@ import { ApiWaveDropsFeed } from '../models/ApiWaveDropsFeed'; import { ApiWaveLog } from '../models/ApiWaveLog'; import { ApiWaveMetadataType } from '../models/ApiWaveMetadataType'; import { ApiWaveMetrics } from '../models/ApiWaveMetrics'; -import { ApiWaveMin } from '../models/ApiWaveMin'; +import { ApiWaveMin } from '../models/ApiWaveMin'; import { ApiWaveOutcome } from '../models/ApiWaveOutcome'; import { ApiWaveOutcomeCredit } from '../models/ApiWaveOutcomeCredit'; import { ApiWaveOutcomeDistributionItem } from '../models/ApiWaveOutcomeDistributionItem'; diff --git a/hooks/useWaveDropsSearch.ts b/hooks/useWaveDropsSearch.ts index 6679842895..8b4ddfef0c 100644 --- a/hooks/useWaveDropsSearch.ts +++ b/hooks/useWaveDropsSearch.ts @@ -19,6 +19,7 @@ const toWaveMin = (wave: ApiWave): ApiWaveMin => { name: wave.name, picture: wave.picture, description_drop_id: wave.description_drop.id, + last_drop_time: wave.last_drop_time, authenticated_user_eligible_to_vote: wave.voting.authenticated_user_eligible, authenticated_user_eligible_to_participate: diff --git a/openapi.yaml b/openapi.yaml index 44f2db148d..9178cc65b4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4690,6 +4690,50 @@ paths: type: array items: $ref: "#/components/schemas/ApiWave" + /waves-overview/favourites-of-identity/{identityKey}: + get: + tags: + - Waves + summary: Get waves where a profile has written the most messages. + description: >- + Returns non-DM waves ranked by how many chat messages the resolved + profile has written in them. Results only include waves the requester + can access. + operationId: getFavouriteWavesOfIdentity + parameters: + - name: identityKey + in: path + description: Profile id, wallet, ENS, or handle to resolve + required: true + schema: + type: string + - name: limit + in: query + description: How many waves to return (50 by default) + required: false + schema: + type: integer + format: int64 + minimum: 1 + maximum: 100 + default: 50 + - name: offset + in: query + description: Used to find next waves + required: false + schema: + type: integer + format: int64 + minimum: 0 + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApiWave" /waves-overview/hot: get: tags: @@ -10181,6 +10225,7 @@ components: - chat - wave - created_at + - last_drop_time - contributors_overview - subscribed_actions - metrics @@ -10207,6 +10252,10 @@ components: created_at: type: number format: int64 + last_drop_time: + description: Unix timestamp in milliseconds of the most recent drop in this wave + type: number + format: int64 description_drop: $ref: "#/components/schemas/ApiDrop" voting: @@ -10571,6 +10620,7 @@ components: - name - picture - description_drop_id + - last_drop_time - authenticated_user_eligible_to_vote - authenticated_user_eligible_to_participate - authenticated_user_eligible_to_chat @@ -10596,6 +10646,10 @@ components: nullable: true description_drop_id: type: string + last_drop_time: + description: Unix timestamp in milliseconds of the most recent drop in this wave + type: number + format: int64 authenticated_user_eligible_to_vote: type: boolean authenticated_user_eligible_to_participate: