@nowsoft-lab/firebase-chatkit
v0.1.1
Published
Firebase Realtime Database 기반 실시간 채팅 / 접속자 카운터 React 컴포넌트
Maintainers
Readme
@nowsoft-lab/firebase-chatkit
Firebase Realtime Database 하나로 동작하는 실시간 채팅 · 접속자 카운터 · 하트 버튼 · 이벤트 브로드캐스트 · 공유 프롬프트 React 컴포넌트 모음입니다. 백엔드 서버를 따로 두지 않고, 내 프로젝트에 컴포넌트를 붙이는 것만으로 실시간 기능을 넣을 수 있습니다.
npm install @nowsoft-lab/firebase-chatkit firebase react react-domimport "@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 으로 클래스 추가 |
목차
- 설치
- Firebase 설정
- 빠른 시작
<Chat/>— 실시간 채팅<Counter/>— 접속자 카운터<HeartButton/>— 하트(좋아요) 버튼<HeartCounter/>— 하트 수만 표시<RoomEvents/>— 이벤트 브로드캐스트<RealTimePrompt/>— 공유 프롬프트- 스타일링
- RTDB 데이터 구조 · 보안 규칙
- 훅만 사용하기
- export 목록
- FAQ · 트러블슈팅
설치
npm install @nowsoft-lab/firebase-chatkit firebase react react-domreact(>=18), react-dom(>=18), firebase(>=10) 는 peerDependency 입니다. 번들에 포함되지 않으므로 여러분 앱의 인스턴스를 그대로 씁니다.
시작하려면 세 가지만 하면 됩니다.
- 스타일 import — 앱 진입점에서 한 번
import "@nowsoft-lab/firebase-chatkit/style.css"; - Firebase 연결 — Firebase 설정
- 보안 규칙 등록 — 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 | true 면 100dvh. 기본은 부모 높이(100%)를 채움 |
| chatBottomStart | false | 메시지가 적을 때 하단부터 쌓기 |
| chatListHide · chatSenderHide | false | 목록 / 입력 영역 숨김 |
| loginOpen | — | (authKey: string) => void. 미로그인 상태에서 입력창 클릭 시 호출 |
로그인 핸드셰이크
로그인 방식은 두 가지이고, 둘 다 지원됩니다.
A. controlled — user prop 으로 직접 제어 (간단)
<Chat channelId="c1" roomId="r1" user={{ userId: "u1", nickname: "홍길동" }} />B. 핸드셰이크 — 호스트 앱이 인증을 처리
- 미로그인 상태에서 사용자가 입력창을 클릭 →
config.loginOpen(authKey)호출 - 호스트가 로그인 UI 를 띄우고 인증 처리
- 받은
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 쓰기가 폭주하지 않도록 세 가지가 걸려 있습니다.
- 낙관적 반영 — 내가 누른 수는 즉시 내 화면에 더해집니다. 기록이 늦어도 체감되지 않습니다.
- 서버측 원자 증가(
increment) — 읽고-더하고-쓰는 왕복이 없어 동시에 눌러도 경합 재시도나 값 유실이 없습니다. - 누적 보고 횟수 기반 백오프 — 기록을 보낼 때마다 다음 기록까지의 간격이
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비교), 입력은debounce후 1회만 기록해 쓰기 횟수를 줄입니다. 언마운트 시 대기 중이던 입력은 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
