为了账号安全,请及时绑定邮箱和手机立即绑定

几分钟内为你的应用添加像 Figma 和 Google Docs 一样的实时协作功能🚀🔥👨‍💻

标签:
设计工具 API

注:这里的“实时协作功能”包含了在线用户显示、光标跟踪等功能。标题旨在传达快速实现这些功能,类似于Figma和Google Docs中的实时协作体验。

TL;DR(太长不看)

了解在您的应用程序中实施协作功能所面临的挑战和解决方案。我们在这个教程里用Velt搞了一个实时的“谁在线?(Who's Online?)”的板块。

包括以下功能等等:

  • 实时显示 在线用户和他们的光标。
  • 用户 认证及文档上下文 管理。
  • 具有评论和注销选项的自定义界面

这份指南教你如何创建吸引人且便于合作的工具。为了更上一层楼,你可以考虑加入点赞、评论功能以及安全的登录方式。

开始吧!🚀

……

现代 web 应用程序变得越来越协作化。想想看,在 Figma 中看到那些彩色光标移动时,是不是觉得非常自然,或者在 Google 文档中看到那些气泡头像显示谁正在看这份文档。这些在场功能对于任何协作型应用程序来说都已变得不可或缺。

你知道吗,当用户可以看到其他用户实时协作时,97%的用户更可能保持对产品的参与吗?这种心理现象真的很有趣——我们会被能看到其他人一起工作的空间所吸引,即使在数字环境中也不例外。

用户们正在一起实时协作

从零开始构建在线状态功能所面临的挑战

一开始看起来构建在线状态功能很简单。但正如许多开发者发现的那样,这很快就会变得复杂起来。最近我参与的一个项目中,我们遇到了这种情况:我们最初通过一个简单的WebSocket连接来显示在线用户,但当需要处理不稳定连接和多个浏览器标签时,情况变得越来越复杂。

我们先来看看后端部分。你需要一个可靠的系统来维护在多个服务器实例间的WebSocket连接。这里有一种常用的做法,使用Redis:

// 服务器端的 presence(状态)跟踪
const presenceMap = new Map();
redis.subscribe('presence', (channel, message) => {
   const { userId, status } = JSON.parse(message); // 解析消息中的用户ID和状态
   presenceMap.set(userId, status); // 更新用户的状态
   broadcastPresence(); // 广播状态更新
});

点击全屏 点击退出全屏

前端也有它自己的挑战。你注意过没有,当你切换标签时,Google 文档会把你显示为“离开”?实现准确的状态意味着要处理各种浏览器相关的事件:

    // 前端可见性检测
    window.addEventListener('visibilitychange', () => {
        if (document.hidden) {
            // 更新存在状态为离开
            updatePresence('away');
        } else {
            // 更新存在状态为活跃
            updatePresence('active');
        }
    });

全屏模式 退出全屏

用户状态图

其中一个最难的部分是分辨真正下线的用户和暂时没网的用户。你可能觉得设置一个简单的超时时间就能搞定。

    // 警告:过于简化的处理方法
    socket.on('disconnect', () => {
        setTimeout(() => 将用户标记为离线(userId), 30000);
    });

进入全屏,退出全屏

但是实际情况中的连接更为复杂。用户可能遇到网络连接不稳定的情况,笔记本电脑可能进入睡眠模式,或者他们可能在未正确断开连接的情况下关闭笔记本电脑。一个更稳健的解决方案需要心跳检测和清理程序来处理这些特殊情况。

各种网络连接情况,如图所示

对于在全球运营的公司来说,多区域支持增加了另一层复杂性。公司的存在系统需要将用户状态在不同地理区域之间同步,同时确保延迟尽可能低。这通常涉及在每个区域设置状态服务器或在线服务器,并实施复杂的状态同步。

// 跨区域同步用户状态
function 同步多区域存在(userId, status) {
    const regions = ['us-east', 'eu-west', 'ap-south'];
    regions.forEach(region => {
        if (region !== 当前区域) {
            通知区域(region, userId, status);
        }
    });
}

全屏模式 退出全屏

好消息是,你不再需要从头开始构建这一切了。现代解决方案可以处理这些边缘情况,同时给你足够的灵活性来自定义用户体验。无论是打造下一个Figma,还是为你的应用增加基本的协作功能,理解这些挑战能帮助你做出更好的架构选择。

为什么越来越多的开发者喜欢使用SDKs,它们变得这么火?

开发人员转向使用SDK来构建用户在线状态功能,因为这比从零开始构建所有功能更加实用。原因如下:

1. 节省时间 - 你不必花几周的时间来处理诸如网络断线或浏览器标签之类的边缘情况,而可以在几个小时内搞定在线状态功能。

2. 经过验证的解决方案包括 - 流行的SDK已经解决了以下常见的问题:

  • 管理不稳定(或不可靠)的互联网连接
  • 处理多个浏览器标签
  • 用户离开时清理
  • 跨服务器同步在线状态

3. 物有所值 - 建立和维护一个自定义的存在系统通常比使用SDK要花钱多。

SDKs在实际中的应用实例:

  • 文档编辑器显示谁在查看或编辑文档
  • 聊天应用显示在线/离线状态
  • 设计工具显示其他用户正在工作的位置或板块
  • 会议平台显示谁正在发言
  • 数据分析平台的仪表板显示实时合作者
  • 视频编辑软件显示谁正在编辑哪个时间线片段

选择自定义构建还是使用SDK,这取决于您的具体需求。如果您只需要基本的在线状态功能,使用SDK通常更合适。自定义解决方案主要适用于独特需求或需要完全控制实现过程的情况。

最受欢迎的用于构建实时协作功能的SDK之一是Velt。在核心功能上,Velt提供了一整套协作功能,这些功能在流行的软件中很常见,例如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

其中, npx 是一个 npm 的工具, create-next-app@latest 是一个创建 Next.js 应用程序的脚本, whos-online 是应用的名称, --typescript, --tailwind, 和 --app 是一些配置选项.

全屏模式, 退出全屏

安装 Velt SDK:

下面的命令用于安装veltdev提供的react包及其类型定义:

npm install @veltdev/react
npm install --save-dev @veltdev/types

全屏 退出全屏

设置

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

然后将这个 API 密钥存到你的 .env 文件中

NEXT_PUBLIC_VELT_API_KEY=your_api_key // 你的API密钥

点击这里切换到全屏模式,点击这里退出全屏

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

'use client'
// 使用客户端标识符,表明此组件只能在客户端渲染

import { VeltProvider as BaseVeltProvider } from "@veltdev/react"
// 从veltdev/react中导入BaseVeltProvider组件

export function VeltProvider({ children }: { children: React.ReactNode }) {
  // 导出VeltProvider函数,接受一个包含children的参数
  return (
    <BaseVeltProvider apiKey={process.env.NEXT_PUBLIC_VELT_API_KEY!}>
      // 使用BaseVeltProvider包裹children,并传入apiKey参数
      {children}
    </BaseVeltProvider>
  )
}
// VeltProvider函数的实现,返回包裹后的children组件

全屏 全屏退出

接下来,在你的根组件里,用 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 的元数据
      title: "谁在线?",
      description: "使用 Velt 构建的实时在线功能",
    };

    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <VeltProvider> // VeltProvider 提供 Velt 上下文给应用程序
          <html lang="en">
            <body
              className={`${geistSans.variable} ${geistMono.variable} antialiased`}
            >
              {children} // 子元素
            </body>
          </html>
        </VeltProvider>
      );
    }

点击全屏/点击退出

用户身份认证

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

    export interface UserData { // UserData 是定义用户数据结构的接口,如下所示:
      userId: 用户ID;
      name: string;
      email: 电子邮件;
      photoUrl?: 头像URL;
      color: 背景颜色;
      textColor: 文字颜色;
    }

全屏 退出全屏

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

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

    const { client } = useVeltClient();

    // 进行身份验证

    client.identify(user); // 这里的 user 是用来标识用户的用户信息

全屏,退出全屏

接下来,我们还需要设定文档的背景。这将是用户要进行互动的文档。

    const { client } = useVeltClient();

    useEffect(() => {
        if (client) {
            client.setDocument('unique-document-id', {documentName: '文档名'}); // 注释:设置文档名称
        }
    }, [client]);

进入全屏,退出全屏

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 = () => { // 生成随机颜色
      const hue = Math.floor(Math.random() * 360);
      const pastelSaturation = 70;
      const pastelLightness = 80;
      return `hsl(${hue}, ${pastelSaturation}%, ${pastelLightness}%)`;
    };

    const getContrastColor = (backgroundColor: string) => { // 获取对比色
      const hsl = backgroundColor.match(/\d+/g)?.map(Number);
      if (!hsl) return '#000000';

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

    export function UserAuth() { // 用户验证
      const { client } = useVeltClient();
      const [isAuthenticated, setIsAuthenticated] = useState(() => { // 检查用户是否已认证
        return !!localStorage.getItem('userData');
      });

      useEffect(() => {
        const initializeUser = async () => { // 初始化用户方法
          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; // 如果用户已认证,返回 null

      return ( // 实际组件
        <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">
              欢迎!👋
              <span className="block text-lg font-normal mt-2 text-gray-600 dark:text-gray-300">
                请介绍一下自己
              </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">
                  姓名
                </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="请输入您的姓名"
                />
              </div>
              <div>
                <label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
                  电子邮件
                </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="请输入您的电子邮件"
                />
              </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"
              >
                立即加入
              </button>
            </form>
          </div>
        </div>
      );
    } 

全屏 退出全屏

同时,我们期望从用户那里获取名字和电子邮件,并将这些数据存储在本地存储中。同时,我们使用client.identify方法来识别用户。

然后我们使用 client.setDocument 方法设置文档的上下文环境。更多关于认证的信息可以在这里查看https://docs.velt.dev/get-started/setup/authenticate,以及如何设置文档的上下文环境https://docs.velt.dev/get-started/setup/initialize-document

我们来看看浏览器里这东西怎么运行:

用户认证 (User Authentication)

添加在线讨论墙

现在我们已经认证了用户并且设置了文档上下文,可以添加在线墙到我们的应用里了。

要做到这一点,我们可以使用VeltPresence组件。这个组件会自动为我们处理在场状态的跟踪。

    'use client'

    import { useVeltClient, VeltPresence } from '@veltdev/react'; // 导入 useVeltClient 和 VeltPresence 组件 from '@veltdev/react';

    export function OnlineWall() { // OnlineWall 是一个展示在线状态的组件
      const { client } = useVeltClient();

      if (!client) return null; // 如果客户端未初始化,则返回 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>
      );
    }

点击全屏显示 点击退出全屏

现在加入后,你应该能看到和其他用户一起加入墙上的用户了。

OnlineWall

我通过三个不同的窗口加入了,你可以看到加入的用户。确保通过两个不同的浏览器配置文件或隐身模式加入,以避免用户之间的冲突。

但是我要给墙添加一个自定义的UI怎么办呢?我要是想让用户从墙上退出呢?

我们看看怎么能做到。

定制在线留言区,并允许用户留下评论

我们可以使用 usePresenceUsers 钩子来自定义在线墙的用户界面。这个钩子返回当前在线的用户列表。然后我们可以利用这个列表来渲染在线用户,形成自定义用户界面。

用户可以通过调用 client.signOutUser 方法来退出。

这里有注销按钮在墙上:

    'use client'

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

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

      const handleLogout = async () => {
        if (client) {
          await client.signOutUser(); // 这将使用户从当前文档中退出
          localStorage.removeItem('userData'); // 这将从本地存储中移除用户数据,
          window.location.reload(); // 这将刷新页面,
        }
      };

      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>退出</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>
      );
    }

进入全屏 退出全屏

我们现在来更新 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;

      // 检查加载状态
      if (!presenceUsers) {
        return (
          <div className="flex items-center justify-center min-h-[200px]">
            <div className="relative">
              {/* 外层旋转环,具有渐变效果 */}
              <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>
              {/* 内层白色圆圈 */}
              <div className="w-16 h-16 rounded-full absolute bg-background"></div>
              {/* 中间旋转环,具有渐变效果 */}
              <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>
              {/* 中间白色圆圈 */}
              <div className="w-12 h-12 rounded-full absolute top-2 left-2 bg-background"></div>
              {/* 中心点,带有脉冲效果 */}
              <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>
        );
      }

      // 从localStorage中获取当前用户数据
      const currentUserData = localStorage.getItem('userData');
      const currentUser = currentUserData ? JSON.parse(currentUserData) : null;

      // 将当前用户排在前面
      const sortedUsers = presenceUsers?.sort((a, b) => {
        if (a.userId === currentUser?.userId) return -1;
        if (b.userId === currentUser?.userId) return 1;
        return 0;
      });

      return ( // 实际的组件
        <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">
                    你
                  </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">当前在线</span>
                  </div>
                </div>
              </motion.div>
            );
          })}
        </div>
      );
    }

点全屏
取消全屏

我们在这里将用户显示在一个整洁的网格中,并用一个“你”徽章标记出当前用户。

在这里,我也会在在线用户被获取时显示加载状态。这可以通过使用 usePresenceUsers 钩子并检查用户是否为 null(空)来实现。

它看起来是这样的:

加强版在线墙

只需点击登出按钮即可从墙上退出。

看起来怎么样?

超酷的 GIF

这有多简单?只需几句代码,我们就给应用加上了这个功能。

在线上显示光标

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

为此,我们将使用 VeltCursor 组件,它会自动处理光标追踪,只需添加到根节点即可。

维特光标的特别强大之处在于,它不仅仅跟踪 x, y 坐标——它还能智能适应底层的内容结构。这意味着无论用户屏幕大小、缩放级别或布局如何,光标始终会出现在与互动内容相对应的正确位置上。这种对文档结构的理解确保了在所有客户端上光标的定位保持一致。

这里,根是OnlineWall组件。虽然在一个单独的根元素中处理会更理想,但现在我们保持简单些。

        <>
          <VeltCursor /> // 只需将此添加到根元素
          <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">
                      是你
                    </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">现在在线</span>
                    </div>
                  </div>
                </motion.div>
              );
            })}
          </div>
        </>

进入全屏 退出全屏

我们将组件包裹在一个片段里,因为我们向根部添加了 VeltCursor 组件,这样可以让我们在根部添加更多的组件。

这可以用更好的方法来维护,但这只是一种临时的解决办法。

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

如果我们想让这面墙更互动,让用户可以随意在墙上任何地方留言,这在Velt(一个平台)上就简单多了。

我们可以使用 VeltCommentsVeltCommentTool 这两个组件让用户在墙上的任何位置都能留下评论,这样一来。

这就来告诉你它是怎么运作的:

        <>
          <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">自己</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">在线中</span>
                    </div>
                  </div>
                </motion.div>
              );
            })}
          </div>
        </>

点击进入全屏模式(按 Esc 键退出)

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

真是简单!现在我们的墙变得更有趣且更吸引眼球了。

项目概要

在这个项目中,我们利用Velt搭建了一个实时显示“谁在线”的墙。我们学会了以下几点:

  • 在我们的项目中启用 Velt
  • 用 Velt 验证用户
  • 为当前用户设定文档环境
  • 使用 VeltPresence 组件展示在线用户
  • 使用 usePresenceUsers 钩子定制在线墙界面
  • 让用户离开并退出
  • 在在线墙上展示用户的光标
  • 让用户在墙上任何地方留言
使用 Velt 的优点:

从头开始构建实时功能通常始于一个有趣的周末项目。你追踪用户状态,设置 WebSocket 连接,并让一个基本版本在本地运行起来。但很快你就会遇到现实问题——处理断开连接、跨地区同步,还要应付各种浏览器的坑,这周末项目就变成了数周的调试。

Velt 在处理这些复杂性的同时,仍然给你所需的控制权。你无需担心 WebSocket 重连逻辑或防抖状态更新,而是可以专注于实际用户体验的打造。这就像使用一个经过实战考验的身份验证系统,而不是自己搭建加密系统——当然你可以自己搭建,但为什么要做呢?

真正的价值在你的应用发展起来时才显现出来。当最初设计用于小团队协作的工具突然需要处理数百个并发用户请求,或者你的应用最初只在美国市场,需要拓展到全球时,你不需要重写你的存在架构。同一个处理了10个用户请求的SDK可以扩展到处理数千个用户请求。

Velt 点击图片查看Velt的详细介绍

开发人员们常提到的实际好处:

  • 无需维护 WebSocket 服务器及连接逻辑
  • 内置处理网络问题和浏览器标签的同步
  • 与现有的身份验证系统轻松集成
  • 自动清理过时的在场信息

你可以把这想象成使用 Redis 来缓存数据——你自己能搭建一个缓存系统吗?当然可以。但 Redis 给你提供了一个经过验证的好用方案,让你能够更好地专注于解决实际的业务问题。

最好的部分是您不必受特定实现的限制。希望在应用程序的不同部分以不同的方式显示在线状态吗?需要添加自定义的在线状态吗?SDK 提供了构建模块,同时让您控制所有内容的外观和行为。

Velt 的其他特性

除了比如展示和实时光标之外,Velt 提供了几个强大的协作功能等等。

  1. 实时表情反应,添加浮动表情和反应,这些表情和反应会实时显示。

  2. 跟随模式(Follow Mode) - 让用户跟随彼此的行为。非常适合用于演示和引导教程。

  3. Huddles - 在您的应用程序中创建即时音视频通话空间。无缝集成到现有的用户界面中

  4. 实时选中 - 实时选中和突出显示文本。非常适合协作编辑场景

  5. 视频播放同步 - 在不同用户之间同步视频播放。非常适合用于视频会议和演示文稿。

了解更多功能 (点击这里)https://velt.dev/#features

结束语

在这篇文章中,我们看到了如何轻松地使用Velt为您的应用程序添加存在功能。我们学习了如何设置Velt,认证用户,设置文档上下文环境,并使用VeltPresence组件来展示在线用户。我们还展示了如何使用usePresenceUsers钩子自定义在线墙的UI,并让用户可以离开在线墙并登出。

你可以通过添加更多功能,如即时反馈、关注模式、小组讨论、提醒等,进一步增强我们的在线互动墙,还有更多精彩功能等着你!

在我们的墙里,我们没有实现一种安全的用户登录方法。这个功能留给你们去实现。你可以使用任何社交登录服务或电子邮件/密码认证,并配置 Velt 认证提供程序使用这些方法。

希望你能够通过这篇文章理解如何使用Velt将功能构建到你的应用程序中。谢谢阅读。

编程快乐!


想了解更多关于 Velt 的信息并开始使用吗?可以参考以下内容:

GIF 我们

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消