发布于 2026-01-06 0 阅读
0

几分钟内即可在您的应用中构建类似 Figma 和 Google Docs 的实时呈现功能🚀🔥🧑‍💻 由 Mux 呈现的 DEV 全球展示挑战赛:展示您的项目!

几分钟内即可在您的应用中构建类似 Figma 和 Google Docs 的实时互动功能🚀🔥🧑‍💻

由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!

太长不看

了解在应用中实现协作功能所面临的挑战和解决方案。在本教程中,我们使用Velt构建了一个实时“谁在线?”的在线状态墙

功能包括:

  • 实时显示在线用户及其光标。
  • 用户身份验证和文档上下文管理。
  • 可自定义的用户界面,带有评论和退出登录选项。

本指南为创建引人入胜的协作工具奠定了基础。为了进一步增强其功能,您可以考虑添加诸如表情反应、通知和安全登录方式等功能。

开始吧🚀!


现代网络应用越来越注重协作。想想看,在 Figma 中看到色彩缤纷的光标移动,或者在 Google Docs 中看到个人资料气泡显示谁正在查看文档,是多么自然流畅。这些状态显示功能对于任何协作应用来说都至关重要。

你知道吗?当用户能够看到其他用户实时积极协作时,97% 的用户更有可能继续使用该产品。这背后的心理学原理非常有趣——我们天生就被那些能够看到其他人与我们并肩工作的空间所吸引,即使是在数字环境中也是如此。

用户实时协作

从零开始构建“在线状态”功能的挑战

构建在线状态功能乍看之下似乎很简单——不就是追踪谁在线吗?但许多开发者很快就会发现,这其实很复杂。以我最近参与的一个项目为例:我们最初使用简单的 WebSocket 连接来显示活跃用户,但当需要处理不稳定的连接和浏览器标签页时,情况就变得一团糟了。

我们先来看后端。你需要一个强大的系统来维护跨多个服务器实例的 WebSocket 连接。以下是使用 Redis 的常见模式:

// Server-side presence tracking
const presence = new Map();
redis.subscribe('presence', (channel, message) => {
   const { userId, status } = JSON.parse(message);
   presence.set(userId, status);
   broadcastPresence();
});
Enter fullscreen mode Exit fullscreen mode

前端也带来了自身的挑战。你有没有注意到,当你切换标签页时,Google Docs 会显示你“离开”?要实现准确的在线状态,就需要处理各种浏览器事件:

// Frontend presence detection
window.addEventListener('visibilitychange', () => {
    if (document.hidden) {
        updatePresence('away');
    } else {
        updatePresence('active');
    }
});
Enter fullscreen mode Exit fullscreen mode

用户状态转换图

最棘手的部分之一是区分用户是真正离线还是暂时断开连接。你可能会认为简单的超时机制就能解决问题:

// WARNING: Oversimplified approach
socket.on('disconnect', () => {
    setTimeout(() => markUserOffline(userId), 30000);
});
Enter fullscreen mode Exit fullscreen mode

但现实世界中的连接情况要复杂得多。用户可能遇到网络连接不稳定的情况,笔记本电脑可能进入睡眠模式,或者用户可能在没有正确断开连接的情况下就合上了笔记本电脑。更稳健的解决方案需要心跳机制和清理功能来应对这些特殊情况。

网络连接场景

对于全球运营的公司而言,多区域支持增加了复杂性。您的在线状态系统需要以最小的延迟跨不同地理区域同步用户状态。这通常需要在每个区域设置在线状态服务器,并实施复杂的状态同步机制:

// Multi-region presence sync
function syncPresenceAcrossRegions(userId, status) {
    const regions = ['us-east', 'eu-west', 'ap-south'];
    regions.forEach(region => {
        if (region !== currentRegion) {
            notifyRegion(region, userId, status);
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

好消息是?您不必再从零开始构建这一切了。现代解决方案能够处理这些特殊情况,同时还能让您灵活地为用户定制体验。无论您是打造下一个 Figma,还是为您的应用添加基本的协作功能,了解这些挑战都能帮助您做出更明智的架构决策。

为什么SDK越来越受欢迎

开发者们纷纷转向使用 SDK 来构建在线状态功能,因为这比从零开始构建所有内容要方便得多。原因如下:

1. 节省时间 -无需花费数周时间处理网络断开或浏览器标签页等极端情况,只需几个小时即可集成在线状态。

2. 成熟的解决方案——流行的 SDK 已经解决了一些常见问题,例如:

  • 应对不稳定的网络连接
  • 处理多个浏览器标签页
  • 用户离开后的清理工作
  • 跨服务器同步在线状态

3. 成本效益高 -构建和维护自定义状态系统所需的时间和资源通常比使用 SDK 成本更高。

SDK 在实际应用中的适用示例:

  • 文档编辑器会显示谁在查看或编辑
  • 聊天应用程序显示在线/离线状态
  • 设计工具显示其他用户的工作位置
  • 会议平台会显示当前发言者信息。
  • 数据分析平台在仪表盘上实时显示协作者信息
  • 视频编辑软件会显示谁在处理哪些时间线片段。

选择构建自定义解决方案还是使用 SDK,取决于您的具体需求。如果您只需要一些基本且开箱即用的功能,SDK 通常是最佳选择。自定义解决方案主要适用于有特殊需求或需要完全掌控实现过程的情况。

Velt是构建在线状态功能最流行的 SDK 之一。其核心功能是提供一套完整的协作功能,类似于 Figma、Google Docs 等热门应用的功能。它负责处理实时协作功能所需的复杂基础架构,包括管理 WebSocket 连接、状态同步以及跨用户和会话的在线状态跟踪。

它对开发者来说尤其有用,因为它抽象化了构建实时功能时常见的难题——例如处理连接中断、跨多个标签页管理在线状态以及清理过期会话。该SDK为常见的在线状态模式提供了即用型组件,同时仍然允许在需要时访问底层在线状态数据。


在项目中设置Velt并添加存在感

让我们来构建一个实时“谁在线?”的页面,用来显示您网站上的活跃用户。我们将使用 Next.js 15、TypeScript 和 Velt 的在线状态功能。

效果大概是这样的:

项目设置

首先,创建一个新的使用 TypeScript 的 Next.js 项目:

npx create-next-app@latest whos-online --typescript --tailwind --app
cd whos-online
Enter fullscreen mode Exit fullscreen mode

安装 Velt SDK:

npm install @veltdev/react
npm install --save-dev @veltdev/types
Enter fullscreen mode Exit fullscreen mode

配置

前往Velt 控制台并获取您的 Velt API 密钥。此密钥将用于验证您对 Velt API 的请求。

然后将此 API 密钥存储在您的.env文件中:

NEXT_PUBLIC_VELT_API_KEY=your_api_key
Enter fullscreen mode Exit fullscreen mode

创建一个新的提供程序组件来初始化 Velt:

'use client'

import { VeltProvider as BaseVeltProvider } from "@veltdev/react"

export function VeltProvider({ children }: { children: React.ReactNode }) {
  return (
    <BaseVeltProvider apiKey={process.env.NEXT_PUBLIC_VELT_API_KEY!}>
      {children}
    </BaseVeltProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

接下来,在根组件中,将你的应用包裹在VeltProvider组件中:

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { VeltProvider } from "./provider/VeltProvider";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = { // Next.js metadata
  title: "Who's Online?",
  description: "A real-time presence feature built with Velt",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <VeltProvider> // VeltProvider is a component that provides the Velt context to the app
      <html lang="en">
        <body
          className={`${geistSans.variable} ${geistMono.variable} antialiased`}
        >
          {children}
        </body>
      </html>
    </VeltProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

用户身份验证

首先,让我们为用户数据创建一个类型:

export interface UserData { // UserData is an interface that defines the structure of the user data
  userId: string;
  name: string;
  email: string;
  photoUrl?: string;
  color: string;
  textColor: string;
}
Enter fullscreen mode Exit fullscreen mode

现在,我们需要一种方法让 Velt 知道用户是谁。useVeltClient你可以使用一个钩子来识别用户。它的工作方式如下:

import { useVeltClient } from '@veltdev/react';

const { client } = useVeltClient();

// Perform authentication

client.identify(user); // here user is the user data that you want to identify the user with
Enter fullscreen mode Exit fullscreen mode

接下来,我们还需要设置文档上下文。这是用户将要与之交互的文档。

const { client } = useVeltClient();

useEffect(() => {
    if (client) {
        client.setDocument('unique-document-id', {documentName: 'Document Name'});
    }
}, [client]);
Enter fullscreen mode Exit fullscreen mode

client.setDocument 方法接受两个参数:

  • 第一个参数是documentId. 这是要设置上下文的文档的唯一标识符。
  • 第二个参数是一个包含文档元数据的对象。这是一个键值对对象,可用于存储有关文档的任何元数据。

在我们简单的“谁在线?”应用程序中,我们将要求用户输入他们的姓名和电子邮件,然后使用 Velt 识别用户。

'use client'

import { useState, useEffect } from 'react';
import { useVeltClient } from '@veltdev/react';
import { UserData } from '../types';
import { User } from '@veltdev/types';

const generateRandomColor = () => { // generateRandomColor is a function that generates a random color
  const hue = Math.floor(Math.random() * 360);
  const pastelSaturation = 70;
  const pastelLightness = 80;
  return `hsl(${hue}, ${pastelSaturation}%, ${pastelLightness}%)`;
};

const getContrastColor = (backgroundColor: string) => { // getContrastColor is a function that returns a contrasting color for the given background color
  const hsl = backgroundColor.match(/\d+/g)?.map(Number);
  if (!hsl) return '#000000';

  const lightness = hsl[2];
  return lightness > 70 ? '#000000' : '#ffffff';
};

export function UserAuth() { // UserAuth is a component that allows the user to authenticate
  const { client } = useVeltClient();
  const [isAuthenticated, setIsAuthenticated] = useState(() => { // state to check if the user is authenticated
    return !!localStorage.getItem('userData');
  });

  useEffect(() => {
    const initializeUser = async () => { // function to set the user data
      const savedUser = localStorage.getItem('userData');
      if (savedUser && client) {
        const userData: UserData = JSON.parse(savedUser);
        await client.identify(userData as User);
        client.setDocument('whos-online-wall', {documentName: 'Who\'s Online?'});
      }
    };

    if (client) {
      initializeUser();
    }
  }, [client]);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;

    if (!name || !email || !client) return;

    const backgroundColor = generateRandomColor();
    const userData: UserData = {
      userId: email,
      name,
      email,
      color: backgroundColor,
      textColor: getContrastColor(backgroundColor)
    };

    localStorage.setItem('userData', JSON.stringify(userData));
    await client.identify(userData as User);
    client.setDocument('whos-online-wall', {documentName: 'Who\'s Online?'});
    setIsAuthenticated(true);
  };

  if (isAuthenticated) return null; // if the user is authenticated, return null

  return ( // actual component
    <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
      <div className="bg-white dark:bg-gray-800 p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100 dark:border-gray-700">
        <h2 className="text-3xl font-bold mb-8 text-gray-800 dark:text-white text-center">
          Welcome! 👋
          <span className="block text-lg font-normal mt-2 text-gray-600 dark:text-gray-300">
            Please introduce yourself
          </span>
        </h2>
        <form onSubmit={handleSubmit} className="space-y-6">
          <div>
            <label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
              Name
            </label>
            <input
              type="text"
              name="name"
              id="name"
              required
              className="mt-1 block w-full rounded-lg border border-gray-300 dark:border-gray-600 
                       px-4 py-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white
                       focus:ring-2 focus:ring-blue-500 focus:border-transparent
                       transition-colors duration-200"
              placeholder="Enter your name"
            />
          </div>
          <div>
            <label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
              Email
            </label>
            <input
              type="email"
              name="email"
              id="email"
              required
              className="mt-1 block w-full rounded-lg border border-gray-300 dark:border-gray-600 
                       px-4 py-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white
                       focus:ring-2 focus:ring-blue-500 focus:border-transparent
                       transition-colors duration-200"
              placeholder="Enter your email"
            />
          </div>
          <button
            type="submit"
            className="w-full bg-blue-600 text-white rounded-lg px-4 py-3 
                     hover:bg-blue-700 transform hover:scale-[1.02]
                     transition-all duration-200 font-medium text-base
                     focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
                     shadow-lg hover:shadow-xl"
          >
            Join Now
          </button>
        </form>
      </div>
    </div>
  );
} 
Enter fullscreen mode Exit fullscreen mode

在这里,我们需要用户提供姓名和邮箱地址,然后将这些数据存储在本地存储中。同时,我们使用 Velt 的相应client.identify方法识别用户。

然后我们使用该方法设置文档上下文client.setDocument。您可以点击此处了解更多关于身份验证的信息,点击此处了解更多关于设置文档上下文的信息

让我们看看它在浏览器中是如何工作的:

用户身份验证

添加在线墙

现在我们已经完成了用户身份验证和文档上下文设置,我们可以将在线墙添加到我们的应用程序中。

为此,我们将使用该VeltPresence组件。该组件将自动为我们处理在线状态跟踪。

'use client'

import { useVeltClient, VeltPresence } from '@veltdev/react'; // import the VeltPresence component

export function OnlineWall() { // OnlineWall is a component that shows the online wall
  const { client } = useVeltClient();

  if (!client) return null; // if the client is not initialized, return null

  return (
    <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4">
      <VeltPresence />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

加入群组后,您应该可以看到已加入群组的在线群组。

在线墙

我用三个不同的窗口加入了这个页面,你可以看到已经加入的用户。为了避免用户冲突,请确保使用两个不同的浏览器配置文件或隐身模式加入。

但如果我想给这面墙添加自定义用户界面呢?如果我想允许用户离开这面墙呢?

我们来看看该怎么做。

自定义在线留言板并允许用户离开

我们可以使用钩子函数来自定义在线用户墙的用户界面usePresenceUsers。该钩子函数会返回当前在线用户列表。然后,我们可以使用此列表在自定义用户界面中渲染用户。

为了允许用户离开墙壁,我们可以使用这种client.signOutUser方法。

这是墙上的注销按钮:

'use client'

import { useVeltClient } from '@veltdev/react';

export function LogoutButton() {
  const { client } = useVeltClient();

  const handleLogout = async () => {
    if (client) {
      await client.signOutUser(); // this will sign out the user from the current document
      localStorage.removeItem('userData'); // this will remove the user data from local storage
      window.location.reload(); // this will reload the page
    }
  };

  const isAuthenticated = !!localStorage.getItem('userData');
  if (!isAuthenticated) return null;

  return (
    <button
      onClick={handleLogout}
      className="fixed top-4 right-4 px-4 py-2 bg-gray-100 dark:bg-gray-700 
                 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-200 
                 dark:hover:bg-gray-600 transition-colors duration-200 
                 flex items-center gap-2 shadow-sm"
    >
      <span>Logout</span>
      <svg 
        xmlns="http://www.w3.org/2000/svg" 
        width="16" 
        height="16" 
        viewBox="0 0 24 24" 
        fill="none" 
        stroke="currentColor" 
        strokeWidth="2" 
        strokeLinecap="round" 
        strokeLinejoin="round"
      >
        <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
        <polyline points="16 17 21 12 16 7" />
        <line x1="21" y1="12" x2="9" y2="12" />
      </svg>
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

现在让我们更新OnlineWall组件以使用该usePresenceUsers钩子,并更新 UI 以在一个漂亮的网格中显示用户。

'use client'

import { useVeltClient, usePresenceUsers } from '@veltdev/react';
import { motion } from 'framer-motion';

export function OnlineWall() {
  const { client } = useVeltClient();
  const presenceUsers = usePresenceUsers();

  if (!client) return null;

  // Add loading state check
  if (!presenceUsers) {
    return (
      <div className="flex items-center justify-center min-h-[200px]">
        <div className="relative">
          {/* Outer spinning ring with gradient */}
          <div className="w-16 h-16 rounded-full absolute animate-spin 
            bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500"
          ></div>
          {/* Inner white circle */}
          <div className="w-16 h-16 rounded-full absolute bg-background"></div>
          {/* Middle spinning ring with gradient */}
          <div className="w-12 h-12 rounded-full absolute top-2 left-2 animate-spin 
            bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500"
          ></div>
          {/* Inner white circle */}
          <div className="w-12 h-12 rounded-full absolute top-2 left-2 bg-background"></div>
          {/* Center dot with pulse effect */}
          <div className="w-8 h-8 rounded-full absolute top-4 left-4
            bg-gradient-to-r from-blue-500 to-purple-500 animate-pulse"
          ></div>
        </div>
      </div>
    );
  }

  // Get current user data from localStorage
  const currentUserData = localStorage.getItem('userData');
  const currentUser = currentUserData ? JSON.parse(currentUserData) : null;

  // Sort users to put current user first
  const sortedUsers = presenceUsers?.sort((a, b) => {
    if (a.userId === currentUser?.userId) return -1;
    if (b.userId === currentUser?.userId) return 1;
    return 0;
  });

  return ( // actual component
    <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4">
      {sortedUsers?.map((user) => {
        const isCurrentUser = user.userId === currentUser?.userId;

        return (
          <motion.div
            key={user.userId}
            initial={{ opacity: 0, scale: 0.9 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.9 }}
            whileHover={{ scale: 1.05 }}
            className="relative group"
          >
            {isCurrentUser && (
              <div className="absolute -top-2 -right-2 bg-blue-500 text-white text-xs px-2 py-1 rounded-full z-10 shadow-lg">
                You
              </div>
            )}
            <div 
              className={`rounded-lg p-4 h-full shadow-lg transition-all duration-300 group-hover:shadow-xl
                ${isCurrentUser ? 'ring-2 ring-blue-500 ring-offset-2 ring-offset-background' : ''}`}
              style={{ 
                backgroundColor: user.color || '#f0f0f0',
                color: user.textColor || '#000000'
              }}
            >
              <div className="flex items-center space-x-3">
                {user.photoUrl ? (
                  <img 
                    src={user.photoUrl} 
                    alt={user.name}
                    className="w-12 h-12 rounded-full border-2 border-white/30"
                  />
                ) : (
                  <div 
                    className="w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold border-2 border-white/30"
                    style={{ backgroundColor: 'rgba(255, 255, 255, 0.2)' }}
                  >
                    {user.name?.charAt(0).toUpperCase()}
                  </div>
                )}
                <div className="flex-1 min-w-0">
                  <h3 className="font-semibold truncate">{user.name}</h3>
                  <p className="text-sm opacity-75 truncate">{user.email}</p>
                </div>
              </div>

              <div className="mt-3 flex items-center">
                <motion.div
                  className="w-2 h-2 rounded-full bg-green-400 mr-2"
                  animate={{
                    scale: [1, 1.2, 1],
                  }}
                  transition={{
                    duration: 2,
                    repeat: Infinity,
                  }}
                />
                <span className="text-sm">Online now</span>
              </div>
            </div>
          </motion.div>
        );
      })}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

这里我们以漂亮的网格形式显示用户,并用“您”徽章指示当前用户。

这里我还展示了获取在线用户数据时的加载状态。这可以通过usePresenceUsers钩子函数并检查用户是否为空来实现。

它的样子是这样的:

增强型在线墙

退出登录页面非常简单,只需点击退出按钮即可。

看起来怎么样?

超棒的GIF

是不是很简单?只需几行代码,我们就为应用程序添加了在线状态显示功能。

在在线墙上显示光标

我们更进一步,在在线墙上显示用户的光标。这意味着无论用户在文档中做什么,我们都会在在线墙上显示该用户的光标。

为此,我们将使用这个VeltCursor组件。该组件会自动处理光标跟踪。只需将该组件添加到根目录即可。

Velt 的光标实现之所以强大,在于它不仅追踪原始的 x、y 坐标,还能智能地适应底层内容结构。这意味着,即使用户使用不同的屏幕尺寸、缩放级别或不同的布局(例如响应式设计),光标始终会出现在他们正在交互的内容的正确位置。这种对文档结构的语义理解确保了所有客户端上光标位置的一致性。

这里,根组件就是根OnlineWall组件本身。虽然用单独的根组件处理会更好,但为了简单起见,我们暂时先这样。

    <>
      <VeltCursor /> // just add this to the root 
      <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4">
        {sortedUsers?.map((user) => {
          const isCurrentUser = user.userId === currentUser?.userId;

          return (
            <motion.div
              key={user.userId}
              initial={{ opacity: 0, scale: 0.9 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.9 }}
              whileHover={{ scale: 1.05 }}
              className="relative group"
            >
              {isCurrentUser && (
                <div className="absolute -top-2 -right-2 bg-blue-500 text-white text-xs px-2 py-1 rounded-full z-10 shadow-lg">
                  You
                </div>
              )}
              <div 
                className={`rounded-lg p-4 h-full shadow-lg transition-all duration-300 group-hover:shadow-xl
                  ${isCurrentUser ? 'ring-2 ring-blue-500 ring-offset-2 ring-offset-background' : ''}`}
                style={{ 
                  backgroundColor: user.color || '#f0f0f0',
                  color: user.textColor || '#000000'
                }}
              >
                <div className="flex items-center space-x-3">
                  {user.photoUrl ? (
                    <img 
                      src={user.photoUrl} 
                      alt={user.name}
                      className="w-12 h-12 rounded-full border-2 border-white/30"
                    />
                  ) : (
                    <div 
                      className="w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold border-2 border-white/30"
                      style={{ backgroundColor: 'rgba(255, 255, 255, 0.2)' }}
                    >
                      {user.name?.charAt(0).toUpperCase()}
                    </div>
                  )}
                  <div className="flex-1 min-w-0">
                    <h3 className="font-semibold truncate">{user.name}</h3>
                    <p className="text-sm opacity-75 truncate">{user.email}</p>
                  </div>
                </div>

                <div className="mt-3 flex items-center">
                  <motion.div
                    className="w-2 h-2 rounded-full bg-green-400 mr-2"
                    animate={{
                      scale: [1, 1.2, 1],
                    }}
                    transition={{
                      duration: 2,
                      repeat: Infinity,
                    }}
                  />
                  <span className="text-sm">Online now</span>
                </div>
              </div>
            </motion.div>
          );
        })}
      </div>
    </>
Enter fullscreen mode Exit fullscreen mode

我们将组件包裹在片段中,因为我们将VeltCursor组件添加到根元素,这样就可以向根元素添加更多组件。

虽然可以采取更好的维护方法,但这只是权宜之计。

在我们的在线留言板上添加评论

如果我们想让留言墙更具互动性,允许用户在墙上的任何位置留言呢?使用 Velt 可以轻松实现这一点。

我们可以使用 ` VeltCommentsand`VeltCommentTool组件,让用户可以在墙上的任何位置留言。

它的运作方式如下:

    <>
      <VeltCursor />
      <VeltComments />
      <div className="fixed bottom-4 right-4 z-50">
        <VeltCommentTool />
      </div>
      <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4">
        {sortedUsers?.map((user) => {
          const isCurrentUser = user.userId === currentUser?.userId;

          return (
            <motion.div
              key={user.userId}
              initial={{ opacity: 0, scale: 0.9 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.9 }}
              whileHover={{ scale: 1.05 }}
              className="relative group"
            >
              {isCurrentUser && (
                <div className="absolute -top-2 -right-2 bg-blue-500 text-white text-xs px-2 py-1 rounded-full z-10 shadow-lg">
                  You
                </div>
              )}
              <div 
                className={`rounded-lg p-4 h-full shadow-lg transition-all duration-300 group-hover:shadow-xl
                  ${isCurrentUser ? 'ring-2 ring-blue-500 ring-offset-2 ring-offset-background' : ''}`}
                style={{ 
                  backgroundColor: user.color || '#f0f0f0',
                  color: user.textColor || '#000000'
                }}
              >
                <div className="flex items-center space-x-3">
                  {user.photoUrl ? (
                    <img 
                      src={user.photoUrl} 
                      alt={user.name}
                      className="w-12 h-12 rounded-full border-2 border-white/30"
                    />
                  ) : (
                    <div 
                      className="w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold border-2 border-white/30"
                      style={{ backgroundColor: 'rgba(255, 255, 255, 0.2)' }}
                    >
                      {user.name?.charAt(0).toUpperCase()}
                    </div>
                  )}
                  <div className="flex-1 min-w-0">
                    <h3 className="font-semibold truncate">{user.name}</h3>
                    <p className="text-sm opacity-75 truncate">{user.email}</p>
                  </div>
                </div>

                <div className="mt-3 flex items-center">
                  <motion.div
                    className="w-2 h-2 rounded-full bg-green-400 mr-2"
                    animate={{
                      scale: [1, 1.2, 1],
                    }}
                    transition={{
                      duration: 2,
                      repeat: Infinity,
                    }}
                  />
                  <span className="text-sm">Online now</span>
                </div>
              </div>
            </motion.div>
          );
        })}
      </div>
    </>
Enter fullscreen mode Exit fullscreen mode

让我们来看看最终的应用程序是什么样子:

太简单了!现在我们的墙面更具互动性和吸引力了。

项目概要

在这个项目中,我们使用 Velt 构建了一个实时“在线用户”墙。我们学习了如何:

  • 在我们的项目中设置 Velt
  • 使用 Velt 对用户进行身份验证
  • 为当前用户设置文档上下文
  • 使用该VeltPresence组件向在线用户显示信息
  • usePresenceUsers使用钩子自定义在线留言板用户界面
  • 允许用户离开页面并退出登录
  • 在在线墙上显示用户的光标
  • 允许用户在墙上的任何位置留言。

使用 Velt 的好处

从零开始构建用户状态功能通常始于一个有趣的周末项目。你设置 WebSocket 连接,跟踪用户状态,并在本地实现一个基本版本。然而,现实往往是困难重重——处理断线、跨区域同步以及应对浏览器兼容性问题,这些都会让原本的周末项目变成长达数周的调试。

Velt 能够处理这些复杂性,同时又能让你拥有所需的控制权。你无需费力处理 WebSocket 重连逻辑或防抖动状态更新,就能专注于打造真正的用户体验。这就像使用经过实战检验的身份验证系统,而不是自己编写加密算法——当然,你也可以自己写,但为什么要这么做呢?

当你的应用规模扩大时,真正的价值才会显现。当小型团队协作工具突然需要处理数百个并发用户,或者你的应用(仅限美国地区)需要扩展到全球时,你无需重写你的应用基础架构。同一个原本可以处理 10 个用户的 SDK,也能扩展到处理数千个用户。

天鹅绒

开发者们欣赏的一些实际好处:

  • 无需维护 WebSocket 服务器和连接逻辑
  • 内置网络问题处理和浏览器标签页同步功能
  • 与现有身份验证系统轻松集成
  • 自动清理过期的在线状态数据

你可以把它想象成使用 Redis 进行缓存——你当然可以自己构建缓存系统,对吧?但 Redis 提供了一个经过验证、行之有效的解决方案,让你能够专注于解决实际的业务问题。

最棒的是,你无需局限于特定的实现方式。想在应用的不同部分以不同的方式显示在线状态?需要添加自定义在线状态?SDK 为你提供构建模块,同时让你掌控一切的外观和行为。

维尔特的其他功能

除了状态显示和实时光标之外,Velt 还提供多种强大的协作功能:

  1. 实时反应- 添加实时显示的浮动表情符号和反应

  2. 跟随模式- 允许用户互相跟随彼此的动作。非常适合用于演示和引导式教程。

  3. Huddles - 在您的应用内创建即时音频/视频会议空间。与您现有的用户界面无缝集成。

  4. 实时选择- 实时选择并高亮文本。非常适合协作编辑。

  5. 视频播放器同步- 实现用户间视频播放器同步。非常适合视频会议和演示。

点击此处查看更多功能

结论

本文介绍了如何使用 Velt 轻松地为应用添加在线状态功能。我们学习了如何设置 Velt、验证用户身份、设置文档上下文,以及如何使用组件VeltPresence显示在线用户。此外,我们还了解了如何使用 hook 自定义在线状态墙 UI usePresenceUsers,并允许用户离开状态墙和注销。

您还可以通过添加更多功能来进一步增强我们的在线墙,例如实时反应、关注模式、群组讨论、通知等等!

我们的后台系统中没有实现安全的登录方式,这部分留给你们自行实现。你们可以使用任何社交账号登录服务或邮箱/密码验证方式,并配置 Velt 身份验证服务以启用该服务。

我希望这篇文章能让你更好地了解如何使用 Velt 在你的应用程序中构建在线状态功能。

祝您编程愉快!


要了解更多关于 Velt 的信息并开始使用,请查看:

我们


如果你喜欢我的内容!请在推特上关注我。

查看我使用的SaaS工具 👉🏼点击此处访问

我乐于合作撰写博客文章和客座文章🫱🏼‍🫲🏼 📅点击此处联系

文章来源:https://dev.to/astrodevil/build-real-time-presence-features-like-figma-and-google-docs-in-your-app-in-minutes-1lae