DevGang
Авторизоваться

Создание приложений Next.js в режиме реального времени с помощью WebSockets и Soketi

В настоящее время все чаще встречаются компоненты веб-приложений с функциями, которые обновляются в режиме реального времени. И одна из вещей, которую ожидают пользователи, заключается в том, что интеграция этих функций осуществляется бесшовным динамическим способом, выходящим за рамки обычной модели "запрос-ответ" со стратегиями извлечения данных.

В сегодняшней статье мы сосредоточимся на одном из моих любимых трио: мы будем использовать фреймворк Next.js для создания веб-приложения, Drizzle для определения схемы базы данных и взаимодействия с ней и, возможно, самый важный компонент этой статьи - Soketi.

Если вы никогда не слышали о Soketi, то вкратце расскажу, что это сервер WebSocket, который был построен на базе uWebSockets.js и имеет отличную совместимость с протоколом Pusher.

Приложение, которое мы будем разрабатывать сегодня, - это чат, в котором мы начнем с определения схемы базы данных, создадим логин пользователя, сможем создавать чаты, приглашать в них других пользователей и обновлять список сообщений в реальном времени с помощью WebSockets.

Среда разработки

Начнем с генерации базового шаблона:

npx create-next-app@latest chat-app --typescript --tailwind --app --eslint --use-npm

Принимая во внимание предыдущую команду, мы видим, что будет создана папка chat-app/, которая будет содержать инициализированную конфигурацию ESLint и Tailwindd CSS, с включенным TypeScript, и мы будем использовать NPM для установки зависимостей (не забыв упомянуть, что будет использоваться App Router).

Для быстрой итерации пользовательского интерфейса мы будем использовать библиотеку NextUI, однако пошаговые инструкции по ее настройке показаны не будут, поэтому я рекомендую следовать этому руководству.

Схема базы данных

Сначала мы установим необходимые зависимости:

npm install drizzle-orm better-sqlite3
npm install --dev drizzle-kit @types/better-sqlite3

Теперь мы определим схему базы данных, которая будет состоять из 4 таблиц. Начиная с таблицы users, у нас будет только два столбца - id и username (имя пользователя), и мы должны убедиться, что они уникальны.

// db/schema.ts
import { relations } from "drizzle-orm";
import { sqliteTable, integer, text, unique } from "drizzle-orm/sqlite-core";

/**
 * Table Definitions
 */

export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  username: text("username").unique().notNull(),
});

// ...

Далее мы определим таблицу conversations, в которой также будет всего два столбца, а именно id и name, последний соответствует названию чата/разговора.

// db/schema.ts

// ...

export const conversations = sqliteTable("conversations", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
});

// ...

Переходя к определению таблицы сообщений, мы определим 4 столбца, а именно: id, body, который соответствует содержанию сообщения, и два внешних ключа conversationId и senderId, с помощью которых мы позже установим связь между таблицей бесед и пользователями.

// db/schema.ts

// ...

export const messages = sqliteTable("messages", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  body: text("body").notNull(),
  conversationId: integer("conversation_id")
    .notNull()
    .references(() => conversations.id),
  senderId: integer("sender_id")
    .notNull()
    .references(() => users.id),
});

// ...

Очень важная таблица, которую необходимо определить, - это участники каждой беседы. Каждая строка таблицы будет соответствовать записи, которая должна иметь уникальное ограничение между столбцами conversationId и userId.

// db/schema.ts

// ...

export const participants = sqliteTable(
  "participants",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    conversationId: integer("conversation_id")
      .notNull()
      .references(() => conversations.id),
    userId: integer("user_id")
      .notNull()
      .references(() => users.id),
  },
  (table) => ({
    participantUniqueConstraint: unique("participant_unique_constraint").on(
      table.conversationId,
      table.userId
    ),
  })
);

// ...

Определив схему таблицы, мы можем приступить к формированию отношений между различными таблицами.

// db/schema.ts

// ...

/**
 * Table Relationships
 */

export const userRelations = relations(users, ({ many }) => ({
  messages: many(messages),
  participants: many(participants),
}));

export const conversationRelations = relations(conversations, ({ many }) => ({
  messages: many(messages),
  participants: many(participants),
}));

export const messageRelations = relations(messages, ({ one }) => ({
  conversation: one(conversations, {
    fields: [messages.conversationId],
    references: [conversations.id],
  }),
  sender: one(users, {
    fields: [messages.senderId],
    references: [users.id],
  }),
}));

export const participantRelations = relations(participants, ({ one }) => ({
  conversation: one(conversations, {
    fields: [participants.conversationId],
    references: [conversations.id],
  }),
  user: one(users, {
    fields: [participants.userId],
    references: [users.id],
  }),
}));

Когда таблицы и столбцы базы данных определены, равно как и ограничения и отношения между ними, мы можем перейти к следующему шагу, который заключается в создании соединения с базой данных, а также в определении клиента базы данных с выводом типов данных из схем, которые мы только что определили в db/schema.ts.

// db/client.ts
import {
  drizzle,
  type BetterSQLite3Database,
} from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";

import * as schema from "./schema";

const sqlite = new Database("local.db");

export const db: BetterSQLite3Database<typeof schema> = drizzle(sqlite, {
  schema,
});

Настройка Soketi

В этом шаге мы приступим к установке Soketi на наше устройство, для этого у нас есть два популярных решения. Либо мы устанавливаем CLI, либо запускаем его в Docker-контейнере. Я рекомендую использовать Docker, и для этого я расскажу вам следующее:

  1. Руководство по установке CLI
  2. Руководство по установке Docker

После выполнения одного из этих руководств и корректной работы Soketi на вашем компьютере, мы можем продолжить эту статью и установить следующие зависимости в проект:

npm install pusher pusher-js

Установив все вышеперечисленные зависимости, мы можем создать папку soketi/ со следующим файлом:

// soketi/index.ts
import PusherServer from "pusher";
import PusherClient from "pusher-js";

export const pusherServer = new PusherServer({
  appId: "app-id",
  key: "app-key",
  secret: "app-secret",
  cluster: "",
  useTLS: false,
  host: "127.0.0.1",
  port: "6001",
});

export const pusherClient = new PusherClient("app-key", {
  cluster: "",
  httpHost: "127.0.0.1",
  httpPort: 6001,
  wsHost: "127.0.0.1",
  wsPort: 6001,
  wssPort: 6001,
  forceTLS: false,
  enabledTransports: ["ws", "wss"],
  authTransport: "ajax",
  authEndpoint: "/api/pusher-auth",
  auth: {
    headers: {
      "Content-Type": "application/json",
    },
  },
});

В приведенном выше коде были созданы два клиента Pusher, один из которых будет использоваться на стороне сервера, а другой - только на стороне клиента.

Конфигурация клиента, который будет использоваться на стороне сервера, имеет только некоторые базовые настройки, такие как адрес, порт и некоторые значения по умолчанию, которые присутствуют на этой странице документации.

В то время как та, что будет использоваться на стороне клиента, следует некоторым определениям, которые были взяты отсюда, с некоторыми изменениями, касающимися конечных точек аутентификации и авторизации для использования каналов протокола Pusher.

Давайте строить!

API-маршруты

В сегодняшней статье нам нужно будет определить API Route для проверки того, авторизован ли пользователь для потребления каналов Pusher. Я рекомендую прочитать эту страницу документации, чтобы получить более подробную информацию по этому вопросу, так как у нас будет очень простая авторизация.

// app/api/pusher-auth/route.ts
import { pusherServer } from "@/soketi";

export async function POST(req: Request) {
  const data = await req.text();
  const [socketId, channelName] = data
    .split("&")
    .map((str) => str.split("=")[1]);

  const authResponse = pusherServer.authorizeChannel(socketId, channelName);

  return new Response(JSON.stringify(authResponse));
}

Страница авторизации

Для создания сегодняшней страницы входа в систему я решил использовать библиотеку React Hook Form для валидации формы вместе с библиотекой valibot для определения схемы валидации, с помощью которой мы будем контролировать структуру данных и типы данных во время выполнения формы перед ее отправкой.

В форме будет только один управляемый вход, соответствующий параметру username (имени пользователя), и если после отправки формы оно не будет уникальным, будет выведено сообщение об ошибке.

// app/page.tsx
"use client";
import { useCallback } from "react";
import { Button, Input } from "@nextui-org/react";
import { minLength, object, string, Input as Infer } from "valibot";
import { Controller, SubmitHandler, useForm } from "react-hook-form";
import { valibotResolver } from "@hookform/resolvers/valibot";

import { addUserAction } from "@/services";

const schema = object({
  username: string([minLength(3)]),
});

export type FormValues = Infer<typeof schema>;

export default function Index() {
  const { handleSubmit, control, setError } = useForm<FormValues>({
    defaultValues: {
      username: "",
    },
    mode: "onSubmit",
    resolver: valibotResolver(schema),
  });

  const onSubmit: SubmitHandler<FormValues> = useCallback(
    async (data) => {
      try {
        await addUserAction(data);
      } catch (cause) {
        if (cause instanceof Error) {
          setError("username", { message: cause.message });
        }
      }
    },
    [setError]
  );

  return (
    <div className="h-screen w-screen flex items-center justify-center">
      <div className="flex flex-col items-center space-y-4 w-1/5">
        <Controller
          name="username"
          control={control}
          render={({ field, formState }) => (
            <Input
              {...field}
              label="Username"
              isInvalid={!!formState.errors.username?.message}
              errorMessage={formState.errors.username?.message}
            />
          )}
        />
        <Button
          type="button"
          onClick={handleSubmit(onSubmit)}
          color="primary"
          variant="shadow"
          fullWidth
        >
          Join
        </Button>
      </div>
    </div>
  );
}

Далее нам нужно будет создать папку services/, в которой будет находиться файл index.ts, содержащий набор функций сервера. Но сейчас давайте просто создадим функцию addUserAction.

// services/index.ts
"use server";
import { redirect } from "next/navigation";

import type { FormValues as IAddUser } from "@/app/page";
import { db } from "../db/client";
import { users } from "../db/schema";

export async function addUserAction(data: IAddUser) {
  let userId: number | undefined;

  const user = await db.query.users.findFirst({
    where: (user, { eq }) => eq(user.username, data.username),
  });

  userId = user?.id;

  if (!userId) {
    const result = await db.insert(users).values(data);
    const rowId = result.lastInsertRowid;
    if (rowId < 1 || typeof rowId !== "number") {
      throw Error("An error has occurred.");
    } else {
      userId = rowId;
    }
  }

  redirect(`/conversations/${userId}`);
}

// ...

Учитывая предыдущую функцию, мы вставляем нового пользователя в базу данных, только если имя пользователя уникально и вставка прошла успешно, происходит перенаправление на страницы бесед, в противном случае выдается ошибка.

Страница диалогов

Следующая страница, которую необходимо создать, связана с беседами. Первый компонент, который мы можем создать, будет отвечать за создание новой беседы, которая в данном случае является просто кнопкой, вызывающей действие сервера, как показано ниже:

// components/CreateConversation.tsx
"use client";
import { useCallback, type FC, type MouseEventHandler } from "react";
import { Button } from "@nextui-org/react";

import { bootstrapNewConversationAction } from "@/services";

interface Props {
  userId: number;
}

export const CreateConversation: FC<Props> = ({ userId }) => {
  const onClickHandler: MouseEventHandler<HTMLButtonElement> = useCallback(
    async (evt) => {
      evt.stopPropagation();
      try {
        await bootstrapNewConversationAction(userId);
      } catch (cause) {
        console.error(cause);
      }
    },
    [userId]
  );

  return (
    <Button
      type="button"
      color="primary"
      variant="flat"
      fullWidth
      onClick={onClickHandler}
    >
      New Conversation
    </Button>
  );
};

Учитывая схему базы данных, нам нужно будет дать уникальное имя чату, чтобы уменьшить сложность, мы сгенерируем уникальный идентификатор, установив следующую зависимость:

npm install nanoid

Теперь нам нужно создать функцию bootstrapNewConversationAction в папке services/, которая может выглядеть следующим образом:

// services/index.ts
"use server";
import { redirect } from "next/navigation";
import { nanoid } from "nanoid";

import type { FormValues as IAddUser } from "@/app/page";
import { db } from "../db/client";
import { conversations, participants, users } from "../db/schema";

// ...

export async function bootstrapNewConversationAction(userId: number) {
  const conversation = await db
    .insert(conversations)
    .values({ name: nanoid() });

  const conversationId = conversation.lastInsertRowid;

  if (typeof conversationId !== "number") {
    throw new Error("An error has occurred.");
  }

  const result = await db
    .insert(participants)
    .values({ userId, conversationId });

  if (result.changes < 1) {
    throw new Error("An error has occurred.");
  }

  redirect(`/conversations/${userId}/${conversationId}`);
}

Таким образом, мы можем определить макет страницы (Layout), который будет отвечать за отображение компонента <CreateConversation /> и остального содержимого маршрута, включая дочерние элементы (которые будут являться содержимым страниц).

Однако на этом мы не остановимся, поскольку нам нужно будет отобразить набор элементов, соответствующих списку бесед, участником которых является пользователь.

// app/conversations/[userId]/layout.tsx
import type { PropsWithChildren } from "react";
import Link from "next/link";

import { CreateConversation } from "@/components/CreateConversation";
import { db } from "@/db/client";

interface Props {
  params: {
    userId: string;
  };
}

export default async function Layout({
  params,
  children,
}: PropsWithChildren<Props>) {
  const userId = Number(params.userId);

  if (!params || !params.userId || isNaN(userId)) {
    throw Error("The user identifier must be provided.");
  }

  const result = await db.query.users.findFirst({
    where: (user, { eq }) => eq(user.id, userId),
    with: {
      participants: {
        with: {
          conversation: true,
        },
      },
    },
  });

  return (
    <div className="h-screen w-screen flex">
      <div className="w-2/6 h-full flex flex-col items-center justify-center space-y-4 border-r-1.5">
        <div className="w-[90%]">
          <CreateConversation userId={userId} />
        </div>

        <div className="bg-white w-[90%] h-5/6 p-2 flex flex-col space-y-4">
          {result?.participants.map((item) => (
            <Link
              key={item.conversationId}
              href={`/conversations/${userId}/${item.conversationId}`}
              className="h-16 w-full flex items-center justify-start border-1.5"
            >
              <span className="mx-4 truncate">
                {item.conversation.name}
              </span>
            </Link>
          ))}
        </div>
      </div>
      <div className="w-4/6 h-full">{children}</div>
    </div>
  );
}

Следующий шаг довольно прост, поскольку теоретически нам нужен корневой маршрут для разговоров, который в данном случае будет соответствовать состоянию бездействия, когда мы еще не выбрали ни одного разговора. Это может выглядеть примерно так:

// app/conversations/[userId]/page.tsx
export default async function Page() {
  return (
    <div className="h-full w-full flex flex-col items-center justify-center space-y-1">
      <span className="text-lg leading-relaxed">Select a conversation</span>
      <small className="text-gray-400 leading-relaxed">
        or create a new one
      </small>
    </div>
  );
}

Детали бесед

Страница "Детали бесед" - это маршрут, который будет отображать список сообщений и набор действий с учетом выбранной беседы. Это будет вложенный маршрут, который будет обмениваться содержимым с корневой страницей, которую мы определили ранее, и он будет обладать набором функций.

Начиная с самого простого, мы создадим компонент, который позволит делиться ссылкой на беседу, чтобы другие пользователи могли к ней присоединиться.

// components/ConversationInviteAction.tsx
"use client";
import { Button } from "@nextui-org/react";

interface Props {
  chatId: number;
}

export function ConversationInviteAction({ chatId }: Props) {
  return (
    <Button
      color="primary"
      variant="flat"
      onClick={(evt) => {
        evt.stopPropagation();
        navigator.clipboard.writeText(`http://localhost:3000/invite/${chatId}`);
      }}
    >
      Invite Link
    </Button>
  );
}

В приведенном выше компоненте мы создаем всего одну кнопку, в которой при распространении события onClick копируем в буфер обмена URL с уникальным идентификатором конверсии.

Следующий компонент, который необходимо создать, связан с отправкой нового сообщения в беседе. Этот компонент будет содержать управляемый ввод и кнопку, которая будет вызывать серверное действие, вставляющее новое сообщение в базу данных и вызывающее событие Pusher.

// components/ConversationTextField.tsx
"use client";
import { useCallback } from "react";
import { Button, Input } from "@nextui-org/react";
import { minLength, object, string, number, Input as Infer } from "valibot";
import { Controller, SubmitHandler, useForm } from "react-hook-form";
import { valibotResolver } from "@hookform/resolvers/valibot";

import { sendMessageAction } from "@/services";

const schema = object({
  body: string([minLength(1)]),
  chatId: number(),
  userId: number(),
});

export type TextFieldFormValues = Infer<typeof schema>;

type Props = Pick<TextFieldFormValues, "chatId" | "userId">;

export function ConversationTextField({ chatId, userId }: Props) {
  const { handleSubmit, control, setError, reset } =
    useForm<TextFieldFormValues>({
      defaultValues: {
        body: "",
        chatId: Number(chatId),
        userId: Number(userId),
      },
      mode: "onSubmit",
      resolver: valibotResolver(schema),
    });

  const onSubmit: SubmitHandler<TextFieldFormValues> = useCallback(
    async (data) => {
      try {
        await sendMessageAction(data);
        reset();
      } catch (cause) {
        if (cause instanceof Error) {
          setError("body", { message: cause.message });
        }
      }
    },
    [reset, setError]
  );

  return (
    <div className="flex flex-row items-center justify-center space-x-4 h-full w-full">
      <div className="w-4/6">
        <Controller
          name="body"
          control={control}
          render={({ field, formState }) => (
            <Input
              {...field}
              isInvalid={!!formState.errors.body?.message}
              errorMessage={formState.errors.body?.message}
              placeholder="Type your message..."
            />
          )}
        />
      </div>
      <Button
        type="button"
        onClick={handleSubmit(onSubmit)}
        color="primary"
        variant="shadow"
      >
        Send
      </Button>
    </div>
  );
}

Продолжая работу с предыдущим компонентом, нам нужно создать действие сервера под названием sendMessageAction в папке services/, как показано ниже:

// services/index.ts
"use server";
import { redirect } from "next/navigation";
import { nanoid } from "nanoid";

import type { FormValues as IAddUser } from "@/app/page";
import { db } from "../db/client";
import { conversations, messages, participants, users } from "../db/schema";
import { TextFieldFormValues } from "@/components/ConversationTextField";
import { pusherServer } from "@/soketi";

// ...

export async function sendMessageAction(data: TextFieldFormValues) {
  const result = await db
    .insert(messages)
    .values({
      conversationId: data.chatId,
      senderId: data.userId,
      body: data.body,
    })
    .returning();

  for await (const item of result) {
    await pusherServer.trigger(
      data.chatId.toString(),
      "evt::new-message",
      item
    );
  }
}

Поскольку мы можем отправить новое сообщение, нам нужно перечислить все сообщения, которые у нас есть в разговоре. Нужно учесть, что список всех сообщений должен быть составлен до установления WebSocket-соединения с Soketi, чтобы каждое новое отправленное сообщение добавлялось в реальном времени постепенно.

Для этого мы сначала установим следующие зависимости:

npm install clsx

Затем мы создаём следующий компонент:

// components/MessageList.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { clsx } from "clsx";

import { pusherClient } from "@/soketi";

type Message = {
  body: string;
  id: number;
  conversationId: number;
  senderId: number;
};

interface Props {
  initialMessages: Array<Message>;
  userId: number;
  chatId: number;
}

export function MessageList({ initialMessages, userId, chatId }: Props) {
  const lastMessageRef = useRef<HTMLDivElement>(null);
  const [items, setItems] = useState<Array<Message>>(() => [
    ...initialMessages,
  ]);

  useEffect(() => {
    lastMessageRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [items]);

  useEffect(() => {
    const channel = pusherClient
      .subscribe(chatId.toString())
      .bind("evt::new-message", (datum: Message) =>
        setItems((state) => [...state, datum])
      );

    return () => {
      channel.unbind();
    };
  }, []);

  return (
    <>
      {items.length > 0 ? (
        <div className="h-full w-full flex flex-col overflow-y-auto">
          {items.map((item, index) => (
            <div
              key={item.id}
              className={clsx([
                "mt-1.5 flex",
                item.senderId === userId ? "justify-end" : "justify-start",
                items.length - 1 === index && "mb-3",
              ])}
            >
              <div
                className={clsx([
                  "max-w-md rounded-xl p-3",
                  item.senderId === userId
                    ? "bg-blue-500 text-white rounded-br-none mr-4"
                    : "bg-gray-300 text-gray-800 rounded-bl-none ml-4",
                ])}
              >
                {item.body}
              </div>
            </div>
          ))}
          <div ref={lastMessageRef} />
        </div>
      ) : (
        <div className="h-full w-full flex flex-col items-center justify-center space-y-1">
          <span className="text-lg leading-relaxed text-gray-500">
            Be the first one to send the first text
          </span>
          <small className="text-gray-400 leading-relaxed">or just wait</small>
        </div>
      )}
    </>
  );
}

Учитывая приведенный выше код, мы получаем в реквизитах компонента начальные сообщения, которые в идеале должны быть теми, которые мы получаем из базы данных для начальной гидратации компонента. Чтобы избежать несоответствия, была сделана их неглубокая копия перед использованием в качестве начального состояния useState.

Затем мы используем useEffect вместе с клиентом Pusher для установления WebSocket-соединения с Soketi, который будет отправлять каждое сообщение в реальном времени с учетом идентификатора чата и события новых сообщений, на которые мы подписаны.

Каждое сообщение, полученное клиентом, будет добавлено в список сообщений, хранящихся в локальном состоянии компонента. Как только компонент будет размонтирован, мы закроем соединение с Soketi.

Теперь, когда у нас есть все необходимые компоненты, мы можем приступить к созданию страницы с информацией о разговоре.

// app/conversations/[userId]/[chatId]/page.tsx
import { ConversationInviteAction } from "@/components/ConversationInviteAction";
import { ConversationTextField } from "@/components/ConversationTextField";
import { MessageList } from "@/components/MessageList";
import { db } from "@/db/client";

interface Props {
  params: {
    userId: string;
    chatId: string;
  };
}

export default async function Page({ params }: Props) {
  const chatId = Number(params.chatId);
  const userId = Number(params.userId);

  if (!params || !params.userId || !params.chatId || isNaN(chatId)) {
    throw Error("The conversation and user identifiers must be provided.");
  }

  const result = await db.query.messages.findMany({
    where: (message, { eq }) => eq(message.conversationId, chatId),
  });

  return (
    <div className="h-full flex flex-col justify-end items-end">
      <div className="w-full h-24 flex items-center justify-end border-b-1.5">
        <div className="mx-4">
          <ConversationInviteAction chatId={chatId} />
        </div>
      </div>

      <MessageList initialMessages={result} userId={userId} chatId={chatId} />

      <div className="w-full h-24 border-t-1.5">
        <ConversationTextField chatId={chatId} userId={userId} />
      </div>
    </div>
  );
}

Страница приглашений

И последнее, но не менее важное: осталось создать страницу, которая будет проверять, может ли пользователь присоединиться к беседе по предоставленной ему ссылке.

На этой странице у нас будет форма, единственным вводом которой будет заполнение имени пользователя, если оно уже присутствует в беседе, то вы получите ошибку. В противном случае вы будете перенаправлены в беседу, в которую вас пригласили.

// app/invite/[chatId]/page.tsx
"use client";
import { useCallback } from "react";
import { Button, Input } from "@nextui-org/react";
import { minLength, object, string, number, Input as Infer } from "valibot";
import { Controller, SubmitHandler, useForm } from "react-hook-form";
import { valibotResolver } from "@hookform/resolvers/valibot";

import { joinConversationAction } from "@/services";

const schema = object({
  username: string([minLength(3)]),
  chatId: number(),
});

export type InvitationFormValues = Infer<typeof schema>;

interface Props {
  params: {
    chatId: string;
  };
}

export default function Index({ params }: Props) {
  const { handleSubmit, control, setError } = useForm<InvitationFormValues>({
    defaultValues: {
      username: "",
      chatId: Number(params.chatId),
    },
    mode: "onSubmit",
    resolver: valibotResolver(schema),
  });

  const onSubmit: SubmitHandler<InvitationFormValues> = useCallback(
    async (data) => {
      try {
        await joinConversationAction(data);
      } catch (cause) {
        if (cause instanceof Error) {
          setError("username", { message: cause.message });
        }
      }
    },
    [setError]
  );

  return (
    <div className="h-screen w-screen flex items-center justify-center">
      <div className="flex flex-col items-center space-y-4 w-1/5">
        <h3 className="text-lg leading-relaxed">Invitation link</h3>
        <Controller
          name="username"
          control={control}
          render={({ field, formState }) => (
            <Input
              {...field}
              label="Username"
              isInvalid={!!formState.errors.username?.message}
              errorMessage={formState.errors.username?.message}
            />
          )}
        />
        <Button
          type="button"
          onClick={handleSubmit(onSubmit)}
          color="primary"
          variant="shadow"
          fullWidth
        >
          Join
        </Button>
      </div>
    </div>
  );
}

Как обычно, нам нужно создать серверное действие joinConversationAction в папке services/.

// services/index.ts
"use server";
import { redirect } from "next/navigation";
import { nanoid } from "nanoid";

import type { FormValues as IAddUser } from "@/app/page";
import { db } from "../db/client";
import { conversations, messages, participants, users } from "../db/schema";
import type { InvitationFormValues } from "@/app/invite/[chatId]/page";
import { TextFieldFormValues } from "@/components/ConversationTextField";
import { pusherServer } from "@/soketi";

// ...

export async function joinConversationAction(data: InvitationFormValues) {
  const user = await db.query.users.findFirst({
    where: (user, { eq }) => eq(user.username, data.username),
  });

  const userId = user?.id;

  if (typeof userId !== "number") {
    throw new Error("An error has occurred.");
  }

  const result = await db
    .insert(participants)
    .values({ userId, conversationId: data.chatId })
    .onConflictDoNothing();

  if (result.changes < 1) {
    throw new Error("This username is already part of the chat");
  }

  redirect(`/conversations/${userId}/${data.chatId}`);
}

Заключение

Надеюсь, эта статья оказалась для вас полезной, независимо от того, используете ли вы информацию в существующем проекте или просто решили попробовать ради интереса.

Пожалуйста, дайте мне знать, если вы заметили какие-либо ошибки в статье, оставив комментарий. А если вы хотите посмотреть исходный код этой статьи, вы можете найти его в репозитории github по этой ссылке.

Источник:

#JavaScript #NodeJS #React
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу