npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nowsoft-lab/firebase-chatkit

v0.1.1

Published

Firebase Realtime Database 기반 실시간 채팅 / 접속자 카운터 React 컴포넌트

Readme

@nowsoft-lab/firebase-chatkit

Firebase Realtime Database 하나로 동작하는 실시간 채팅 · 접속자 카운터 · 하트 버튼 · 이벤트 브로드캐스트 · 공유 프롬프트 React 컴포넌트 모음입니다. 백엔드 서버를 따로 두지 않고, 내 프로젝트에 컴포넌트를 붙이는 것만으로 실시간 기능을 넣을 수 있습니다.

npm install @nowsoft-lab/firebase-chatkit firebase react react-dom
import "@nowsoft-lab/firebase-chatkit/style.css";
import { initFirebaseChatkit, Chat } from "@nowsoft-lab/firebase-chatkit";

initFirebaseChatkit({ databaseURL: "https://<project>-default-rtdb.firebaseio.com" });

export default function Room() {
  return (
    <div style={{ height: 600 }}>
      <Chat channelId="c1" roomId="r1" user={{ userId: "u1", nickname: "홍길동" }} />
    </div>
  );
}

| | | | --- | --- | | 필요한 것 | Firebase 프로젝트의 Realtime Database 하나 (databaseURL) | | 요구 버전 | React 18+ · react-dom 18+ · firebase 10+ (모두 peerDependency) | | 타입 | TypeScript 로 작성, 모든 props·핸들 타입 export | | 스타일 | style.css 한 줄 import 로 끝. 소비 앱에 Tailwind 가 없어도 동작 | | 커스터마이징 | 모든 영역이 components prop(slot) 으로 교체 가능, ui prop 으로 클래스 추가 |

목차

설치

npm install @nowsoft-lab/firebase-chatkit firebase react react-dom

react(>=18), react-dom(>=18), firebase(>=10) 는 peerDependency 입니다. 번들에 포함되지 않으므로 여러분 앱의 인스턴스를 그대로 씁니다.

시작하려면 세 가지만 하면 됩니다.

  1. 스타일 import — 앱 진입점에서 한 번
    import "@nowsoft-lab/firebase-chatkit/style.css";
  2. Firebase 연결Firebase 설정
  3. 보안 규칙 등록RTDB 데이터 구조 · 보안 규칙. 규칙을 열지 않으면 PERMISSION_DENIED 가 납니다.

Next.js · SSR 환경

컴포넌트가 브라우저 API(localStorage · RTDB 구독)를 쓰므로 클라이언트에서만 렌더해야 합니다.

"use client"; // Next.js App Router

import "@nowsoft-lab/firebase-chatkit/style.css";
import { initFirebaseChatkit, Chat } from "@nowsoft-lab/firebase-chatkit";

Pages Router 라면 next/dynamic{ ssr: false } 로 감싸면 됩니다. initFirebaseChatkit() 도 클라이언트 진입점(또는 컴포넌트 마운트 시)에서 호출하세요.

Firebase 설정

이 라이브러리는 Realtime Database 만 사용하므로 databaseURL 하나가 필수이고 나머지 옵션(apiKey·authDomain·projectId 등)은 필요할 때만 넣으면 됩니다. 내부적으로는 firebase-chatkit 이라는 전용 named app 을 만들어 쓰기 때문에 소비 앱의 다른 firebase app 과 충돌하지 않습니다.

1) 전역 init — 앱 시작 시 1회 (권장)

import { initFirebaseChatkit } from "@nowsoft-lab/firebase-chatkit";

initFirebaseChatkit({
  databaseURL: "https://<project>-default-rtdb.firebaseio.com",
});

2) 컴포넌트 props 로 주입

<Chat channelId="c1" roomId="r1" firebaseConfig={myConfig} />
// 또는 이미 만든 Database 인스턴스를 직접 주입 (소비 앱이 Auth·Storage 를 함께 쓰는 경우)
<Chat channelId="c1" roomId="r1" database={myDatabase} />

우선순위: database prop → firebaseConfig prop → initFirebaseChatkit 전역 config. 셋 다 없으면 에러가 발생합니다.

빠른 시작

import "@nowsoft-lab/firebase-chatkit/style.css";
import { initFirebaseChatkit, Chat, Counter } from "@nowsoft-lab/firebase-chatkit";

initFirebaseChatkit({ databaseURL: "https://<project>-default-rtdb.firebaseio.com" });

export function Room() {
  return (
    <div style={{ height: 600 }}>
      <Counter channelId="c1" roomId="r1" config={{ placeholder: "%n명 시청중" }} />
      <Chat channelId="c1" roomId="r1" user={{ userId: "u1", nickname: "홍길동" }} />
    </div>
  );
}

모든 컴포넌트가 channelId / roomId 두 개로 방을 식별합니다. 같은 값을 보는 모든 클라이언트가 같은 데이터를 주고받습니다. 서로 다른 컴포넌트에 같은 방 값을 주면(예: <Chat/><HeartButton/>) 같은 방의 데이터를 함께 씁니다.

| 컴포넌트 | 하는 일 | RTDB 경로 | | --- | --- | --- | | <Chat/> | 실시간 채팅 (가상 스크롤 · 로그인 연동 · slot 커스터마이징) | chats/… | | <Counter/> | 접속자 수 (배지 또는 텍스트) | rooms/… | | <HeartButton/> | 하트(좋아요) 버튼 + 떠오르는 애니메이션 | hearts/… | | <HeartCounter/> | 누적 하트 수만 텍스트로 | hearts/… | | <RoomEvents/> | 방 전체에 이벤트 전파 → 다 같이 동작 실행 | events/… | | <RealTimePrompt/> | 여러 화면이 공유하는 textarea | prompts/… |

<Chat/> — 실시간 채팅

import { useRef, useState } from "react";
import { Chat, type ChatHandle } from "@nowsoft-lab/firebase-chatkit";

function ChatRoom() {
  const chatRef = useRef<ChatHandle>(null);
  const [user, setUser] = useState<{ userId: string; nickname: string } | null>(null);

  return (
    <Chat
      ref={chatRef}
      channelId="chatChannel1"
      roomId="chat1"
      user={user}
      config={{
        limit: 100,
        fullHeight: true, // 세로 full (100dvh)
        sendBtn: "보내기",
        placeholder: "메시지를 입력하세요.",
        loginPlaceholder: "로그인이 필요합니다.",
        // 미로그인 상태에서 입력창 클릭 시 호출
        loginOpen: (authKey) => {
          const nickname = prompt("닉네임");
          if (nickname) {
            const session = { userId: nickname, nickname };
            setUser(session);
            // 인증 처리 후 authKey 와 함께 로그인 확정
            chatRef.current?.login(authKey, session);
          }
        },
      }}
      ui={{ sender: { sendBtn: "text-blue-500" } }}
    />
  );
}
  • 목록은 react-virtuoso 로 가상 스크롤되며, 새 메시지 도착 시 자동으로 하단에 고정됩니다.
  • 데이터 경로: chats/{channelId}/{roomId}

props

| prop | 타입 | 설명 | | --- | --- | --- | | channelId · roomId | string | 필수. 방 식별자 | | user | ChatUser \| null | 로그인 사용자. { userId, nickname?, uuid?, color? }. userId 가 있으면 로그인 상태 | | config | ChatConfig | 아래 표 참고 | | ui | ChatUi | 영역별 클래스 추가(기본 클래스에 append) | | className | string | 루트 클래스 추가 | | components | ChatComponents | 영역별 커스텀 컴포넌트(slot) | | firebaseConfig · database | — | Firebase 설정 참고 | | ref | ChatHandle | 명령형 핸들 |

config

| 키 | 기본값 | 설명 | | --- | --- | --- | | limit | 100 | 불러올 최근 메시지 수 (limitToLast) | | sendBtn | "보내기" | 전송 버튼 라벨 | | placeholder | "메시지를 입력하세요." | 로그인 상태 입력창 placeholder | | loginPlaceholder | "로그인이 필요합니다." | 미로그인 상태 placeholder | | fullHeight | false | true100dvh. 기본은 부모 높이(100%)를 채움 | | chatBottomStart | false | 메시지가 적을 때 하단부터 쌓기 | | chatListHide · chatSenderHide | false | 목록 / 입력 영역 숨김 | | loginOpen | — | (authKey: string) => void. 미로그인 상태에서 입력창 클릭 시 호출 |

로그인 핸드셰이크

로그인 방식은 두 가지이고, 둘 다 지원됩니다.

A. controlled — user prop 으로 직접 제어 (간단)

<Chat channelId="c1" roomId="r1" user={{ userId: "u1", nickname: "홍길동" }} />

B. 핸드셰이크 — 호스트 앱이 인증을 처리

  1. 미로그인 상태에서 사용자가 입력창을 클릭 → config.loginOpen(authKey) 호출
  2. 호스트가 로그인 UI 를 띄우고 인증 처리
  3. 받은 authKey 를 그대로 실어 ref.login(authKey, { userId, nickname, color? }) 호출 → 로그인 확정

authKey 가 발급된 값과 다르면 무시됩니다.

핸들 (ref)

| 메서드 | 설명 | | --- | --- | | login(authKey, data) | 로그인 확정 (위 핸드셰이크) | | showUi() · showConfig() · showLoginInfo() | 현재 ui / config / 로그인 정보를 콘솔에 출력 (디버그용) |

높이 (입력창 잘림 방지)

<Chat/> 은 flex 컬럼 레이아웃으로 헤더 · 입력창은 고정, 목록만 늘어나거나 줄어듭니다.

| 상황 | 설정 | | --- | --- | | 화면 전체를 채우는 채팅 | config={{ fullHeight: true }}height: 100dvh (미지원 브라우저는 100vh fallback) | | 지정한 부모 안에 넣기 (기본) | 부모에 높이를 주면 됨 (<div style={{ height: 480 }}>) — 채팅은 height: 100% |

모바일 주소창·툴바 때문에 100vh 가 실제 화면보다 커져 입력창이 잘리는 문제를 막기 위해, 컨테이너에는 항상 max-height: 100dvh 가 적용됩니다.

닉네임 색상

  • 참여 시 닉네임 색상이 랜덤 배정되고 로컬에 저장되어, 같은 사용자는 다음 방문에도 같은 색을 유지합니다.
  • 저장 키는 사용자별(COLOR:{userId})이라 같은 브라우저에서 닉네임이 바뀌면 색도 새로 뽑힙니다. 최근 배정된 색상들과 색상환에서 최소 40° 벌어진 hue 를 골라 비슷한 색이 겹치지 않게 합니다.
  • 배정된 색상은 메시지와 함께 저장(message.color)되므로 다른 참여자 화면에서도 같은 색으로 보입니다.
  • 직접 지정하려면 user.color 또는 ref.login(authKey, { userId, nickname, color }) 로 넘기면 그 값이 우선합니다.

커스텀 컴포넌트 (components)

헤더 · 메시지 · 푸터 · 전송버튼 · 빈 상태를 각각 교체할 수 있습니다. 미지정 영역은 기본 컴포넌트가 쓰입니다.

import {
  Chat,
  type ChatHeaderSlotProps,
  type ChatMessageSlotProps,
  type ChatSendButtonSlotProps,
  type ChatFooterSlotProps,
} from "@nowsoft-lab/firebase-chatkit";

<Chat
  channelId="c1"
  roomId="r1"
  components={{
    // 목록 상단 헤더 (기본: 없음)
    Header: ({ user, messageCount }: ChatHeaderSlotProps) => (
      <div className="header">라이브 · {messageCount}개</div>
    ),

    // 메시지 한 줄 (기본: ChatLine)
    Message: ({ message }: ChatMessageSlotProps) => (
      <div style={{ color: message.color }}>
        <b>{message.nickname}</b> {message.message}
      </div>
    ),

    // 전송 버튼 (기본 Footer 안에서 사용)
    SendButton: ({ disabled }: ChatSendButtonSlotProps) => (
      <button type="submit" disabled={disabled}>전송 ➤</button>
    ),

    // 메시지가 없을 때
    Empty: () => <div>아직 메시지가 없습니다.</div>,

    // 입력 영역 전체를 직접 구성 (form/input/버튼 모두 커스텀)
    Footer: ({ value, onChange, onSubmit, onInputClick, loggedIn, placeholder }: ChatFooterSlotProps) => (
      <form onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
        <input
          value={value}
          placeholder={placeholder}
          onChange={(e) => onChange(e.target.value)}
          onClick={onInputClick}
          readOnly={!loggedIn}
        />
        <button type="submit" disabled={!loggedIn}>전송</button>
      </form>
    ),
  }}
/>;

| slot | props | | --- | --- | | Header | channelId, roomId, user, messageCount | | Message | message, index, ui | | SendButton | disabled, label, className | | Empty | (없음) | | Footer | value, onChange, onSubmit, onInputClick, loggedIn, placeholder, ui, SendButton, sendButtonProps |

Footer 를 지정하지 않으면 기본 form + input + SendButton 이 쓰이며, 이때 SendButton 만 교체해 버튼 디자인만 바꿀 수 있습니다. Footer 를 직접 지정하면 입력 영역 전체를 제어합니다. 기본 컴포넌트(DefaultFooter · DefaultMessage · DefaultSendButton)도 export 되어 재사용/부분 확장이 가능합니다.

ui 클래스 영역

| 키 | 대상 | | --- | --- | | base | 루트 컨테이너 | | chat.base · chat.listBody · chat.line · chat.nickname · chat.message · chat.date | 목록 · 메시지 줄 · 닉네임 · 본문 · 시각 | | sender.base · sender.messageInput · sender.sendBtn | 입력 영역 · 입력창 · 전송 버튼 |

<Counter/> — 접속자 카운터

접속 시 presence 를 등록하고, 연결 종료(onDisconnect)·언마운트 시 자동 정리됩니다. 데이터 경로: rooms/{channelId}/{roomId}/{uuid}

floating (기본) — 화면 상/하단 고정 배지

import { Counter } from "@nowsoft-lab/firebase-chatkit";

<Counter
  channelId="channel1"
  roomId="room1"
  config={{
    placeholder: "%n명이 함께 보고있습니다.", // %n → 실시간 인원수
    position: "bottom",                        // "top"(기본) | "bottom"
    visibleCnt: 1,                             // 이 인원 이상일 때 노출
    timeout: 5000,                             // ms 후 자동 숨김 (100 이하면 계속 표시)
  }}
/>

floating 은 position: fixed 라 부모가 아니라 브라우저 창 기준으로 붙습니다.

inline — 문서 흐름 안에 텍스트만

variant: "inline" 이면 고정 위치·애니메이션·자동 숨김 없이 <span> 텍스트 하나만 렌더됩니다. 기본 스타일이 없으므로 className / style / ui.inline.base 로 원하는 대로 꾸밉니다.

<Counter
  channelId="chatChannel1"
  roomId="chat1"
  config={{ variant: "inline", placeholder: "%n명 참여중", visibleCnt: 0 }}
  className="text-xs font-bold"      // 클래스 추가
  style={{ color: "#7dd3fc" }}       // 인라인 스타일
/>

채팅 헤더에 참여 인원을 넣는 예 (components.Header slot 안에 그대로 넣습니다):

<Chat
  channelId="chatChannel1"
  roomId="chat1"
  components={{
    Header: ({ channelId, roomId, user }) => (
      <div className="flex items-center justify-between px-4 py-3 bg-slate-800 text-white">
        <strong>라이브 채팅</strong>
        <span className="text-xs opacity-70">
          {user.userId ?? "게스트"} ·{" "}
          <Counter
            channelId={channelId}
            roomId={roomId}
            config={{ variant: "inline", placeholder: "%n명 참여중", visibleCnt: 0 }}
            style={{ color: "#7dd3fc" }}
          />
        </span>
      </div>
    ),
  }}
/>

텍스트 자체를 컴포넌트로 바꾸려면 components.Text 를 넘깁니다(count, text props).

<Counter
  channelId="c1"
  roomId="r1"
  config={{ variant: "inline" }}
  components={{ Text: ({ count }) => <b>👥 {count.toLocaleString()}</b> }}
/>

config

| 키 | 기본값 | 설명 | | --- | --- | --- | | variant | "floating" | "floating" · "inline" | | placeholder | "%n명이 보고있습니다." | %n 이 실시간 인원수로 치환 | | visibleCnt | 1 | 이 값 이상일 때만 노출 (0 이면 항상) | | position | "top" | floating 전용. "top" · "bottom" | | timeout | 5000 | floating 전용. ms 후 자동 숨김. 100 이하면 계속 표시 | | showUi | true | 렌더 여부 |

props · ui

| prop | 용도 | | --- | --- | | className / style | 루트 요소 클래스 추가 · 인라인 스타일 | | ui.inline.base | inline 텍스트 클래스 (기본값 없음) | | ui.floating.base · ui.floating.text | floating 배지 · 텍스트 | | ui.base · ui.animated · ui.animationDuration | 컨테이너 · 애니메이션 클래스 · 지속시간 | | components.Text | 텍스트 렌더러 교체 (count, text) |

<HeartButton/> — 하트(좋아요) 버튼

누를 때마다 하트가 랜덤한 방향·크기·속도로 떠오르고, 누른 횟수가 RTDB 에 누적됩니다. <Counter/> 처럼 독립 컴포넌트라 채팅과 무관하게 어디서든 쓸 수 있습니다.

import { HeartButton } from "@nowsoft-lab/firebase-chatkit";

// 채팅 스크롤 영역 우하단에 띄우기 — 부모에 relative 만 주면 된다
<div className="relative h-[420px]">
  <Chat channelId="c1" roomId="r1" user={user} />
  {/* offsetY 로 입력창 높이만큼 띄워 스크롤 영역 하단에 놓는다 */}
  <HeartButton channelId="c1" roomId="r1" config={{ offsetY: 76 }} />
</div>
  • 연타해도 flushDelay(기본 1초) 마다 한 번만 기록합니다. 1초 동안 7번 누르면 쓰기 1회 · +7.
  • 화면 숫자는 누르는 즉시 올라가고(낙관적 반영), 기록은 모아서 나갑니다. 언마운트 · 탭 숨김 · 페이지 종료 시 대기분을 flush 합니다.
  • 동시 사용 폭주 대비 안전장치는 아래를 참고하세요.
  • 기본 버튼은 불투명 흰 원 + 빨간 하트라 채팅 위에 떠도 뒤 글자가 비치지 않는다(z-index: 20).
  • 데이터 경로: hearts/{channelId}/{roomId} (숫자 하나)

동시 사용 안전장치

많은 사람이 동시에 연타해도 RTDB 쓰기가 폭주하지 않도록 세 가지가 걸려 있습니다.

  1. 낙관적 반영 — 내가 누른 수는 즉시 내 화면에 더해집니다. 기록이 늦어도 체감되지 않습니다.
  2. 서버측 원자 증가(increment) — 읽고-더하고-쓰는 왕복이 없어 동시에 눌러도 경합 재시도나 값 유실이 없습니다.
  3. 누적 보고 횟수 기반 백오프 — 기록을 보낼 때마다 다음 기록까지의 간격이 flushBackoff 만큼 늘어납니다(상한 maxFlushDelay). 오래 연타하는 클라이언트일수록 쓰기 빈도가 낮아지고, 한 번에 더 많은 하트를 묶어 보냅니다.

백오프는 총 하트가 backoffThreshold(기본 100) 이상일 때만 걸립니다. 표시가 100+ · 200+ 로 뭉뚱그려지는 구간부터라 기록이 늦어도 화면상 차이가 없기 때문입니다. backoffResetAfter(기본 15초) 동안 누르지 않으면 보고 횟수가 초기화되어 다시 즉각 반응합니다.

실측(21초 동안 150ms 간격으로 계속 누름):

| | 값 | | --- | --- | | 누른 횟수 | 142 | | RTDB 증가 | 142 (유실 0) | | 실제 쓰기 횟수 | 9회 | | 쓰기 간격 | 1.5s → 2.0s → 2.6s → 3.0s → 3.7s 로 증가 | | 1회당 묶인 하트 | +7 → +13 → +18 → +26 으로 증가 |

카운트 표시

config.showCount 로 버튼 하단 숫자의 표시 여부를, config.compactCount 로 정밀도를 정합니다. 기본값은 100 미만이면 정확한 수, 그 이상은 단위로 뭉뚱그려 정확한 값을 감춥니다.

| 실제 값 | 기본 (compactCount: true) | compactCount: false | | --- | --- | --- | | 87 | 87 | 87 | | 100 | 100+ | 100 | | 342 | 300+ | 342 | | 999 | 900+ | 999 | | 1,480 | 1k+ | 1,480 | | 2,400,000 | 2m+ | 2,400,000 |

config

| 키 | 기본값 | 설명 | | --- | --- | --- | | burst | 1 | 한 번 누를 때 발사되는 하트 개수 | | flushDelay | 1000 | 누른 횟수를 모아 기록하기까지의 기본 지연(ms) | | flushBackoff | 400 | 기록 1회마다 다음 지연에 더해지는 시간(ms) | | maxFlushDelay | 8000 | 백오프로 늘어나는 지연의 상한(ms) | | backoffThreshold | 100 | 총 하트가 이 값 이상일 때만 백오프 적용 | | backoffResetAfter | 15000 | 이 시간(ms) 동안 안 누르면 보고 횟수 초기화 | | duration | 1200 | 하트가 떠오르는 시간(ms). 개별 하트는 이 값의 ±20% 로 랜덤 | | distance | 170 | 떠오르는 높이(px). 개별 하트는 랜덤 가중 | | colors | 레드/핑크 5색 | 하트 색상 후보. 발사할 때마다 랜덤 선택 | | heartSize | 28 | 떠오르는 하트 크기(px). 개별 하트는 끝나면서 0.8~1.5배까지 랜덤으로 커진다 | | showCount | true | 버튼 하단 카운트 표시 | | compactCount | true | 100 이상을 300+ · 1k+ · 2m+ 로 축약 | | maxParticles | 40 | 동시에 떠 있을 수 있는 하트 수 | | variant | "floating" | "floating"(부모 기준 absolute 우하단) · "inline"(문서 흐름) | | side | "right" | floating 전용. "left" · "right" | | offsetX · offsetY | 16 | floating 전용 여백(px). 채팅 위에 얹을 땐 입력창 높이만큼(예: offsetY: 76) 띄운다 | | disabled | false | 발사·기록 모두 중단 | | onPress | — | (pending: number) => void. 누를 때마다 호출 | | onChange | — | (total: number) => void. 총 하트 수가 바뀔 때 |

props · ui · slot · 핸들

| prop | 용도 | | --- | --- | | className | 루트 클래스 추가 | | style | 루트 인라인 스타일. floating 위치(offsetX/offsetY)보다 우선 | | ui.base · ui.button · ui.icon · ui.count · ui.particle | 루트 · 버튼 · 아이콘 · 카운트 · 떠오르는 하트 |

| slot | props | | --- | --- | | Button | onPress, disabled, total, className, children(기본 아이콘) | | Icon | total, pressed, className | | Count | total, text, className | | Particle | color, className |

<HeartButton
  channelId="c1"
  roomId="r1"
  config={{ variant: "inline", burst: 3, colors: ["#facc15", "#f97316"] }}
  ui={{ button: "h-14 w-14 rounded-2xl bg-amber-400" }}
  components={{
    Icon: ({ pressed }) => <span>{pressed ? "🌟" : "⭐"}</span>,
    Particle: ({ color, className }) => (
      <span className={className} style={{ color }}>★</span>
    ),
  }}
/>

| 핸들 | 설명 | | --- | --- | | press(count?) | 프로그램에서 하트 발사 (기본 config.burst) | | getTotal() | 현재 총 하트 수(대기분 포함) | | flush() | 대기 중인 하트를 즉시 기록 |

기본 slot(DefaultHeartButton · DefaultHeartIcon · DefaultHeartCount · DefaultHeartParticle)도 export 되어 부분 확장이 가능합니다. UI 없이 수치만 쓰려면 useHearts(db, channelId, roomId, { flushDelay }) 훅을 직접 사용하세요.

<HeartCounter/> — 하트 수만 표시

버튼 없이 누적 하트 수만 텍스트로 렌더합니다. <Counter/> 의 inline 과 같은 형태라 고정 위치·애니메이션·기본 스타일이 없고 <span> 하나만 그립니다. (플로팅은 제공하지 않습니다 — 하트를 보내는 UI 가 필요하면 <HeartButton/> 을 쓰세요.)

import { HeartCounter } from "@nowsoft-lab/firebase-chatkit";

// 문장 안에
<p>지금까지 <HeartCounter channelId="c1" roomId="r1" /> 개의 하트가 모였습니다.</p>

// 배지처럼
<HeartCounter
  channelId="c1"
  roomId="r1"
  config={{ placeholder: "❤️ %n" }}
  className="rounded-full bg-red-50 px-2 py-1 text-xs font-bold text-red-600"
/>

// 렌더러 교체 · 정확한 수
<HeartCounter
  channelId="c1"
  roomId="r1"
  config={{ compactCount: false }}
  components={{
    Text: ({ text }) => <b className="text-2xl text-red-500">{text}</b>,
  }}
/>

config

| 키 | 기본값 | 설명 | | --- | --- | --- | | placeholder | "%n" | %n 이 표시 형식이 적용된 하트 수로 치환 | | visibleCnt | 0 | 이 값 이상일 때만 노출 | | compactCount | true | 100 이상을 300+ · 1k+ · 2m+ 로 축약 (표시 규칙) | | showUi | true | 렌더 여부 | | onChange | — | (total: number) => void |

| prop | 용도 | | --- | --- | | className / style | 클래스 추가 · 인라인 스타일 | | ui.base | 텍스트 클래스 (기본값 없음) | | components.Text | 텍스트 렌더러 교체 (count, text) |

  • 읽기 전용입니다. 구독만 하고 쓰기는 하지 않습니다.
  • 데이터 경로: hearts/{channelId}/{roomId}<HeartButton/> 과 같은 값을 봅니다.
  • 표시 문자열만 필요하면 formatHeartCount(value, compact?) 를 직접 쓸 수 있습니다.

<RoomEvents/> — 이벤트 브로드캐스트

한쪽에서 이벤트를 보내면 같은 방을 보고 있는 모든 화면이 수신해 동작을 실행합니다. 알림 띄우기, 화면 전환, 효과 재생 같은 "다 같이 반응해야 하는" 동작에 씁니다.

import { useRef } from "react";
import { RoomEvents, type RoomEventsHandle } from "@nowsoft-lab/firebase-chatkit";

const events = useRef<RoomEventsHandle>(null);

<RoomEvents
  ref={events}
  channelId="c1"
  roomId="r1"
  handlers={{
    // type 별로 실행할 동작
    alert: (event) => toast(String(event.message)),
    theme: (event) => setTheme(String(event.color)),
  }}
/>

// 보내기 — 이 방을 보고 있는 모든 화면에서 alert 핸들러가 실행된다
events.current?.send({ type: "alert", message: "안녕" });

// 배열로 보내면 한 번의 쓰기로 모두 전달된다
events.current?.send([
  { type: "theme", color: "#0f766e" },
  { type: "alert", message: "테마와 알림을 한 번에" },
]);

<RoomEvents/> 는 기본적으로 아무것도 렌더하지 않는 브리지입니다. 수신 목록으로 UI 를 그리려면 children 에 함수를 넘기세요.

<RoomEvents channelId="c1" roomId="r1">
  {({ events, last, send }) => <div>마지막 이벤트: {last?.event.type}</div>}
</RoomEvents>

이벤트 형식

type(문자열)만 필수이고 나머지 필드는 자유입니다. 객체가 RTDB 에 그대로 저장되므로 undefined 값·함수는 넣을 수 없습니다.

수신 콜백은 (event, meta) 를 받습니다.

| meta | 설명 | | --- | --- | | id | RTDB key | | uid | 보낸 탭 ID | | at | 보낸 시각(ms) | | self | 내가 보낸 이벤트인지 |

config

| 키 | 기본값 | 설명 | | --- | --- | --- | | path | "events" | 데이터 루트 경로 | | history | 20 | 들고 있을 최근 이벤트 수 | | receiveSelf | true | 내가 보낸 이벤트도 수신할지 | | types | — | 이 종류만 수신 (미지정이면 전부) | | keep | 50 | RTDB 에 남겨둘 최대 이벤트 수. 보낼 때 주기적으로 오래된 것을 정리. 0 이면 정리 안 함 |

| 핸들 | 설명 | | --- | --- | | send(event \| event[]) | 방 전체에 전송 | | getEvents() | 최근 수신 목록 | | clear() | 저장된 이벤트 전부 삭제 |

  • 접속 전에 쌓여 있던 이벤트는 재생하지 않습니다. 새로 연 화면에서 과거 알림이 다시 뜨지 않습니다.
  • 훅만 쓰려면 useRoomEvents(db, channelId, roomId, options){ send, events, last, clear, ready }.
  • 데이터 경로: events/{channelId}/{roomId}/{key} = { data: <보낸 객체>, uid, at }

<RealTimePrompt/> — 공유 프롬프트

같은 channelId / roomId 를 보는 모든 화면이 내용을 공유하는 textarea 입니다. 한쪽에서 입력하면 다른 화면에 그대로 반영됩니다(프롬프터 · 공동 편집 · 운영자 공지 등).

import { useRef } from "react";
import { RealTimePrompt, type RealTimePromptHandle } from "@nowsoft-lab/firebase-chatkit";

const promptRef = useRef<RealTimePromptHandle>(null);

<RealTimePrompt
  ref={promptRef}
  channelId="promptChannel1"
  roomId="prompt1"
  config={{
    debounce: 300,        // 입력 후 기록까지 지연 ms (기본 300)
    rows: 8,              // 기본 6
    maxLength: 2000,
    placeholder: "여기에 입력하면 실시간으로 공유됩니다.",
    autoGrow: true,       // 내용에 맞춰 높이 자동 조절
    showStatus: true,     // 하단 글자 수 · 마지막 수정 시각 (기본 true)
    onChange: (value) => console.log(value),
  }}
/>;

// 명령형 핸들
promptRef.current?.setValue("값 덮어쓰기"); // 다른 참여자에게도 전파
promptRef.current?.clear();
promptRef.current?.focus();
promptRef.current?.getValue();

읽기 전용 뷰어(프롬프터 화면)는 readOnly 만 켜면 됩니다.

<RealTimePrompt
  channelId="promptChannel1"
  roomId="prompt1"
  config={{ readOnly: true, showStatus: false }}
/>

config

| 키 | 기본값 | 설명 | | --- | --- | --- | | debounce | 300 | 입력 후 RTDB 기록까지 지연(ms) | | rows | 6 | textarea 줄 수 | | maxLength | — | 최대 글자 수 | | placeholder | "여기에 입력하면 실시간으로 공유됩니다." | | | readOnly | false | 수신만 하고 편집은 막음 | | autoGrow | false | 내용에 맞춰 높이 자동 조절 | | showStatus | true | 하단 글자 수 · 마지막 수정 시각 | | onChange | — | 값 변경 시 호출(로컬 입력 · 원격 수신 모두) | | view · showViewSettings · viewStorageKey · onViewChange | — | 아래 표시 설정 |

표시 설정 — 글자 크기 · 배경 · 글자색

config.view 로 기본값을 주고, showViewSettings 로 사용자가 직접 조절하는 UI 를 켭니다. 사용자가 바꾼 값은 localStorage 에 저장되어 새로고침 후에도 유지되며, 저장값이 config.view 기본값보다 우선합니다.

<RealTimePrompt
  channelId="promptChannel1"
  roomId="prompt1"
  config={{
    readOnly: true,
    showViewSettings: true,   // 글자 크기 슬라이더 + 배경/글자색 picker + 초기화
    view: {                   // props 기본값
      fontSize: 22,           // px (12~72 로 clamp)
      background: "#0f172a",
      color: "#6ee7b7",
    },
    viewStorageKey: "viewer", // 저장 키 접미사 (기본: `{channelId}:{roomId}`)
    onViewChange: (view) => console.log(view),
  }}
/>

| | 내용 | | --- | --- | | 우선순위 | localStorage 저장값 → config.view → 라이브러리 기본값(14px / #ffffff / #111827) | | 저장 위치 | localStorage["firebase-chatkit:prompt-view:{viewStorageKey}"] | | 초기화 | 기본 UI 의 "초기화" 버튼 또는 ref.resetView() — 저장값을 지우고 config.view 로 되돌립니다 | | 명령형 제어 | ref.getView() · ref.setView({ fontSize: 32 }) · ref.resetView() | | UI 교체 | components.ViewSettings slot (view, update, reset, className) · 영역 클래스는 ui.settings | | 훅만 사용 | usePromptView(storageKey, defaults){ view, update, reset } |

설정값은 textarea 의 인라인 스타일로 적용되므로, 색·크기는 ui.textarea 클래스가 아니라 config.view 로 지정하세요.

핸들 · slot

| 핸들 | 설명 | | --- | --- | | getValue() · setValue(v) · clear() | 값 읽기 / 덮어쓰기(전파) / 비우기 | | focus() | textarea 포커스 | | getView() · setView(patch) · resetView() | 표시 설정 |

| slot | props | | --- | --- | | Header | channelId, roomId, length, updatedAt, updatedByOther | | Textarea | value, onChange, placeholder, rows, maxLength, readOnly, className, style, textareaRef | | Status | length, maxLength, updatedAt, ready, updatedByOther, className | | ViewSettings | view, update, reset, className |

커스텀 Textarea 는 받은 textareaRef 를 실제 <textarea> 요소에 그대로 넘겨야 커서 복원 · autoGrow · focus() 가 동작합니다.

동작 규칙

  • 자기 자신이 쓴 변경은 되돌려 받지 않고(uid 비교), 입력은 debounce1회만 기록해 쓰기 횟수를 줄입니다. 언마운트 시 대기 중이던 입력은 flush 됩니다.
  • 원격 변경이 도착해도 입력 중이던 커서 위치를 복원합니다.
  • 동시에 같은 위치를 편집하면 마지막 기록이 이깁니다(last-write-wins).

스타일링

컴포넌트는 Tailwind 유틸리티 클래스를 사용하며, 빌드된 style.css 에 필요한 CSS(Tailwind 유틸 + 커스텀 클래스 + Counter 애니메이션)가 모두 포함되어 있어 소비 앱에 Tailwind 가 없어도 그대로 동작합니다. 런타임 CSS 의존성은 없습니다.

여러분의 전역 스타일은 건드리지 않습니다

style.css 에는 Tailwind Preflight(전역 리셋)가 들어 있지 않습니다. body { margin: 0 } · h1~h6 { font-size: inherit } · img, svg { display: block } 같은 규칙이 여러분 앱 전체에 적용되는 일은 없습니다.

컴포넌트가 실제로 필요로 하는 리셋(box-sizing, 테두리 기본값, 폼 요소 글꼴 상속 등)은 라이브러리 루트 요소인 .fbck-root 하위로만 적용되고, 전부 :where() 로 감싸 특이성이 0 입니다. 따라서 여러분이 준 className · ui 클래스가 언제나 이깁니다.

ui prop 은 기본 클래스를 덮어쓰지 않고 뒤에 추가(append) 합니다. 기본값을 이기려면 더 구체적인 클래스나 !important(Tailwind ! 접두사)를 쓰세요.

<Chat ui={{ chat: { nickname: "text-emerald-600" }, sender: { base: "p-4" } }} />

RTDB 데이터 구조 · 보안 규칙

| 컴포넌트 | 경로 | 값 | | --- | --- | --- | | <Chat/> | chats/{channelId}/{roomId}/{key} | { type: "msg", message, nickname, color, date, uid } | | <Counter/> | rooms/{channelId}/{roomId}/{tabId} | presence — 탭 하나당 1개, 연결 종료 시 자동 삭제 | | <HeartButton/> · <HeartCounter/> | hearts/{channelId}/{roomId} | 누적 하트 수 (숫자) | | <RoomEvents/> | events/{channelId}/{roomId}/{key} | { data: <보낸 객체>, uid, at } | | <RealTimePrompt/> | prompts/{channelId}/{roomId} | { value, uid, date } |

세 경로 모두 읽기·쓰기 권한이 필요합니다. 인증 없이 쓰는 최소 예시(공개 데모용):

{
  "rules": {
    "chats":   { ".read": true, ".write": true },
    "rooms":   { ".read": true, ".write": true },
    "hearts":  { ".read": true, ".write": true },
    "events":  { ".read": true, ".write": true },
    "prompts": { ".read": true, ".write": true }
  }
}

실서비스에서는 Firebase Auth 와 함께 ".write": "auth != null" 등으로 반드시 좁히세요. 위 규칙은 누구나 읽고 쓸 수 있습니다.

훅만 사용하기

UI 없이 데이터만 필요하면 훅을 직접 씁니다.

import { resolveDatabase, useRealTimePrompt, usePromptView } from "@nowsoft-lab/firebase-chatkit";

const db = resolveDatabase();
const { value, setValue, ready, updatedAt, updatedByOther } =
  useRealTimePrompt(db, "promptChannel1", "prompt1", { debounce: 300 });

const { view, update, reset } = usePromptView("my-key", { fontSize: 20 });

export 목록

| 구분 | 이름 | | --- | --- | | 컴포넌트 | Chat · Counter · HeartButton · HeartCounter · RoomEvents · RealTimePrompt · ChatLine | | 기본 slot | DefaultFooter · DefaultMessage · DefaultSendButton · DefaultHeartButton · DefaultHeartIcon · DefaultHeartCount · DefaultHeartParticle · DefaultPromptTextarea · DefaultPromptStatus · DefaultPromptViewSettings | | Firebase | initFirebaseChatkit · resolveDatabase | | 훅 | useHearts · useRoomEvents · useRealTimePrompt · usePromptView | | 유틸 | formatHeartCount | | 상수 | DEFAULT_PROMPT_VIEW · PROMPT_VIEW_FONT_SIZE_MIN · PROMPT_VIEW_FONT_SIZE_MAX | | 타입 | ChatProps · ChatHandle · ChatUser · ChatConfig · ChatUi · ChatMessage · ChatComponents · Chat*SlotProps · CounterProps · CounterConfig · CounterUi · CounterComponents · CounterTextSlotProps · HeartButtonProps · HeartButtonHandle · HeartButtonConfig · HeartButtonUi · HeartButtonComponents · HeartButton*SlotProps · HeartCounterProps · HeartCounterConfig · HeartCounterUi · HeartCounterComponents · HeartCounterTextSlotProps · RoomEvent · RoomEventMeta · RoomEventRecord · RoomEventHandlers · RoomEventsConfig · RoomEventsProps · RoomEventsHandle · RealTimePromptProps · RealTimePromptHandle · RealTimePromptConfig · RealTimePromptUi · RealTimePromptComponents · RealTimePrompt*SlotProps · RealTimePromptView · ResolvedRealTimePromptView · FirebaseChatkitConfig · FirebaseInjectable |

FAQ · 트러블슈팅

Next.js 에서 window is not defined / hydration 오류가 나요 서버에서 렌더되고 있는 경우입니다. 파일 맨 위에 "use client" 를 붙이거나 next/dynamic({ ssr: false }) 로 감싸세요.

이미 firebase 를 쓰고 있는 앱인데 충돌하지 않나요? 이 라이브러리는 firebase-chatkit:{databaseURL} 이라는 전용 named app 을 따로 만들어 쓰므로 기존 initializeApp() 인스턴스와 섞이지 않습니다. databaseURL 이 이름에 들어가므로 서로 다른 데이터베이스를 쓰는 컴포넌트가 한 화면에 있어도 각자 올바른 곳에 붙습니다. 같은 인스턴스를 쓰고 싶으면 database prop 으로 직접 넘기세요.

여러분의 localStorage 를 오염시키지 않나요? 이 라이브러리가 쓰는 키는 전부 firebase-chatkit: 으로 시작합니다(firebase-chatkit:chat:uuid 등). 전역 설정을 바꾸는 저장소 라이브러리에 의존하지 않습니다.

아무것도 렌더되지 않아요 initFirebaseChatkit() 이 컴포넌트 렌더보다 먼저 호출됐는지, databaseURL 이 맞는지 확인하세요. 셋 다 없으면 에러를 던집니다.

PERMISSION_DENIED 가 뜹니다 RTDB 보안 규칙에 chats / rooms / prompts 경로 권한이 있는지 확인하세요(위 규칙 예시).

스타일이 깨져 보여요 import "@nowsoft-lab/firebase-chatkit/style.css"; 를 빠뜨리지 않았는지 확인하세요.

style.css 를 넣었더니 우리 앱 스타일이 바뀌었어요 그럴 일이 없습니다 — style.css 에는 전역 리셋(Preflight)이 들어있지 않고, 리셋은 .fbck-root 하위로만 적용됩니다(스타일링 참고). 그래도 증상이 있다면 이슈로 알려주세요.

입력창이 화면 밖으로 잘려요 config.fullHeight: true 를 쓰거나, 부모에 명시적인 높이를 주세요. 부모 높이가 없으면 height: 100% 가 0 이 됩니다.

카운터 숫자가 실제보다 많아요 같은 사용자가 여러 탭을 열면 탭마다 presence 가 등록됩니다. 연결이 끊기면 onDisconnect 로 정리되지만 네트워크 상황에 따라 수 초 지연될 수 있습니다.

네트워크가 끊겼다 붙으면 카운터에서 빠지지 않나요? 빠지지 않습니다. .info/connected 를 구독해 연결이 살아날 때마다 presence 를 다시 등록하므로, 지하철·화면 잠금 등으로 잠깐 끊겨도 복구되면 다시 집계됩니다.

라이선스

MIT