Skip to content

Commit 9fc7c8d

Browse files
prazgaitisclaude
andauthored
feat: improve feed UX — switch to All tab on new activities, reuse ActivityCard on user profile (#201)
- "New activities" button on For You tab now switches to All tab (where live feed already has the new activities) instead of re-fetching algo feed - Add userId filter to getChallengeFeed query for single-user feeds - Rewrite user activities page to render full ActivityCard with likes, comments, media, share, and flag support instead of plain list - Export ActivityCard and ActivityFeedItem for reuse across pages Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9e42858 commit 9fc7c8d

4 files changed

Lines changed: 85 additions & 63 deletions

File tree

apps/web/app/challenges/[id]/(dashboard)/users/[userId]/activities/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ export default async function UserActivitiesPage({
2929

3030
return (
3131
<div className="mx-auto max-w-2xl px-4 py-6">
32-
<UserActivitiesContent challengeId={id} profileUserId={userId} />
32+
<UserActivitiesContent
33+
challengeId={id}
34+
profileUserId={userId}
35+
currentUserId={currentUser._id}
36+
/>
3337
</div>
3438
);
3539
}
Lines changed: 71 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,100 @@
11
"use client";
22

3-
import Link from "next/link";
4-
import { formatDistanceToNow } from "date-fns";
5-
import { usePaginatedQuery } from "convex/react";
3+
import { useMemo } from "react";
4+
import { usePaginatedQuery, useQuery } from "convex/react";
65
import { api } from "@repo/backend";
76
import type { Id } from "@repo/backend/_generated/dataModel";
87
import { Loader2 } from "lucide-react";
98

109
import { Button } from "@/components/ui/button";
11-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
10+
import {
11+
ActivityCard,
12+
type ActivityFeedItem,
13+
} from "@/components/dashboard/activity-feed";
14+
import { useMentionableUsers } from "@/hooks/use-mentionable-users";
1215

1316
interface UserActivitiesContentProps {
1417
challengeId: string;
1518
profileUserId: string;
19+
currentUserId?: string;
1620
}
1721

1822
export function UserActivitiesContent({
1923
challengeId,
2024
profileUserId,
25+
currentUserId,
2126
}: UserActivitiesContentProps) {
22-
const { results, status, loadMore } = usePaginatedQuery(
23-
api.queries.users.getActivities,
27+
const { results, status, loadMore, isLoading } = usePaginatedQuery(
28+
api.queries.activities.getChallengeFeed,
2429
{
25-
userId: profileUserId as Id<"users">,
2630
challengeId: challengeId as Id<"challenges">,
31+
userId: profileUserId as Id<"users">,
32+
includeEngagementCounts: true,
33+
includeMediaUrls: true,
2734
},
28-
{ initialNumItems: 20 }
35+
{ initialNumItems: 10 },
2936
);
3037

38+
const followingIds = useQuery(api.queries.follows.getFollowingIds);
39+
const followingSet = useMemo(
40+
() => new Set(followingIds ?? []),
41+
[followingIds],
42+
);
43+
const { users: mentionUsers } = useMentionableUsers(challengeId);
44+
45+
const items = (results ?? []).filter(
46+
(
47+
item,
48+
): item is NonNullable<typeof item> & {
49+
user: NonNullable<(typeof item)["user"]>;
50+
} => item.user !== null,
51+
) as ActivityFeedItem[];
52+
3153
return (
32-
<div className="space-y-4">
33-
<Card>
34-
<CardHeader>
35-
<CardTitle>User Activities</CardTitle>
36-
<CardDescription>
37-
All logged activities for this challenge.
38-
</CardDescription>
39-
</CardHeader>
40-
<CardContent className="space-y-3">
41-
{status === "LoadingFirstPage" && (
42-
<div className="flex items-center justify-center py-8 text-muted-foreground">
43-
<Loader2 className="h-5 w-5 animate-spin" />
44-
</div>
45-
)}
54+
<div>
55+
{status === "LoadingFirstPage" && (
56+
<div className="flex items-center justify-center py-12 text-muted-foreground">
57+
<Loader2 className="h-5 w-5 animate-spin" />
58+
</div>
59+
)}
4660

47-
{status !== "LoadingFirstPage" && results.length === 0 && (
48-
<p className="py-8 text-center text-sm text-muted-foreground">
49-
No activities logged yet.
50-
</p>
51-
)}
61+
{status !== "LoadingFirstPage" && items.length === 0 && (
62+
<p className="py-12 text-center text-sm text-muted-foreground">
63+
No activities logged yet.
64+
</p>
65+
)}
5266

53-
{results.map((item) => (
54-
<Link
55-
key={item.activity._id}
56-
href={`/challenges/${challengeId}/activities/${item.activity._id}`}
57-
className="flex items-center justify-between rounded-lg border bg-muted/30 p-4 transition-colors hover:bg-muted/50"
58-
>
59-
<div>
60-
<p className="font-medium">
61-
{item.activityType?.name ?? "Activity"}
62-
</p>
63-
<p className="text-xs text-muted-foreground">
64-
{formatDistanceToNow(new Date(item.activity.createdAt), {
65-
addSuffix: true,
66-
})}
67-
</p>
68-
</div>
69-
<p className="font-semibold text-primary">
70-
{item.activity.pointsEarned >= 0 ? "+" : ""}
71-
{item.activity.pointsEarned.toFixed(2)} pts
72-
</p>
73-
</Link>
74-
))}
67+
{items.map((item) => (
68+
<ActivityCard
69+
key={item.activity._id}
70+
challengeId={challengeId}
71+
showEngagementCounts
72+
item={{
73+
...item,
74+
activity: {
75+
...item.activity,
76+
id: item.activity._id,
77+
},
78+
mediaUrls: item.mediaUrls ?? [],
79+
cloudinaryPublicIds: item.cloudinaryPublicIds,
80+
}}
81+
mentionOptions={mentionUsers}
82+
currentUserId={currentUserId}
83+
isFollowing={followingSet.has(item.user.id)}
84+
/>
85+
))}
7586

76-
{status === "CanLoadMore" && (
77-
<div className="pt-2 text-center">
78-
<Button variant="outline" onClick={() => loadMore(20)}>
79-
Load more
80-
</Button>
81-
</div>
82-
)}
83-
</CardContent>
84-
</Card>
87+
{status === "CanLoadMore" && (
88+
<div className="flex justify-center py-4">
89+
<Button
90+
variant="outline"
91+
onClick={() => loadMore(10)}
92+
disabled={isLoading}
93+
>
94+
{isLoading ? "Loading..." : "Load more"}
95+
</Button>
96+
</div>
97+
)}
8598
</div>
8699
);
87100
}

apps/web/components/dashboard/activity-feed.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ interface BonusThreshold {
100100
description: string;
101101
}
102102

103-
interface ActivityFeedItem {
103+
export interface ActivityFeedItem {
104104
activity: {
105105
_id: string;
106106
id?: string; // mapped from _id for compatibility if needed
@@ -585,7 +585,7 @@ export function ActivityFeed({
585585
<button
586586
onClick={() => {
587587
acknowledgeActivity();
588-
void fetchAlgoFeed();
588+
setFeedFilter("all");
589589
window.scrollTo({ top: 0, behavior: "smooth" });
590590
}}
591591
className="flex items-center gap-1.5 rounded-full bg-indigo-500 px-4 py-2 text-sm font-medium text-white shadow-lg transition-transform hover:scale-105 active:scale-95"
@@ -785,7 +785,7 @@ interface ActivityCardProps {
785785
isFollowing: boolean;
786786
}
787787

788-
const ActivityCard = memo(function ActivityCard({
788+
export const ActivityCard = memo(function ActivityCard({
789789
challengeId,
790790
challengeName,
791791
item,

packages/backend/queries/activities.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export const getChallengeFeed = query({
161161
args: {
162162
challengeId: v.id("challenges"),
163163
followingOnly: v.optional(v.boolean()),
164+
userId: v.optional(v.id("users")),
164165
includeEngagementCounts: v.optional(v.boolean()),
165166
includeMediaUrls: v.optional(v.boolean()),
166167
paginationOpts: paginationOptsValidator,
@@ -227,7 +228,11 @@ export const getChallengeFeed = query({
227228
.filter(notDeleted)
228229
.order("desc");
229230

230-
if (args.followingOnly && followingIds) {
231+
if (args.userId) {
232+
activitiesQuery = activitiesQuery.filter((q) =>
233+
q.eq(q.field("userId"), args.userId!)
234+
);
235+
} else if (args.followingOnly && followingIds) {
231236
const followedUserIds = Array.from(followingIds);
232237
if (followedUserIds.length === 1) {
233238
const [followedUserId] = followedUserIds;

0 commit comments

Comments
 (0)