使用 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 上访问完整的源代码。
让我们开始吧!
先决条件
在开始项目之前,请确保您已准备好以下内容:
项目设置
让我们从设置项目开始。我们将首先克隆一个包含初始设置代码和文件夹结构的入门模板,以帮助我们快速入门:
# 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
项目结构应如下所示:

该项目的组织方式是为了保持代码整洁,并且随着代码的增长易于管理:
设置数据库
要构建类似于 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 克隆中的主要关系。以下是每个模型的作用:
每个模型都有自己的细节和联系,使得我们在构建特征时可以轻松获取相关数据。
接下来,让我们设置数据库连接。导航到你的 `.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 帐户。转到 Clerk 注册页面,使用您的电子邮件或社交登录选项进行注册。
创建 Clerk 项目

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

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

接下来,我们将在注册时将名字和姓氏设为必填字段:
在你的项目中安装 Clerk
接下来,让我们将 Clerk 添加到你的 Next.js 项目中:
创建注册和登录页面
现在,我们需要使用 Clerk 的``和`` 组件。这些组件带有内置 UI 并处理所有身份验证逻辑。
添加页面的方法如下:

完成这些步骤后,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 (
在这里,我们渲染“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) => (
))}
);
};
export default WorkspaceList; 综合起来
现在我们已经创建了工作区仪表板的基本组件,是时候将它们组合在一起并制作主仪表板页面了。我们将使用“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 */}
Welcome back
{workspaces.length > 0 ? (
) : (
You are not a member of any workspaces yet.
)}
{/* Create new workspace */}
{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?
);
}以下是代码每个部分的作用:

创建工作区
构建创建工作区 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 交互的用户界面。
在 `/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
);
};
export default GetStarted; 在上面的代码中:

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

通过遵循这些步骤,您可以确认工作区创建流程是否正确运行。
在应用程序中设置流
什么是 Stream?
Stream 是一个平台,允许开发人员为其应用程序添加丰富的聊天和视频功能。Stream 提供 API 和 SDK 来帮助您快速轻松地添加聊天和视频功能,而无需从头开始创建聊天和视频功能。
在这个项目中,我们将使用 Stream 的 React SDK for Video 和 React Chat SDK 在我们的 Slack 克隆中构建聊天和视频通话功能。
创建您的 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.

* Click **"Complete Signup"** to continue.您现在将被重定向到您的 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.
配置用户权限
为了允许用户发送消息、阅读频道和执行其他操作,您需要在 Stream 仪表板中设置必要的权限:

* Create Message * Read Channel * Read Channel Members * Create Reaction * Upload Attachments * Create Attachments
安装 Stream SDK
要开始在 Next.js 项目中使用 Stream,我们需要安装一些 SDK:
将 Clerk 与 Stream 应用同步
为了确保 Clerk 和 Stream 之间的用户数据一致,您需要设置一个同步用户信息的 webhook:
* 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).
* **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.
* 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`.
完成这些步骤后,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; 这里发生了很多事情,让我们来分析一下:
添加 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”组件中,当用户点击工作区按钮时,我们有一个下拉菜单。此下拉菜单可让用户轻松在工作区之间切换或添加新工作区。
构建工作区布局组件
接下来,我们将创建在上一节中添加的“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` 组件为整个工作区提供了一致的结构。它包括:
添加频道预览组件
接下来,我们将创建“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;在上面的代码中:
添加侧边栏
`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 (

