使用 Next.js 和 TailwindCSS 构建 Slack 克隆 - 第一部分

协作是成功的关键,而创建一款帮助团队更好地工作的工具可以既有趣又有益。随着越来越多的人在家工作,开发一款帮助所有人通过消息、视频和群聊保持联系的应用可以带来巨大的改变。

在这个由三部分组成的系列中,我们将构建一个 Slack 克隆版 - 一款帮助团队通过即时消息、视频通话和频道保持联系的应用程序。我们将使用 React (Next.js)、TailwindCSS、Prisma 和 Stream SDK 构建此应用程序。

在第一部分中,我们将通过设置项目和构建第一个用户界面(包括频道页面)来设置基础知识。

在第二部分中,我们将使用 Stream React Chat SDK 添加实时消息和频道。最后,在第三部分中,我们将使用 Stream React Video and Audio SDK 添加视频通话(如 Slack Huddles)并添加最后的润色。

在本系列结束时,您将构建一个强大的协作应用程序,该应用程序反映了 Slack 的基本功能。

最终产品的外观如下:

您可以查看现场演示并在 GitHub 上访问完整的源代码。

让我们开始吧!

先决条件

在开始项目之前,请确保您已准备好以下内容:

  • 对 React 的基本了解:您应该能够轻松构建组件、管理状态并了解组件的工作原理。
  • Node.js 和 npm:确保您的计算机上安装了 Node.js 和 npm(Node 包管理器)。这对于运行和构建我们的项目很重要。
  • 熟悉 TypeScript、Next.js 和 TailwindCSS 基础知识:我们会经常使用这些工具,因此了解基础知识将帮助您轻松地跟进。
  • 项目设置

    让我们从设置项目开始。我们将首先克隆一个包含初始设置代码和文件夹结构的入门模板,以帮助我们快速入门:

    # Clone the repository
    git clone https://github.com/TropicolX/slack-clone.git
    
    # Navigate into the project directory
    cd slack-clone
    
    # Check out the starter branch
    git checkout starter
    
    # Install the dependencies
    npm install

    项目结构应如下所示:

    Project structure

    该项目的组织方式是为了保持代码整洁,并且随着代码的增长易于管理:

  • 组件目录:此文件夹包含用户界面的所有可重复使用的部分,如图标、按钮和其他基本组件。
  • Hooks 目录:hooks 文件夹有自定义的 React hooks,例如 useClickOutside,我们将使用它来处理特定的用户交互。
  • Lib 目录:此文件夹包含诸如 utils.ts 之类的实用函数,可简化整个应用程序的常见任务。
  • 设置数据库

    要构建类似于 Slack 的应用程序,我们需要能够在数据库中存储有关工作区、频道、成员和邀请的信息。

    我们将使用 Prisma 来帮助我们轻松地与该数据库交互。

    什么是 Prisma?

    Prisma 是一个开源 ORM(对象关系映射)工具,它允许我们定义数据库结构并高效地运行查询。使用 Prisma,您可以更直观地编写数据库操作,而无需直接处理 SQL,这让事情变得更简单,并减少错误。

    安装 Prisma

    让我们首先安装 Prisma 及其依赖项:

    npm install prisma --save-dev
    npm install @prisma/client sqlite3

    `@prisma/client` 库帮助我们与数据库交互,而 `sqlite3` 是我们将用于该项目的数据库。

    安装完成后,让我们使用以下命令初始化 Prisma:

    npx prisma init

    此命令设置默认的 Prisma 结构并创建一个新的 `.env` 文件,我们将在其中配置数据库连接。

    设置数据库模式

    现在,让我们定义数据库模式。打开“prisma/schema.prisma”文件并添加以下内容:

    datasource db {
      provider = "sqlite"
      url      = env("DATABASE_URL")
    }
    
    generator client {
      provider = "prisma-client-js"
    }
    
    model Workspace {
      id           String       @id @default(cuid())
      name         String
      image        String?
      ownerId      String 
      channels     Channel[]    
      memberships  Membership[]
      invitations Invitation[]
    }
    
    model Channel {
      id           String       @id @default(cuid())
      name         String
      description  String?
      workspaceId  String
      workspace    Workspace    @relation(fields: [workspaceId], references: [id])
    }
    
    model Membership {
      id           String       @id @default(cuid())
      userId       String
      email        String
      workspaceId  String
      workspace    Workspace @relation(fields: [workspaceId], references: [id])
      role         String?      @default("member")
      joinedAt     DateTime?    @default(now())
      @@unique([userId, workspaceId])
    }
    
    model Invitation {
      id            Int        @id @default(autoincrement())
      email         String
      token         String     @unique
      workspaceId   String
      workspace     Workspace  @relation(fields: [workspaceId], references: [id])
      invitedById   String
      acceptedById  String?
      createdAt     DateTime   @default(now())
      acceptedAt    DateTime?
    }

    此模式定义了我们的 Slack 克隆中的主要关系。以下是每个模型的作用:

  • 工作区:代表人们可以协作的工作区。它包含工作区名称、图像以及与之关联的频道、会员资格和邀请列表等信息。
  • 频道:表示工作区内的频道。频道是用户可以进行特定讨论的地方,它们属于特定的工作区。
  • 成员资格:跟踪哪些用户属于哪个工作区。其中包括用户 ID、电子邮件、角色(例如成员)以及他们加入工作区的时间等详细信息。
  • 邀请:管理加入工作区的邀请。它会跟踪受邀者的电子邮件、邀请的唯一令牌、邀请人以及邀请是否已被接受。
  • 每个模型都有自己的细节和联系,使得我们在构建特征时可以轻松获取相关数据。

    接下来,让我们设置数据库连接。导航到你的 `.env` 文件并添加以下内容:

    DATABASE_URL=file:./dev.db

    这将 SQLite 设置为我们用于本地开发的数据库。您可以在生产中切换到其他数据库,但 SQLite 非常适合快速原型设计和开发。

    运行 Prisma 迁移

    要根据我们的模式创建数据库表,请运行以下命令:

    npx prisma migrate dev --name init

    此命令为我们在数据库中定义的模型设置表。它还可以帮助我们在开发过程中跟踪数据库设置的变化。

    运行迁移后,通过运行以下命令生成 Prisma 客户端:

    npx prisma generate

    此命令创建 Prisma 客户端,让我们能够在整个代码中安全可靠地使用数据库。

    在代码中设置 Prisma 客户端

    要在我们的项目中使用 Prisma 客户端,请在“lib”目录中创建一个新的“prisma.ts”文件,其中包含以下代码:

    import { PrismaClient } from '@prisma/client';
    
    let prisma: PrismaClient;
    
    if (process.env.NODE_ENV === 'production') {
      prisma = new PrismaClient();
    } else {
      // @ts-expect-error global.prisma is used by @prisma/client
      if (!global.prisma) {
        // @ts-expect-error global.prisma is used by @prisma/client
        global.prisma = new PrismaClient();
      }
      // @ts-expect-error global.prisma is used by @prisma/client
      prisma = global.prisma;
    }
    export default prisma;

    此脚本确保我们只创建一个 Prisma 客户端实例。我们在开发过程中使用全局实例,以避免数据库连接过多的问题。这特别有用,因为频繁重启或热重载可能会导致连接问题。

    使用 Clerk 进行用户身份验证

    什么是 Clerk?

    Clerk 是一个通过提供身份验证和用户资料工具来帮助管理用户的平台。它包括现成的 UI 组件、API 和管理员仪表板,使向应用添加身份验证功能变得更加简单。您无需自己构建整个身份验证系统,Clerk 提供这些现成的功能,从而节省了时间和精力。

    在这个项目中,我们将使用 Clerk 来处理用户身份验证。

    设置职员帐户

    Clerk sign-up page

    首先,您需要创建一个免费的 Clerk 帐户。转到 Clerk 注册页面,使用您的电子邮件或社交登录选项进行注册。

    创建 Clerk 项目

    Create new project

    登录后,您可以在 Clerk 中为您的应用创建一个新项目:

  • 转到仪表板并单击“创建应用程序”。
  • 将您的应用程序命名为“Slack clone”。
  • 在“登录选项”下,选择电子邮件、用户名和 Google。
  • 单击“创建应用程序”以完成设置。
  • Clerk dashboard steps

    创建项目后,您将看到应用程序概览页面,其中包含您的**可发布密钥**和**密钥**——请保留这些信息,因为您稍后会需要它们。

    Clerk first and last name settings

    接下来,我们将在注册时将名字和姓氏设为必填字段:

  • 导航到仪表板的“配置”选项卡。
  • 在“用户和身份验证”下,选择“电子邮件、电话、用户名”。
  • 在“个人信息”部分中找到“姓名”选项并将其打开。
  • 点击“名称”旁边的齿轮图标,并根据需要进行设置。
  • 单击“继续”保存您的更改。
  • 在你的项目中安装 Clerk

    接下来,让我们将 Clerk 添加到你的 Next.js 项目中:

  • 通过运行以下命令安装 Clerk 包:npm install @clerk/nextjs
  • 创建一个 .env.local 文件并添加以下环境变量:NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key CLERK_SECRET_KEY=your_clerk_secret_key 用项目概览页中的密钥替换 your_clerk_publishable_key 和 your_clerk_secret_key。
  • 要在整个应用中使用 Clerk 的身份验证,您需要使用 ClerkProvider 包装您的应用。像这样更新您的 app/layout.tsx 文件: import type { Metadata } from 'next'; import { ClerkProvider } from '@clerk/nextjs'; ... export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( {孩子们} ); }
  • 创建注册和登录页面

    现在,我们需要使用 Clerk 的``和`` 组件。这些组件带有内置 UI 并处理所有身份验证逻辑。

    添加页面的方法如下:

  • 设置身份验证 URL:店员的和组件需要知道它们在应用中的安装位置。将这些路由添加到 .env.local 文件中:NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
  • 创建注册页面:在 app/sign-up/[[...sign-up]]/page.tsx 创建一个注册页面,并添加以下代码: import { SignUp } from '@clerk/nextjs'; export default function Page() { return ( ); }
  • 创建登录页面:在 app/sign-in/[[...sign-in]] 目录中创建一个 page.tsx 文件,并添加以下代码: import { SignIn } from '@clerk/nextjs'; export default function Page() { return ( ); }
  • 添加您的 Clerk 中间件:Clerk 附带一个 clerkMiddleware() 助手,可将身份验证集成到我们的 Next.js 项目中。我们可以使用此中间件来保护某些路由,同时保持其他路由公开。在我们的例子中,我们只希望每个人都可以访问注册和登录路由,同时保护其他路由。为此,请在 src 目录中创建一个 middleware.ts 文件,其中包含以下代码: import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; const isPublicRoute = createRouteMatcher([ '/sign-in(.*)', '/sign-up(.*)', ]); export default clerkMiddleware(async (auth, request) => { if (!isPublicRoute(request)) { await auth.protect(); } }); export const config = { matcher: [ // 跳过 Next.js 内部和所有静态文件,除非在搜索参数中找到 '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', // 始终为 API 路由运行 '/(api|trpc)(.*)', ], };
  • Sign up page

    完成这些步骤后,Clerk 应该已集成到您的应用中,并且您的登录和注册页面应该可以完全正常运行。

    构建工作区仪表板

    现在我们已经设置了数据库和身份验证,是时候开始构建工作区仪表板了。此仪表板将是用户可以使用应用程序的不同部分、查看其工作区以及查看其他工作区的邀请的主要区域。

    首先,我们需要创建构成用户界面主要部分的组件。

    创建导航栏

    导航栏是网站不可或缺的一部分,因为它可以帮助用户浏览网站并访问不同的功能。

    但是,在此设置中,导航链接不会指向任何地方。这里最有用的项目是其中的“**创建新工作区**”按钮。

    在 `components` 文件夹中创建一个 `Navbar.tsx` 文件并添加以下代码:

    import { ReactNode } from 'react';
    import Button from './Button';
    
    type NavbarProps = {
      action: () => void;
    };
    
    const Navbar = ({ action }: NavbarProps) => {
      return (
        
    ); }; type NavLinkProps = { dropdown?: boolean; children: ReactNode; }; const NavLink = ({ dropdown = false, children }: NavLinkProps) => { return (
  • ); }; export default Navbar;

    在这里,我们渲染“Navbar”组件,其中包括徽标、占位符链接和用于创建新工作区的按钮。“Navbar”还接受一个“action”属性,当单击“**创建新工作区**”按钮时会触发该属性。

    此功能允许我们定义当用户想要创建新工作区时发生的情况。

    创建工作区列表组件

    要显示用户拥有的所有工作区,我们需要一个“WorkspaceList”组件。此组件将显示工作区详细信息,并允许用户启动工作区或接受邀请。

    创建一个名为“/components/WorkspaceList.tsx”的新文件并添加以下代码:

    import { Workspace } from '@prisma/client';
    
    import Button from './Button';
    
    interface WorkspaceListProps {
      action: (formData: FormData) => void;
      actionText: string;
      buttonVariant?: 'primary' | 'secondary';
      title: string;
      workspaces: (Omit & {
        memberCount: number;
        token?: string;
        firstChannelId?: string;
      })[];
    }
    
    const placeholderImage =
      'https://a.slack-edge.com/80588/img/avatars-teams/ava_0014-88.png';
    
    const WorkspaceList = ({
      action,
      actionText,
      buttonVariant = 'primary',
      title,
      workspaces,
    }: WorkspaceListProps) => {
      return (
        
    {title}
    {workspaces.map((workspace) => (
    {/* eslint-disable-next-line @next/next/no-img-element */} workspace-image
    {workspace.name}
    {workspace.memberCount} member {workspace.memberCount !== 1 && 's'}
    ))}
    ); }; export default WorkspaceList;
  • 该组件需要几个道具:action:定义单击操作按钮时发生的情况的函数。actionText:每个工作区按钮上显示的文本。buttonVariant:指定按钮的样式,'primary' 或 'secondary'。title:工作区列表的标题。workspaces:带有附加详细信息的工作区对象数组。
  • 对于每个工作区,我们显示:名称:工作区的名称。图像:工作区图像或占位符(如果未提供图像)。成员数:工作区中的成员数。
  • 每个工作区项也是一个具有隐藏输入字段的表单。这些字段存储了诸如 channelId、token 和 workworkId 等基本数据。当用户单击提交按钮时,这些输入会发送所需的信息以代表该工作区采取行动。
  • 综合起来

    现在我们已经创建了工作区仪表板的基本组件,是时候将它们组合在一起并制作主仪表板页面了。我们将使用“Navbar”和“WorkspaceList”组件为我们的工作区应用程序构建一个用户友好的界面。

    更新你的 `app/page.tsx` 文件以将所有组件整合在一起并创建仪表板:

    import Image from 'next/image';
    import { redirect } from 'next/navigation';
    import { currentUser } from '@clerk/nextjs/server';
    import { SignOutButton } from '@clerk/nextjs';
    
    import Button from '@/components/Button';
    import Navbar from '@/components/Navbar';
    import prisma from '@/lib/prisma';
    import WorkspaceList from '@/components/WorkspaceList';
    
    export default async function Home() {
      const user = await currentUser();
      const userEmail = user?.primaryEmailAddress?.emailAddress;
    
      const memberships = await prisma.membership.findMany({
        where: {
          userId: user!.id,
        },
        include: {
          workspace: {
            include: {
              _count: {
                select: { memberships: true },
              },
              memberships: {
                take: 5,
              },
              channels: {
                take: 1,
                select: {
                  id: true,
                },
              },
            },
          },
        },
      });
    
      const workspaces = memberships.map((membership) => {
        const { workspace } = membership;
        return {
          id: workspace.id,
          name: workspace.name,
          image: workspace.image,
          memberCount: workspace._count.memberships,
          firstChannelId: workspace.channels[0].id,
        };
      });
    
      const invitations = await prisma.invitation.findMany({
        where: {
          email: userEmail,
          acceptedAt: null,
        },
        include: {
          workspace: {
            include: {
              _count: {
                select: { memberships: true },
              },
              memberships: {
                take: 5,
              },
            },
          },
        },
      });
    
      const processedInvitations = invitations.map((invitation) => {
        const { workspace } = invitation;
        return {
          id: workspace.id,
          name: workspace.name,
          image: workspace.image,
          memberCount: workspace._count.memberships,
          token: invitation.token,
        };
      });
    
      async function acceptInvitation(formData: FormData) {
        'use server';
        const token = String(formData.get('token'));
        const invitation = await prisma.invitation.findUnique({
          where: { token },
        });
    
        await prisma.membership.create({
          data: {
            userId: user!.id,
            email: userEmail!,
            workspace: {
              connect: { id: invitation!.workspaceId },
            },
            role: 'user',
          },
        });
    
        await prisma.invitation.update({
          where: { token },
          data: {
            acceptedAt: new Date(),
            acceptedById: user!.id,
          },
        });
    
        const workspace = await prisma.workspace.findUnique({
          where: { id: invitation!.workspaceId },
          select: {
            id: true,
            channels: {
              take: 1,
              select: {
                id: true,
              },
            },
          },
        });
    
        redirect(`/client/${workspace!.id}/${workspace!.channels[0].id}`);
      }
    
      async function launchChat(formData: FormData) {
        'use server';
        const workspaceId = formData.get('workspaceId');
        const channelId = formData.get('channelId');
        redirect(`/client/${workspaceId}/${channelId}`);
      }
    
      async function goToGetStartedPage() {
        'use server';
        redirect('/get-started');
      }
    
      return (
        
    {/* Workspaces */}
    waving-hand

    Welcome back

    {workspaces.length > 0 ? ( ) : (

    You are not a member of any workspaces yet.

    )}
    {/* Create new workspace */}
    woman-with-laptop

    {workspaces.length > 0 ? 'Want to use Slack with a different team?' : 'Want to get started with Slack?'}

    {/* Invitations */}
    {processedInvitations.length > 0 && ( )}

    Not seeing your workspace?

    ); }

    以下是代码每个部分的作用:

  • 用户信息:该功能首先使用 Clerk 检索当前用户的信息。
  • 工作区数据:它查询数据库以获取用户所属的所有工作区和任何待处理的邀请。
  • 操作函数:这里定义了三个主要函数:acceptInvitation():接受邀请并将用户重定向到适当的工作区。launchChat():通过重定向到正确的 URL 启动所选工作区的聊天。goToGetStartedPage():重定向到“开始”页面以创建新的工作区。
  • 最后,我们返回 Navbar、WorkspaceList 和 Clerk 的 SignOutButton 按钮以呈现一个欢迎界面。
  • Home page

    创建工作区

    构建创建工作区 API

    为了允许用户创建新的工作区,我们需要构建一个 API 来处理创建过程和一个可以提供必要详细信息的用户界面。

    创建一个 `/api/workspaces/create` 目录,然后添加一个 `route.ts` 文件,内容如下:

    import { NextResponse } from 'next/server';
    import { auth, currentUser } from '@clerk/nextjs/server';
    
    import prisma from '@/lib/prisma';
    import {
      generateChannelId,
      generateToken,
      generateWorkspaceId,
      isEmail,
    } from '@/lib/utils';
    
    export async function POST(request: Request) {
      const { userId } = await auth();
    
      if (!userId) {
        return NextResponse.json(
          { error: 'Authentication required' },
          { status: 401 }
        );
      }
    
      try {
        const user = await currentUser();
        const userEmail = user?.primaryEmailAddress?.emailAddress;
    
        const body = await request.json();
        const { workspaceName, channelName, emails, imageUrl } = body;
    
        // Validate input
        if (
          !workspaceName ||
          !channelName ||
          !Array.isArray(emails) ||
          emails.length === 0
        ) {
          return NextResponse.json(
            { error: 'Invalid input data' },
            { status: 400 }
          );
        }
    
        // Validate emails
        for (const email of emails) {
          if (!isEmail(email)) {
            return NextResponse.json(
              { error: `Invalid email address: ${email}` },
              { status: 400 }
            );
          }
        }
    
        // Create workspace
        const workspace = await prisma.workspace.create({
          data: {
            id: generateWorkspaceId(),
            name: workspaceName,
            image: imageUrl || null,
            ownerId: userId,
          },
        });
    
        // Create initial channel
        const channel = await prisma.channel.create({
          data: {
            id: generateChannelId(),
            name: channelName,
            workspaceId: workspace.id,
          },
        });
    
        // Add authenticated user as admin
        await prisma.membership.create({
          data: {
            userId: userId,
            email: userEmail!,
            workspace: {
              connect: { id: workspace.id },
            },
            role: 'admin',
          },
        });
    
        // Invite provided emails
        const invitations = [];
        const skippedEmails = [];
        const errors = [];
    
        for (const email of emails) {
          try {
            // Check if an invitation already exists
            const existingInvitation = await prisma.invitation.findFirst({
              where: {
                email,
                workspaceId: workspace.id,
                acceptedAt: null,
              },
            });
    
            // check if the user is already a member
            const existingMembership = await prisma.membership.findFirst({
              where: {
                email,
                workspaceId: workspace.id,
              },
            });
    
            if (existingInvitation) {
              skippedEmails.push(email);
              continue;
            }
    
            if (existingMembership) {
              skippedEmails.push(email);
              continue;
            }
    
            if (email === userEmail) {
              skippedEmails.push(email);
              continue;
            }
    
            // Generate token
            const token = generateToken();
    
            // Create invitation
            const invitation = await prisma.invitation.create({
              data: {
                email,
                token,
                workspaceId: workspace.id,
                invitedById: userId,
              },
            });
    
            invitations.push(invitation);
          } catch (error) {
            console.error(`Error inviting ${email}:`, error);
            errors.push({ email, error });
          }
        }
    
        // Return response
        const response = {
          message: 'Workspace created successfully',
          workspace: {
            id: workspace.id,
            name: workspace.name,
          },
          channel: {
            id: channel.id,
            name: channelName,
          },
          invitationsSent: invitations.length,
          invitationsSkipped: skippedEmails.length,
          errors,
        };
    
        if (errors.length > 0) {
          return NextResponse.json(response, { status: 207 });
        } else {
          return NextResponse.json(response, { status: 200 });
        }
      } catch (error) {
        console.error('Error creating workspace:', error);
        return NextResponse.json(
          { error: 'Internal server error' },
          { status: 500 }
        );
      } finally {
        await prisma.$disconnect();
      }
    }

    此 API 的功能如下:

  • 身份验证:首先检查用户是否登录。只有登录的用户才能创建工作区。
  • 输入验证:确保提供的信息(如工作区名称、频道名称和电子邮件列表)是正确的。
  • 创建工作区和频道:API 然后会在数据库中创建一个新的工作区,并为该工作区设置第一个频道。
  • 添加管理员:我们将创建工作区的用户添加为该工作区的管理员。
  • 发送邀请:它向提供的电子邮件地址发送邀请,同时跳过任何已被邀请、已是会员或无效的邀请。
  • 最后,API 返回一个响应,其中包含有关新工作区、频道以及成功发送或跳过的邀请数量的详细信息。

    构建工作区设置页面

    接下来,让我们创建一个页面,用户可以在其中填写设置新工作区所需的信息。此页面将是与我们的 API 交互的用户界面。

    在 `/app` 内创建一个 `get-started` 目录,然后在其中创建一个 `page.tsx` 文件并添加以下代码:

    'use client';
    import { useState } from 'react';
    import { useRouter } from 'next/navigation';
    
    import { isUrl } from '@/lib/utils';
    import ArrowDropdown from '@/components/icons/ArrowDropdown';
    import Avatar from '@/components/Avatar';
    import Button from '@/components/Button';
    import Hash from '@/components/icons/Hash';
    import Home from '@/components/icons/Home';
    import MoreHoriz from '@/components/icons/MoreHoriz';
    import RailButton from '@/components/RailButton';
    import SidebarButton from '@/components/SidebarButton';
    import Tags from '@/components/Tags';
    import TextField from '@/components/TextField';
    
    const pattern = `(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))`;
    
    const GetStarted = () => {
      const router = useRouter();
      const [workspaceName, setWorkspaceName] = useState('');
      const [channelName, setChannelName] = useState('');
      const [emails, setEmails] = useState([]);
      const [imageUrl, setImageUrl] = useState('');
      const [loading, setLoading] = useState(false);
    
      const allFieldsValid = Boolean(
        workspaceName &&
          channelName &&
          (!imageUrl || (isUrl(imageUrl) && RegExp(pattern).test(imageUrl))) &&
          emails.length > 0
      );
    
      const onSubmit = async (e: React.FormEvent) => {
        if (allFieldsValid) {
          e.stopPropagation();
    
          try {
            setLoading(true);
            const response = await fetch('/api/workspaces/create', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({
                workspaceName: workspaceName.trim(),
                channelName: channelName.trim(),
                emails,
                imageUrl,
              }),
            });
    
            const result = await response.json();
    
            if (response.ok) {
              alert('Workspace created successfully!');
              const { workspace, channel } = result;
              router.push(`/client/${workspace.id}/${channel.id}`);
            } else {
              alert(`Error: ${result.error}`);
            }
          } catch (error) {
            console.error('Error creating workspace:', error);
            alert('An unexpected error occurred.');
          } finally {
            setLoading(false);
          }
        }
      };
    
      return (
        
    } active />
    } />
    {workspaceName}
    {channelName && (
    )}

    Create a new workspace

    {}} className="contents"> setWorkspaceName(e.target.value)} placeholder="Enter a name for your workspace" required /> Workspace image{' '} (optional) } name="workspaceImage" type="url" value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="Paste an image URL" pattern={`(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))`} title='Image URL must start with "http://" or "https://" and end with ".png", ".jpg", ".jpeg", ".gif", or ".svg"' /> setChannelName( e.target.value.toLowerCase().replace(/\s/g, '-') ) } placeholder="Enter a name for your first channel" maxLength={80} required />
    ); }; export default GetStarted;

    在上面的代码中:

  • 该页面允许用户提供创建新工作区的详细信息,包括:工作区名称:工作区的名称。频道名称:工作区内要创建的第一个频道的名称。要邀请的成员的电子邮件:用户可以输入电子邮件地址列表以邀请成员加入工作区。工作区图像(可选):用户可以选择提供图像 URL 来表示工作区。
  • 表单使用 useState 钩子来存储用户输入的值,并使用 allFieldsValid 来确保所有必填字段都正确填写。
  • 提交表单后,它会向 /api/workspaces/create 路由发出 POST 请求,并传递工作区详细信息。如果请求成功,我们会将用户重定向到新的工作区。
  • 在处理请求时,界面提供视觉反馈,例如显示加载状态或出现错误时的错误消息。
  • Create new workspace page

    创建你的第一个工作区

    现在您已完成 API 和设置页面的构建,现在是时候创建您的第一个工作区以确保一切按预期运行。请遵循以下步骤:

  • 转到设置页面:导航到您的应用程序的 /get-started 页面。
  • 填写必要的详细信息:输入您想要邀请的成员的工作区名称、频道名称和电子邮件地址。
  • 添加图像(可选):如果您愿意,可以添加图像 URL 以使工作区看起来更加个性化。
  • 提交表单:单击“提交”按钮以创建工作区。
  • 验证创建:确保工作区创建成功并且所有受邀成员都收到邀请。
  • 检查仪表板:验证新的工作区是否在仪表板上正确列出,以及初始频道是否可见。
  • New workspace creation

    通过遵循这些步骤,您可以确认工作区创建流程是否正确运行。

    在应用程序中设置流

    什么是 Stream?

    Stream 是一个平台,允许开发人员为其应用程序添加丰富的聊天和视频功能。Stream 提供 API 和 SDK 来帮助您快速轻松地添加聊天和视频功能,而无需从头开始创建聊天和视频功能。

    在这个项目中,我们将使用 Stream 的 React SDK for Video 和 React Chat SDK 在我们的 Slack 克隆中构建聊天和视频通话功能。

    创建您的 Stream 帐户

    Stream sign up page

    要开始使用 Stream,您需要创建一个帐户:

  • 注册:转到 Stream 注册页面并使用您的电子邮件或社交登录信息创建一个帐户。
  • 完善您的个人资料:
  • * After signing up, you'll be asked for additional information, such as your role and industry.
    
    * Select the **"Chat Messaging"** and **"Video and Audio"** options since we need these tools for our app.
    
        ![Strem sign up options](https://cdn.hashnode.com/res/hashnode/image/upload/v1726664432078/966254af-e0b3-4a54-b395-52667e6374b7.png)
    
    * Click **"Complete Signup"** to continue.

    您现在将被重定向到您的 Stream 仪表板。

    创建新的流项目

    Create new app form

    创建 Stream 帐户后,下一步是为您的项目设置一个应用程序:

  • 创建新应用程序:在您的 Stream 仪表板中,单击“创建应用程序”按钮。
  • 配置您的应用程序:
  • * **App Name**: Enter a name like "**Slack Clone**" or any other name you choose.
    
    * **Region**: Pick the region nearest to you for the best performance.
    
    * **Environment**: Keep it set to "**Development**".
    
    * Click the "**Create App**" to finish.
  • 获取 API 密钥:创建应用后,导航至“应用访问密钥”部分。您需要这些密钥才能将 Stream 连接到您的项目。
  • 配置用户权限

    为了允许用户发送消息、阅读频道和执行其他操作,您需要在 Stream 仪表板中设置必要的权限:

    Updating roles and permissions
  • 导航到“聊天消息”下的“角色和权限”选项卡。
  • 选择“用户”角色并选择“消息”范围。
  • 单击“编辑”按钮并选择以下权限:
  • * Create Message
    
    * Read Channel
    
    * Read Channel Members
    
    * Create Reaction
    
    * Upload Attachments
    
    * Create Attachments
  • 保存并确认更改。
  • 安装 Stream SDK

    要开始在 Next.js 项目中使用 Stream,我们需要安装一些 SDK:

  • 安装 SDK:运行以下命令安装必要的软件包:npm install @stream-io/node-sdk @stream-io/video-react-sdk stream-chat-react stream-chat
  • 设置环境变量:将您的 Stream API 密钥添加到您的 .env.local 文件:NEXT_PUBLIC_STREAM_API_KEY=your_stream_api_key STREAM_API_SECRET=your_stream_api_secret 用您的 Stream 仪表板中的密钥替换 your_stream_api_key 和 your_stream_api_secret。
  • 导入样式表:Stream SDK 为其聊天和视频组件提供了现成的样式表。将这些样式导入到您的 app/layout.tsx 文件中:​ // app/layout.tsx ... import '@stream-io/video-react-sdk/dist/css/styles.css'; import 'stream-chat-react/dist/css/v2/index.css'; import './globals.css'; ...
  • 将 Clerk 与 Stream 应用同步

    为了确保 Clerk 和 Stream 之间的用户数据一致,您需要设置一个同步用户信息的 webhook:

  • 设置 ngrok:由于 webhook 需要可公开访问的 URL,我们将使用 ngrok 来公开我们的本地服务器。请按照以下步骤为您的应用设置 ngrok 隧道:
  • * Go to the [ngrok website](https://dashboard.ngrok.com/signup) and sign up for a free account.
    
    * [Download and install ngrok](https://dashboard.ngrok.com/get-started/setup), then start a tunnel to your local server (assuming it's running on port 3000):
    ```bash
        ngrok http 3000 --domain=YOUR_DOMAIN
        ```
    Replace `YOUR_DOMAIN` with the [generated ngrok domain](https://dashboard.ngrok.com/cloud-edge/domains).
  • 在 Clerk 中创建 Webhook 端点:
  • * **Navigate to Webhooks**: In your [Clerk dashboard](https://dashboard.clerk.com/last-active?path=webhooks), navigate to the “**Configure**” tab and select "**Webhooks**.”
    
    * **Add a New Endpoint**:
    
        * Click "**Add Endpoint**" and enter your ngrok URL, followed by `/api/webhooks` (e.g., `https://your-subdomain.ngrok.io/api/webhooks`).
    
        * Under “**Subscribe to events**”, select `user.created` and `user.updated`.
    
        * Click "**Create**".
    
    * **Get the Signing Secret**: Copy the signing secret provided and add it to your `.env.local` file:
    ```dockerfile
        WEBHOOK_SECRET=your_clerk_webhook_signing_secret
        ```
    Replace `your_clerk_webhook_signing_secret` with the signing secret from the webhooks page.
    
        ![Signing secret](https://cdn.hashnode.com/res/hashnode/image/upload/v1726741867125/7c6ffd89-36ac-4c4b-a5e0-bd665796612d.png)
  • 安装 Svix:我们需要 Svix 来验证和处理传入的 webhook。运行以下命令安装该包:npm install svix
  • 在您的应用中创建 Webhook 端点:接下来,我们需要创建一个路由来接收 webhook 的有效负载。创建一个 /app/api/webhooks 目录并添加一个 route.ts 文件,其中包含以下代码:从“svix”导入 { Webhook }; 从“next/headers”导入 { headers }; 从“@clerk/nextjs/server”导入 { WebhookEvent }; 从“@stream-io/node-sdk”导入 { StreamClient }; const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!; const SECRET = process.env.STREAM_API_SECRET!; const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; export async function POST(req: Request) { const client = new StreamClient(API_KEY, SECRET); if (!WEBHOOK_SECRET) { throw new Error( '请将 WEBHOOK_SECRET 从 Clerk Dashboard 添加到 .env 或 .env.local' ); } // 获取标题 const headerPayload = headers(); const svix_id = headerPayload.get('svix-id'); const svix_timestamp = headerPayload.get('svix-timestamp'); const svix_signature = headerPayload.get('svix-signature'); // 如果没有标题,则出错 if (!svix_id || !svix_timestamp || !svix_signature) { return new Response('发生错误 -- 没有 svix 标题', { status: 400, }); } // 获取主体 const payload = await req.json(); const body = JSON.stringify(payload); // 使用您的机密创建一个新的 Svix 实例。 const wh = new Webhook(WEBHOOK_SECRET); let evt: WebhookEvent; // 使用标头验证有效负载 try { evt = wh.verify(body, { 'svix-id': svix_id, 'svix-timestamp': svix_timestamp, 'svix-signature': svix_signature, }) as WebhookEvent; } catch (err) { console.error('验证 webhook 时出错:', err); return new Response('发生错误', { status: 400, }); } const eventType = evt.type; switch (eventType) { case 'user.created': case 'user.updated': const newUser = evt.data; await client.upsertUsers([ { id: newUser.id, role: 'user', name: `${newUser.first_name} ${newUser.last_name}`, custom: { username: newUser.username, email: newUser.email_addresses[0].email_address, }, image: newUser.has_image ? newUser.image_url : undefined, }, ]); break; default: break; } return new Response('Webhooktreated', { status: 200 }); } 在 webhook 处理程序中:
  • * We use Svix's `Webhook` class to verify incoming requests. If the request is valid, we sync the user data with Stream using the `upsertUsers` method for `user.created` and `user.updated` events.
    
    * For `user.created` and `user.updated` events, we sync the user data with Stream using `upsertUsers`.
  • 将 Webhook 端点设为公共:最后,我们需要将 webhook 端点添加到中间件配置中的公共路由,以确保 Clerk 可以从“外部”访问它。导航到 middleware.ts 文件,并添加以下内容:import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; const isPublicRoute = createRouteMatcher([ ... '/api/webhooks(.*)', ]); ...
  • 完成这些步骤后,Stream 将在您的应用程序中成功设置。

    构建工作空间中心

    工作区中心是我们 Slack 克隆版的核心部分,用户可以在这里聊天、进行视频通话和管理他们的工作区。它结合了所有基本功能(例如 Slack 如何组织沟通渠道和工具),使用户可以轻松沟通和协作。

    创建布局

    我们需要一个布局作为工作区中所有活动的基础。此布局将侧边栏、聊天区和聚会等部分整合在一起。

    在 `app` 目录下创建 `client` 文件夹,并添加 `layout.tsx` 文件,内容如下:

    'use client';
    import { createContext, ReactNode, useEffect, useState } from 'react';
    import {
      Channel,
      Invitation,
      Membership,
      Workspace as PrismaWorkspace,
    } from '@prisma/client';
    import { UserButton, useUser } from '@clerk/nextjs';
    import { StreamChat } from 'stream-chat';
    import { Chat } from 'stream-chat-react';
    import {
      Call,
      StreamVideo,
      StreamVideoClient,
    } from '@stream-io/video-react-sdk';
    
    import ArrowBack from '@/components/icons/ArrowBack';
    import ArrowForward from '@/components/icons/ArrowForward';
    import Avatar from '@/components/Avatar';
    import Bookmark from '@/components/icons/Bookmark';
    import Clock from '@/components/icons/Clock';
    import IconButton from '@/components/IconButton';
    import Help from '@/components/icons/Help';
    import Home from '@/components/icons/Home';
    import Plus from '@/components/icons/Plus';
    import Messages from '@/components/icons/Messages';
    import MoreHoriz from '@/components/icons/MoreHoriz';
    import Notifications from '@/components/icons/Notifications';
    import RailButton from '@/components/RailButton';
    import SearchBar from '@/components/SearchBar';
    import WorkspaceLayout from '@/components/WorkspaceLayout';
    import WorkspaceSwitcher from '@/components/WorkspaceSwitcher';
    
    interface LayoutProps {
      children?: ReactNode;
      params: Promise<{ workspaceId: string }>;
    }
    
    export type Workspace = PrismaWorkspace & {
      channels: Channel[];
      memberships: Membership[];
      invitations: Invitation[];
    };
    
    export const AppContext = createContext<{
      workspace: Workspace;
      setWorkspace: (workspace: Workspace) => void;
      otherWorkspaces: Workspace[];
      setOtherWorkspaces: (workspaces: Workspace[]) => void;
      channel: Channel;
      setChannel: (channel: Channel) => void;
      loading: boolean;
      setLoading: (loading: boolean) => void;
      chatClient: StreamChat;
      setChatClient: (chatClient: StreamChat) => void;
      videoClient: StreamVideoClient;
      setVideoClient: (videoClient: StreamVideoClient) => void;
      channelCall: Call | undefined;
      setChannelCall: (call: Call) => void;
    }>({
      workspace: {} as Workspace,
      setWorkspace: () => {},
      otherWorkspaces: [],
      setOtherWorkspaces: () => {},
      channel: {} as Channel,
      setChannel: () => {},
      loading: false,
      setLoading: () => {},
      chatClient: {} as StreamChat,
      setChatClient: () => {},
      videoClient: {} as StreamVideoClient,
      setVideoClient: () => {},
      channelCall: undefined,
      setChannelCall: () => {},
    });
    
    const tokenProvider = async (userId: string) => {
      const response = await fetch('/api/token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ userId: userId }),
      });
      const data = await response.json();
      return data.token;
    };
    
    const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY as string;
    
    const Layout = ({ children }: LayoutProps) => {
      const { user } = useUser();
      const [loading, setLoading] = useState(true);
      const [workspace, setWorkspace] = useState();
      const [channel, setChannel] = useState();
      const [otherWorkspaces, setOtherWorkspaces] = useState([]);
      const [chatClient, setChatClient] = useState();
      const [videoClient, setVideoClient] = useState();
      const [channelCall, setChannelCall] = useState();
    
      useEffect(() => {
        const customProvider = async () => {
          const token = await tokenProvider(user!.id);
          return token;
        };
    
        const setUpChatAndVideo = async () => {
          const chatClient = StreamChat.getInstance(API_KEY);
          const clerkUser = user!;
          const chatUser = {
            id: clerkUser.id,
            name: clerkUser.fullName!,
            image: clerkUser.imageUrl,
            custom: {
              username: user?.username,
            },
          };
    
          if (!chatClient.user) {
            await chatClient.connectUser(chatUser, customProvider);
          }
    
          setChatClient(chatClient);
          const videoClient = StreamVideoClient.getOrCreateInstance({
            apiKey: API_KEY,
            user: chatUser,
            tokenProvider: customProvider,
          });
          setVideoClient(videoClient);
        };
    
        if (user) setUpChatAndVideo();
      }, [user, videoClient, chatClient]);
    
      if (!chatClient || !videoClient || !user)
        return (
          
    ); return (
    {/* Toolbar */}
    {!loading && (
    } disabled /> } disabled />
    } />
    } />
    )}
    {/* Main */}
    {/* Rail */}
    {!loading && ( <>
    } active /> } /> } /> } /> } />
    )}
    {children}
    ); }; export default Layout;

    这里发生了很多事情,让我们来分析一下:

  • 上下文管理:AppContext 存储整个应用程序内的共享信息,如当前工作区、频道、聊天客户端、视频客户端等。
  • 设置聊天和视频客户端:在 useEffect 中,我们有一个 setUpChatAndVideo 函数,用于设置来自 Stream 的聊天和视频客户端。它将用户连接到聊天客户端并设置视频客户端以进行通话。
  • Token Provider:tokenProvider 函数从我们的 /api/token 端点请求一个 token。Stream 的服务需要这个 token 来知道用户是谁。
  • 主要组件:布局分为不同的主要部分:工具栏:工具栏有导航按钮、搜索栏和帮助按钮。栏杆:这是一个垂直部分,有“主页”、“DM”、“活动”等按钮。工作区切换器:此部分允许用户在工作区之间切换。工作区布局:工作区布局包含侧边栏和主频道内容。
  • 添加 Token API 路由

    在上一节中,我们添加了一个 token 提供程序,它向 `/api/token` 发送请求以获取 Stream 用户 token。接下来,我们将创建处理此请求的 API 路由。

    创建一个 `/app/api/token` 目录,然后添加一个 `route.ts` 文件,内容如下:

    import { StreamClient } from '@stream-io/node-sdk';
    
    const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
    const SECRET = process.env.STREAM_API_SECRET!;
    
    export async function POST(request: Request) {
      const client = new StreamClient(API_KEY, SECRET);
    
      const body = await request.json();
    
      const userId = body?.userId;
    
      if (!userId) {
        return Response.error();
      }
    
      const token = client.generateUserToken({ user_id: userId });
    
      const response = {
        userId: userId,
        token: token,
      };
    
      return Response.json(response);
    }

    在上面的代码中,我们使用 Stream 的 Node SDK 根据用户的“userId”为其创建令牌。此令牌将对用户进行身份验证,以使用 Stream 的聊天和视频功能。

    工作区切换器组件

    接下来,让我们创建在上一节中添加到布局中的“WorkspaceSwitcher”组件。

    在 `components` 目录中创建 `WorkspaceSwitcher.tsx` 文件,并添加以下代码:

    import { MutableRefObject, useContext, useState } from 'react';
    import { useRouter } from 'next/navigation';
    import clsx from 'clsx';
    
    import { AppContext, Workspace } from '@/app/client/layout';
    import Avatar from './Avatar';
    import Plus from './icons/Plus';
    import useClickOutside from '@/hooks/useClickOutside';
    
    const WorkspaceSwitcher = () => {
      const router = useRouter();
      const [open, setOpen] = useState(false);
      const {
        workspace,
        setWorkspace,
        otherWorkspaces,
        setOtherWorkspaces,
        setChannel,
      } = useContext(AppContext);
    
      const domNode = useClickOutside(() => {
        setOpen(false);
      }, true) as MutableRefObject;
    
      const switchWorkspace = (otherWorkspace: Workspace) => {
        setOtherWorkspaces([
          ...otherWorkspaces.filter((w) => w.id !== otherWorkspace.id),
          workspace,
        ]);
        setWorkspace(otherWorkspace);
        setChannel(otherWorkspace.channels[0]);
        router.push(
          `/client/${otherWorkspace.id}/${otherWorkspace.channels[0].id}`
        );
      };
    
      return (
        
    setOpen((prev) => !prev)} className="relative w-9 h-9 mb-[5px] cursor-pointer" >
    {workspace.name}
    {workspace.name.replace(/\s/g, '').toLowerCase()}.slack.com
    Never miss a notification
    Get the Slack app {' '} to see notifications from your other workspaces
    {otherWorkspaces.map((otherWorkspace) => ( ))}
    ); }; export default WorkspaceSwitcher;

    在“WorkspaceSwitcher”组件中,当用户点击工作区按钮时,我们有一个下拉菜单。此下拉菜单可让用户轻松在工作区之间切换或添加新工作区。

  • 切换工作区:switchWorkspace 函数更新当前工作区和频道,然后将用户导航到新工作区的主页。
  • 单击外部关闭:当用户单击工作区切换器下拉菜单外部的任意位置时,useClickOutside 钩子用于关闭工作区切换器下拉菜单。
  • 添加工作区:底部的按钮允许用户创建新的工作区,并将他们引导至设置页面。
  • 构建工作区布局组件

    接下来,我们将创建在上一节中添加的“WorkspaceLayout”组件,类似于创建“WorkspaceSwitcher”组件的方式。

    在 `components` 目录中创建 `WorkspaceLayout.tsx` 文件,并添加以下代码:

    'use client';
    import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
    import clsx from 'clsx';
    
    import { AppContext } from '../app/client/layout';
    import Sidebar from './Sidebar';
    
    interface WorkspaceLayoutProps {
      children: ReactNode;
    }
    
    const WorkspaceLayout = ({ children }: WorkspaceLayoutProps) => {
      const { loading } = useContext(AppContext);
      const layoutRef = useRef(null);
      const [layoutWidth, setLayoutWidth] = useState(0);
    
      useEffect(() => {
        if (!layoutRef.current) {
          return;
        }
    
        const resizeObserver = new ResizeObserver((entries) => {
          for (const entry of entries) {
            setLayoutWidth(entry.contentRect.width);
          }
        });
    
        resizeObserver.observe(layoutRef.current);
    
        return () => {
          resizeObserver.disconnect();
        };
      }, [layoutRef]);
    
      return (
        
    {/* Sidebar */} {layoutWidth > 0 &&
    {children}
    }
    ); }; export default WorkspaceLayout;

    `WorkspaceLayout` 组件为整个工作区提供了一致的结构。它包括:

  • 侧边栏集成:此布局中包含侧边栏,以便用户轻松访问所有工作区频道。
  • 布局宽度:该组件使用 ResizeObserver 获取布局的当前宽度并确保侧边栏可以适当调整大小。
  • 添加频道预览组件

    接下来,我们将创建“ChannelPreview”组件,它显示工作区中每个频道的预览。

    在 `components` 目录中创建 `ChannelPreview.tsx` 文件,并添加以下代码:

    import { useContext } from 'react';
    import { ChannelPreviewUIComponentProps } from 'stream-chat-react';
    import { usePathname, useRouter } from 'next/navigation';
    
    import { AppContext } from '../app/client/layout';
    import Hash from './icons/Hash';
    import SidebarButton from './SidebarButton';
    
    const ChannelPreview = ({
      channel,
      displayTitle,
      unread,
    }: ChannelPreviewUIComponentProps) => {
      const pathname = usePathname();
      const router = useRouter();
      const { workspace, setChannel } = useContext(AppContext);
    
      const goToChannel = () => {
        const channelId = channel.id;
        setChannel(workspace.channels.find((c) => c.id === channelId)!);
        router.push(`/client/${workspace.id}/${channelId}`);
      };
    
      const channelActive = () => {
        const pathChannelId = pathname.split('/').filter(Boolean).pop();
        return pathChannelId === channel.id;
      };
    
      return (
        
      );
    };
    
    export default ChannelPreview;

    在上面的代码中:

  • 频道预览:ChannelPreview 组件在侧边栏显示每个频道。用户可以单击某个频道,使用 goToChannel 函数将其打开,该函数会导航到所选频道。
  • 未读消息以粗体显示:如果频道中有未读消息,频道名称将以粗体显示,方便用户查看哪些频道需要关注。
  • 活动频道突出显示:channelActive 函数检查当前频道是否处于活动状态,并在侧边栏中突出显示,以便用户知道他们当前在哪个频道。
  • 添加侧边栏

    `Sidebar` 组件的主要功能是让用户快速访问频道。

    在 `components` 目录中创建 `Sidebar.tsx` 文件,并添加以下代码:

    'use client';
    import { useContext, useEffect, useMemo, useRef, useState } from 'react';
    import { useUser } from '@clerk/nextjs';
    import { ChannelList } from 'stream-chat-react';
    import clsx from 'clsx';
    
    import { AppContext } from '../app/client/layout';
    import ArrowDropdown from './icons/ArrowDropdown';
    import CaretDown from './icons/CaretDown';
    import ChannelPreview from './ChannelPreview';
    import Compose from './icons/Compose';
    import IconButton from './IconButton';
    import Refine from './icons/Refine';
    import Send from './icons/Send';
    import SidebarButton from './SidebarButton';
    import Threads from './icons/Threads';
    
    const [minWidth, defaultWidth] = [215, 275];
    
    type SidebarProps = {
      layoutWidth: number;
    };
    
    const Sidebar = ({ layoutWidth }: SidebarProps) => {
      const { user } = useUser();
      const { loading, workspace } = useContext(AppContext);
    
      const [width, setWidth] = useState(() => {
        const savedWidth =
          parseInt(window.localStorage.getItem('sidebarWidth') as string) ||
          defaultWidth;
        window.localStorage.setItem('sidebarWidth', String(savedWidth));
        return savedWidth;
      });
      const maxWidth = useMemo(() => layoutWidth - 374, [layoutWidth]);
    
      const isDragged = useRef(false);
    
      useEffect(() => {
        if (!layoutWidth) return;
    
        const onMouseMove = (e: MouseEvent) => {
          if (!isDragged.current) {
            return;
          }
          document.body.style.userSelect = 'none';
          document.body.style.cursor = 'col-resize';
          document.querySelectorAll('.sidebar-btn').forEach((el) => {
            el.setAttribute('style', 'cursor: col-resize');
          });
          setWidth((previousWidth) => {
            const newWidth = previousWidth + e.movementX / 1.3;
            if (newWidth < minWidth) {
              return minWidth;
            } else if (newWidth > maxWidth) {
              return maxWidth;
            }
            return newWidth;
          });
        };
    
        const onMouseUp = () => {
          document.body.style.userSelect = 'auto';
          document.body.style.cursor = 'auto';
          document.querySelectorAll('.sidebar-btn').forEach((el) => {
            el.removeAttribute('style');
          });
          isDragged.current = false;
        };
    
        window.removeEventListener('mousemove', onMouseMove);
        window.addEventListener('mousemove', onMouseMove);
        window.addEventListener('mouseup', onMouseUp);
    
        return () => {
          window.removeEventListener('mousemove', onMouseMove);
          window.removeEventListener('mouseup', () => onMouseUp);
        };
      }, [layoutWidth, maxWidth]);
    
      useEffect(() => {
        if (!layoutWidth || layoutWidth < 0) return;
    
        if (width) {
          let newWidth = width;
          if (width > maxWidth) {
            newWidth = maxWidth;
          }
          setWidth(newWidth);
          localStorage.setItem('sidebarWidth', String(width));
        }
      }, [width, layoutWidth, maxWidth]);
    
      return (
        

    在上面的代码中:

  • 可调整大小的侧边栏:用户可以调整侧边栏的大小,让他们可以根据自己的喜好调整宽度。
  • 频道列表:stream-chat-react 的 ChannelList 展示了工作区中的所有频道。该列表可以进行过滤和排序,帮助用户快速找到所需的频道。
  • 接下来,将以下样式添加到“globals.css”中,以修改“ChannelList”的默认样式:

    ...
    @layer components {
      #sidebar
        .str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react {
        background: none;
        border: none;
      }
    
      #sidebar
        .str-chat.messaging.light.str-chat__channel-list.str-chat__channel-list-react
        > div {
        padding: 0;
      }
    }

    创建工作区 API 路由

    为了获取工作区数据,我们需要一个返回工作区信息的 API 路由。

    在 `/api/workspaces/[workspaceId]` 目录中创建一个 `route.ts` 文件并添加以下代码:

    import { NextResponse } from 'next/server';
    import { auth } from '@clerk/nextjs/server';
    
    import prisma from '@/lib/prisma';
    
    export async function GET(
      _: Request,
      { params }: { params: Promise<{ workspaceId: string }> }
    ) {
      const { userId } = await auth();
    
      if (!userId) {
        return NextResponse.json(
          { error: 'Authentication required' },
          { status: 401 }
        );
      }
    
      const workspaceId = (await params).workspaceId;
    
      if (!workspaceId || Array.isArray(workspaceId)) {
        return NextResponse.json(
          { error: 'Invalid workspace ID' },
          { status: 400 }
        );
      }
    
      try {
        // Check if the user is a member of the workspace
        const membership = await prisma.membership.findUnique({
          where: {
            userId_workspaceId: {
              userId,
              workspaceId,
            },
          },
        });
    
        if (!membership) {
          return NextResponse.json({ error: 'Access denied' }, { status: 403 });
        }
    
        // Fetch the workspace along with related data
        const workspace = await prisma.workspace.findUnique({
          where: { id: workspaceId },
          include: {
            channels: true,
            memberships: true,
            invitations: {
              where: { acceptedAt: null },
            },
          },
        });
    
        if (!workspace) {
          return NextResponse.json(
            { error: 'Workspace not found' },
            { status: 404 }
          );
        }
    
        // Fetch the other workspaces the user is a member of excluding the current workspace
        const otherWorkspaces = await prisma.workspace.findMany({
          where: {
            memberships: {
              some: {
                userId,
                workspaceId: { not: workspaceId },
              },
            },
          },
          include: {
            channels: true,
            memberships: true,
            invitations: {
              where: { acceptedAt: null },
            },
          },
        });
    
        return NextResponse.json({ workspace, otherWorkspaces }, { status: 200 });
      } catch (error) {
        console.error('Error fetching workspace:', error);
        return NextResponse.json(
          { error: 'Internal server error' },
          { status: 500 }
        );
      } finally {
        await prisma.$disconnect();
      }
    }

    此路由处理 `GET` 请求,以根据 URL 中提供的工作区 ID 获取工作区数据:

  • 身份验证:路由首先通过验证用户会话来检查用户是否已经通过身份验证。
  • 成员资格验证:在返回数据之前,它还会检查用户是否是所请求工作区的成员。
  • 数据检索:如果用户获得授权,路线将从数据库中检索工作区、频道和会员数据以及任何待处理的邀请。
  • 构建频道页面

    频道页面将显示特定工作区中的当前频道。它使用多个钩子和上下文来确保所有频道信息均已正确加载和显示。

    创建一个 `/client/[workspaceId]/[channelId]/` 目录,以及一个 `page.tsx` 文件并添加以下代码:

    'use client';
    import { useContext, useEffect, useRef, useState } from 'react';
    import { useRouter } from 'next/navigation';
    import { useUser } from '@clerk/nextjs';
    import { Channel as ChannelType } from 'stream-chat';
    import { DefaultStreamChatGenerics } from 'stream-chat-react';
    import { StreamCall, useCalls } from '@stream-io/video-react-sdk';
    import clsx from 'clsx';
    
    import { AppContext } from '../../layout';
    import CaretDown from '@/components/icons/CaretDown';
    import Files from '@/components/icons/Files';
    import Hash from '@/components/icons/Hash';
    import Headphones from '@/components/icons/Headphones';
    import Message from '@/components/icons/Message';
    import MoreVert from '@/components/icons/MoreVert';
    import Pin from '@/components/icons/Pin';
    import Plus from '@/components/icons/Plus';
    import User from '@/components/icons/User';
    
    interface ChannelProps {
      params: {
        workspaceId: string;
        channelId: string;
      };
    }
    
    const Channel = ({ params }: ChannelProps) => {
      const { workspaceId, channelId } = params;
      const router = useRouter();
      const { user } = useUser();
      const [currentCall] = useCalls();
      const {
        chatClient,
        loading,
        setLoading,
        workspace,
        setWorkspace,
        setOtherWorkspaces,
        channel,
        setChannel,
        channelCall,
        setChannelCall,
        videoClient,
      } = useContext(AppContext);
    
      const [chatChannel, setChatChannel] =
        useState>();
      const [channelLoading, setChannelLoading] = useState(true);
      const [pageWidth, setPageWidth] = useState(0);
      const layoutRef = useRef(null);
    
      useEffect(() => {
        if (loading || !layoutRef.current) return;
        const resizeObserver = new ResizeObserver((entries) => {
          for (const entry of entries) {
            setPageWidth(entry.contentRect.width);
          }
        });
        resizeObserver.observe(layoutRef.current);
    
        return () => {
          resizeObserver.disconnect();
        };
      }, [layoutRef, loading]);
    
      useEffect(() => {
        const loadWorkspace = async () => {
          try {
            const response = await fetch(`/api/workspaces/${workspaceId}`);
            const result = await response.json();
            if (response.ok) {
              setWorkspace(result.workspace);
              setOtherWorkspaces(result.otherWorkspaces);
              localStorage.setItem(
                'activitySession',
                JSON.stringify({ workspaceId, channelId })
              );
              setLoading(false);
            } else {
              console.error('Error fetching workspace data:', result.error);
              router.push('/');
            }
          } catch (error) {
            console.error('Error fetching workspace data:', error);
            router.push('/');
          }
        };
    
        const loadChannel = async () => {
          const currentMembers = workspace.memberships.map((m) => m.userId);
          const chatChannel = chatClient.channel('messaging', channelId, {
            members: currentMembers,
            name: channel.name,
            description: channel.description,
            workspaceId: channel.workspaceId,
          });
    
          await chatChannel.create();
    
          if (currentCall?.id === channelId) {
            setChannelCall(currentCall);
          } else {
            const channelCall = videoClient?.call('default', channelId);
            setChannelCall(channelCall);
          }
    
          setChatChannel(chatChannel);
          setChannelLoading(false);
        };
    
        const loadWorkspaceAndChannel = async () => {
          if (!workspace) {
            await loadWorkspace();
          } else {
            if (!channel)
              setChannel(workspace.channels.find((c) => c.id === channelId)!);
            if (loading) setLoading(false);
            if (chatClient && channel) loadChannel();
          }
        };
    
        if ((!chatChannel || chatChannel?.id !== channelId) && user)
          loadWorkspaceAndChannel();
      }, [
        channel,
        channelId,
        chatChannel,
        chatClient,
        currentCall,
        loading,
        router,
        setChannel,
        setChannelCall,
        setLoading,
        setOtherWorkspaces,
        setWorkspace,
        user,
        videoClient,
        workspace,
        workspaceId,
      ]);
    
      useEffect(() => {
        if (currentCall?.id === channelId) {
          setChannelCall(currentCall);
        }
      }, [currentCall, channelId, setChannelCall]);
    
      if (loading) return null;
    
      return (
        
    {/* Toolbar */}
    0 && pageWidth < 500 ? 'hidden' : 'flex' )} > {channel?.description}
    {/* Tab Bar */}
    Messages
    Files
    Pins
    {/* Chat */}
    {/* Body */}
    0 ? pageWidth : '100%', }} className="relative" >
    {/* Messages */}
    Hello World!
    {/* Footer */}
    ); }; export default Channel;

    让我们详细分析一下:

  • 布局管理:组件通过layoutRef和ResizeObserver进行布局管理,根据频道版块的宽度动态调整页面布局。
  • 通道加载:组件首先检查工作区和通道信息是否可用,如果不可用,则进行API调用来加载数据。
  • 存储活动会话:加载工作区数据后,我们将活动会话存储在 localStorage 中。此会话包含workspaceId 和 channelId,以记住用户最后活动的工作区和频道。
  • 聊天和视频客户端:我们初始化聊天和视频客户端,以允许频道内的实时消息和通话功能。
  • 工具栏和页脚:工具栏显示有关当前频道的详细信息,例如其名称和说明,而页脚包含用于发送消息的输入区域。
  • Workspace layout demo

    设置客户端页面

    客户端组件是一个实用页面,可将用户重定向到他们上一个活动的工作区和频道。它通过检查存储在“localStorage”中的“activitySession”来实现这一点。如果没有找到活动会话,则将用户重定向到主页。

    在 `/app/client` 目录中创建一个 `page.tsx` 文件,内容如下:

    'use client';
    import { useRouter } from 'next/navigation';
    import { useEffect } from 'react';
    
    export default function Client() {
      const router = useRouter();
    
      useEffect(() => {
        const fetchActivitySession = async () => {
          const activitySession = localStorage.getItem('activitySession');
          if (activitySession) {
            const { workspaceId, channelId } = await JSON.parse(activitySession);
            router.push(`/client/${workspaceId}/${channelId}`);
          } else {
            router.push('/');
          }
        };
    
        fetchActivitySession();
      }, [router]);
    
      return null;
    }

    构建工作区页面

    最后,我们将创建第二个实用程序页面,它将处理将用户重定向到工作区内的频道的逻辑。

    在 `/client/[workspaceId]` 目录中创建一个 `page.tsx` 文件:

    'use client';
    import { useContext, useEffect } from 'react';
    import { useRouter } from 'next/navigation';
    
    import { AppContext, Workspace } from '../layout';
    
    interface WorkspacePageProps {
      params: {
        workspaceId: string;
      };
    }
    
    export default function WorkspacePage({ params }: WorkspacePageProps) {
      const { workspaceId } = params;
      const { workspace, setWorkspace, setOtherWorkspaces } =
        useContext(AppContext);
      const router = useRouter();
    
      useEffect(() => {
        const goToChannel = (workspace: Workspace) => {
          const channelId = workspace.channels[0].id;
          localStorage.setItem(
            'activitySession',
            JSON.stringify({ workspaceId: workspace.id, channelId })
          );
          router.push(`/client/${workspace.id}/${channelId}`);
        };
    
        const loadWorkspace = async () => {
          try {
            const response = await fetch(`/api/workspaces/${workspaceId}`);
            const result = await response.json();
            if (response.ok) {
              setWorkspace(result.workspace);
              setOtherWorkspaces(result.otherWorkspaces);
              goToChannel(result.workspace);
            } else {
              console.error('Error fetching workspace data:', result.error);
            }
          } catch (error) {
            console.error('Error fetching workspace data:', error);
          }
        };
    
        if (!workspace) {
          loadWorkspace();
        } else {
          goToChannel(workspace);
        }
      }, [workspace, workspaceId, setWorkspace, setOtherWorkspaces, router]);
    
      return null;
    }

    在上面的代码中,如果工作区数据尚未加载,我们将从 `/api/workspaces/[workspaceId]` 路由获取它,并将用户导航到该工作区中第一个可用频道。

    Client page demo

    这样,我们现在为 Slack 克隆奠定了坚实的基础!

    结论

    在构建 Slack 克隆的第一部分中,我们:

  • 设置项目,包括工作区创建、频道管理以及与 Stream 和 Clerk 的集成。
  • 创建用于管理工作区和频道的 API 路由。
  • 构建了在工作区和频道之间导航的基本组件。
  • 在下一部分中,我们将重点介绍如何实现实时消息传递和管理渠道。

    敬请关注!