feat: add ability to follow and unfollow a user on their profile page
This commit is contained in:
Vendored
+2
@@ -14,6 +14,7 @@ import type {
|
|||||||
FilterApi,
|
FilterApi,
|
||||||
FunctionReference,
|
FunctionReference,
|
||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
|
import type * as follows from "../follows";
|
||||||
import type * as http from "../http";
|
import type * as http from "../http";
|
||||||
import type * as likes from "../likes";
|
import type * as likes from "../likes";
|
||||||
import type * as posts from "../posts";
|
import type * as posts from "../posts";
|
||||||
@@ -28,6 +29,7 @@ import type * as users from "../users";
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
|
follows: typeof follows;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
likes: typeof likes;
|
likes: typeof likes;
|
||||||
posts: typeof posts;
|
posts: typeof posts;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { v } from 'convex/values';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { userQuery } from './users';
|
||||||
|
|
||||||
|
export const addFollow = mutation({
|
||||||
|
args: { followingId: v.id('users') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const clerkUser = await ctx.auth.getUserIdentity();
|
||||||
|
|
||||||
|
if (clerkUser === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userQuery(ctx, clerkUser.subject);
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFollow = await ctx.db
|
||||||
|
.query('follows')
|
||||||
|
.withIndex('by_following_follower_id', q =>
|
||||||
|
q.eq('following', args.followingId).eq('follower', user._id)
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
if (existingFollow !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert('follows', {
|
||||||
|
following: args.followingId,
|
||||||
|
follower: user._id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeFollow = mutation({
|
||||||
|
args: { followingId: v.id('users') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const clerkUser = await ctx.auth.getUserIdentity();
|
||||||
|
|
||||||
|
if (clerkUser === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userQuery(ctx, clerkUser.subject);
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFollow = await ctx.db
|
||||||
|
.query('follows')
|
||||||
|
.withIndex('by_following_follower_id', q =>
|
||||||
|
q.eq('following', args.followingId).eq('follower', user._id)
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
if (existingFollow === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(existingFollow._id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUserFollowerCount = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const followers = await ctx.db
|
||||||
|
.query('follows')
|
||||||
|
.withIndex('by_following', q => q.eq('following', args.userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return followers.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUserFollowingCount = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const followings = await ctx.db
|
||||||
|
.query('follows')
|
||||||
|
.withIndex('by_follower', q => q.eq('follower', args.userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return followings.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getFollowing = query({
|
||||||
|
args: { followingId: v.id('users') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const currentClerkUser = await ctx.auth.getUserIdentity();
|
||||||
|
|
||||||
|
if (currentClerkUser == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userQuery(ctx, currentClerkUser.subject);
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const following = await ctx.db
|
||||||
|
.query('follows')
|
||||||
|
.withIndex('by_following_follower_id', q =>
|
||||||
|
q.eq('following', args.followingId).eq('follower', user._id)
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
return following !== null;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -147,3 +147,15 @@ export const getPostReplyCount = query({
|
|||||||
return replies.length;
|
return replies.length;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getUserPostCount = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const posts = await ctx.db
|
||||||
|
.query('posts')
|
||||||
|
.withIndex('by_user_id', q => q.eq('userId', args.userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return posts.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
+2
-1
@@ -53,7 +53,8 @@ export default defineSchema(
|
|||||||
follower: v.id('users'), // person following
|
follower: v.id('users'), // person following
|
||||||
})
|
})
|
||||||
.index('by_following', ['following'])
|
.index('by_following', ['following'])
|
||||||
.index('by_follower', ['follower']),
|
.index('by_follower', ['follower'])
|
||||||
|
.index('by_following_follower_id', ['following', 'follower']),
|
||||||
},
|
},
|
||||||
{ schemaValidation: false }
|
{ schemaValidation: false }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function Loader({
|
|||||||
}) {
|
}) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className='flex w-full items-center justify-center gap-2 p-5 border-t border-gray-600'>
|
<div className='flex w-full items-center justify-center gap-2 p-5 border-t border-gray-600 text-sm'>
|
||||||
<SpinningLoader className='animate-spin w-5 h-5' />
|
<SpinningLoader className='animate-spin w-5 h-5' />
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
@@ -25,7 +25,7 @@ export default function Loader({
|
|||||||
return (
|
return (
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
<button
|
<button
|
||||||
className='bg-primary-accent text-white px-3 py-1 rounded-full'
|
className='bg-primary-accent text-white px-3 py-1 rounded-full text-sm'
|
||||||
onClick={loadButtonClickHandler}
|
onClick={loadButtonClickHandler}
|
||||||
disabled={status !== 'CanLoadMore'}
|
disabled={status !== 'CanLoadMore'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,15 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { UserDto } from '@/app/types';
|
import { UserDto } from '@/app/types';
|
||||||
|
import { useUser } from '@clerk/nextjs';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { AiFillCalendar } from 'react-icons/ai';
|
import { AiFillCalendar } from 'react-icons/ai';
|
||||||
|
import { api } from '../../convex/_generated/api';
|
||||||
|
|
||||||
export default function UserProfile({ user }: { user: UserDto }) {
|
export default function UserProfile({ user }: { user: UserDto }) {
|
||||||
|
const { isLoaded, isSignedIn, user: currentUser } = useUser();
|
||||||
|
const addFollow = useMutation(api.follows.addFollow);
|
||||||
|
const removeFollow = useMutation(api.follows.removeFollow);
|
||||||
|
|
||||||
|
const followerCount = useQuery(api.follows.getUserFollowerCount, {
|
||||||
|
userId: user._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const followingCount = useQuery(api.follows.getUserFollowingCount, {
|
||||||
|
userId: user._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFollowing = useQuery(api.follows.getFollowing, {
|
||||||
|
followingId: user._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const postCount = useQuery(api.posts.getUserPostCount, { userId: user._id });
|
||||||
const username = user.clerkUsername ?? user._id;
|
const username = user.clerkUsername ?? user._id;
|
||||||
const userCreatedDate = new Date(user._creationTime);
|
const userCreatedDate = new Date(user._creationTime);
|
||||||
|
|
||||||
const monthJoined = userCreatedDate.toLocaleString(undefined, {
|
const monthJoined = userCreatedDate.toLocaleString(undefined, {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
});
|
});
|
||||||
|
|
||||||
const yearJoined = userCreatedDate.getFullYear();
|
const yearJoined = userCreatedDate.getFullYear();
|
||||||
|
|
||||||
|
const countDataLoaded =
|
||||||
|
postCount !== undefined &&
|
||||||
|
followerCount !== undefined &&
|
||||||
|
followingCount !== undefined;
|
||||||
|
|
||||||
|
async function handleFollowButtonClick() {
|
||||||
|
if (isFollowing) {
|
||||||
|
await removeFollow({ followingId: user._id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addFollow({ followingId: user._id });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
<div className='shadow-md bg-gradient-to-r from-violet-600 via-violet-600 to-indigo-600 rounded-t-md pt-20 px-5'>
|
<div className='shadow-md bg-gradient-to-r from-violet-600 via-violet-600 to-indigo-600 rounded-t-md pt-20 px-5'>
|
||||||
@@ -23,7 +61,8 @@ export default function UserProfile({ user }: { user: UserDto }) {
|
|||||||
className='rounded-full object-cover border-4 border-primary-accent'
|
className='rounded-full object-cover border-4 border-primary-accent'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col flex-1 min-w-0 text-white'>
|
<div className='flex flex-col-reverse mb-[25px] flex-1 min-w-0 gap-0.5 md:flex-row md:mb-0 md:gap-2'>
|
||||||
|
<div className='flex flex-col min-w-0 text-white'>
|
||||||
<h1
|
<h1
|
||||||
className='font-bold overflow-hidden text-ellipsis'
|
className='font-bold overflow-hidden text-ellipsis'
|
||||||
title={username}
|
title={username}
|
||||||
@@ -35,9 +74,46 @@ export default function UserProfile({ user }: { user: UserDto }) {
|
|||||||
{`Joined ${monthJoined} ${yearJoined}`}
|
{`Joined ${monthJoined} ${yearJoined}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
{isLoaded && isSignedIn && currentUser.id !== user.clerkUserId ? (
|
||||||
|
<button
|
||||||
|
onClick={handleFollowButtonClick}
|
||||||
|
type='button'
|
||||||
|
className={`py-0.5 px-3 rounded-full text-sm border border-white text-white ${
|
||||||
|
isFollowing ? 'bg-primary-accent' : 'o'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Follow
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-1 bg-white dark:bg-primary-gray pl-5 border border-gray-600 border-b-0'>
|
||||||
|
<div className='flex flex-col min-h-[25px] items-start gap-1 py-1 pl-[116px] md:flex-row md:items-center md:gap-4'>
|
||||||
|
{countDataLoaded ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<span className='font-bold whitespace-nowrap'>{postCount}</span>{' '}
|
||||||
|
posts
|
||||||
|
</div>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<span className='font-bold whitespace-nowrap'>
|
||||||
|
{followerCount}
|
||||||
|
</span>{' '}
|
||||||
|
followers
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className='font-bold whitespace-nowrap'>
|
||||||
|
{followingCount}
|
||||||
|
</span>{' '}
|
||||||
|
following
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='h-[50px] bg-white dark:bg-primary-gray pl-5 pr-40 border border-gray-600 border-b-0'></div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user