feat: add ability to follow and unfollow a user on their profile page

This commit is contained in:
Stevan Freeborn
2023-09-15 17:02:20 -05:00
parent bc86873d89
commit 2a1b46f06d
6 changed files with 221 additions and 14 deletions
+2
View File
@@ -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;
+116
View File
@@ -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;
},
});
+12
View File
@@ -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
View File
@@ -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 }
); );
+2 -2
View File
@@ -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'}
> >
+78 -2
View File
@@ -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>
); );
} }