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: