메신저 (Messenger)
앱 안의 사내 메신저입니다. 한 컴포넌트가 두 모드로 삽니다 — 직원 앱의 전체화면과 나머지 일곱 앱의 떠 있는 채팅 위젯.
Alpine 컴포넌트 · Web · 진행 중(WIP) · 문서 포맷 시범 적용
Interactive playground
hostHeader 가 무엇을 없애는가 둘입니다. 화면 상태를 「채팅 상세」로 바꾸고 hostHeader 를 켜 보세요.// 위젯 모드 — 앱은 chatWidget 을 쓴다. 안에서 messengerComponent 를 만들어
// **spread 가 아니라 mutate 로** 위젯 속성을 얹는다(getter 를 살리려고).
const component = messengerComponent({ sectionName: "messenger" });
component.isOpen = false; // ⚠️ { ...component, isOpen: false } 로 하면 getter 가 죽는다
<div x-data="chatWidget()">
<!-- include "../../packages/components/html/chat-widget.html" -->
</div>
// hostHeader 는 넘기지 않는다(기본 false) — 채팅 상세가 자기 헤더 줄을 그린다위젯 모드 · 대화 목록 — 7개 앱이 이 모양입니다(chat-widget 안)
연락처 — 같은 줄의 탭으로 전환합니다. 이 탭은 SubTab 이 아니라 TabNav(ml · divider 없음) 입니다
위젯 · 채팅 상세 — hostHeader: false. 자기 헤더 줄(뒤로가기)이 있습니다
⚠️ 위젯 · 채팅 상세 · hostHeader: true — 뒤로가기가 사라져 빠져나올 수 없습니다
전체화면 · 채팅 상세 · hostHeader: true — 여기서는 호스트 앱바가 뒤로가기를 맡으므로 정상입니다
헤더 줄이 없습니다. 전체화면 모드에서는 호스트 앱바가 뒤로가기·제목을 맡습니다 — 위젯 모드에서 이걸 켜면 대화방에서 빠져나올 방법이 사라집니다.
hostHeader 는 「호스트가 앱바를 그린다」는 약속이라, 그 앱바가 없는 위젯에서 켜면 화면이 막힙니다. 그래서 기본값이 false 입니다. 껍데기만 옮겼습니다
파셜이 1,164줄, 컴포넌트가 1,996줄입니다. 문서가 정해야 할 것은 대화 기능이 아니라 모드에 따라 무엇이 달라지는가라서, 서브내비 줄과 채팅 상세 헤더에 집중했습니다.
규칙 (강제) · 코딩 에이전트용
Messenger 강제 규칙. 상세는 아래 본문.
⚠️ 트랩 ① hostHeader 를 위젯에서 켜지 마세요 — 나올 방법이 사라집니다hostHeader: true 는 「호스트가 상단 앱바를 그린다」는 약속입니다. 켜면 채팅·공지 상세가 자기 헤더 줄을 안 그립니다(뒤로가기·제목·검색·멤버). 데스크톱 채팅 위젯에는 상단 헤더가 아예 없어서, 켜는 순간 대화방에서 빠져나올 수 없습니다. 기본값은 반드시 false 입니다.
⚠️ 트랩 ② 컴포넌트를 spread 로 감싸지 마세요 — getter 가 죽습니다ChatWidget은 messengerComponent() 결과를 mutate 해서 위젯 속성을 얹습니다. { ...component, isOpen: false } 로 하면 filteredConversations · departments · chatInviteContacts 같은 getter 가 그 자리에서 한 번 평가돼 고정값이 됩니다. 목록이 갱신되지 않는 조용한 고장이 납니다.
⚠️ 트랩 ③ 「대화 / 연락처」 전환은 SubTab 이 아니라 TabNav 입니다 이름(messengerSubTabs · messengerSubTabActive · switchMessengerSubTab)과 타입(SubTabItem[])이 전부 SubTab 이라고 말하지만, 마크업은 tabNav({ size: 'ml', divider: false }) 를 include 합니다. SubTab 규칙을 적용하려 하지 마세요 — TabNav 쪽입니다.
⚠️ 트랩 ④ 이 컴포넌트의 CSS 는 파셜 안 <style> 에 있습니다design-system.css 도, 앱 main.css 도 아닙니다 — messenger-content.html 첫머리의 <style> 블록입니다(「모든 앱에서 동작하도록 심었다」는 주석). 그래서 DS 로드 체인 밖이고 하드코딩 hex 가 그대로 남아 있습니다(아래).
알림은 DS 다이얼로그를 씁니다askConfirm · showAlert · showError (../dialog). 네이티브 confirm()/alert() 를 되살리지 마세요.
- 다크 모드(2026-10-19) — 공용
messenger-content.html의 팔레트 유틸 204곳을 시멘틱으로 옮겼다: 루트 흰 면bg-white→bg-bg-container(이 한 줄이 직원 앱 메신저 탭 전체를 흰색으로 남겼었다), 대화방 바탕 · 날짜 구분선(bg-line-alternative) · 탭 줄.messenger-subnav-bar(--ds-bw-white→bg-container) · 아바타 · 공지 배지. 남은 7곳은 보내기 버튼의 브랜드 주황과 주황 말풍선 위 반투명 hover(bg-white/20)로 테마 불변 자리다. 직원 앱 셸이 메신저 탭에서 하단 내비 그라데이션 끝 색으로 넘기던 고정 흰색도bg-container로(10-20). 상태 면도 테마를 따른다(10-20): 연결 상태 띠(연결 중 =status-warning-bg/-fg, 끊김 =status-error-bg·status-error), 방장 나가기 경고 원(status-error-bg·-icon), 「나가기」 hover(status-error-bg), 공지 목록 중요도 타일(긴급 =status-error-*, 보통 =status-warning-*), 필수(부서) 방 썸네일(bg-secondary-purple-bg·text-secondary-purple), 공지 상세 「확인 완료」 띠(status-success-*). 연한 팔레트 50단계 면(bg-red-50등)을 직접 쓰면 다크에서 파스텔 면으로 튄다. 확인: Playground 「미리보기 테마 → 다크」.
두 모드
한 컴포넌트가 두 껍데기 안에서 삽니다.
| 전체화면 | 떠 있는 위젯 | |
|---|---|---|
| 쓰는 앱 | 직원 앱 하나 | 나머지 7개 |
| 껍데기 | 앱 셸(app-shell.html)이 직접 include | ChatWidget — FAB + 패널 |
| 등록 이름 | messengerShared / 앱의 _messenger | chatWidget |
sectionName | "messenger" (뒤로가기 라우팅) | "messenger" |
hostHeader | true — 앱바가 있음 | false — 앱바가 없음 |
| 추가로 하는 일 | pull-to-refresh · 라우팅 | FAB · 미확인 배지 · 겹침 방어 |
hostHeader 가 위험한 이유
위젯에서 켜면 대화방에서 못 빠져나옵니다
hostHeader: true 는 **「뒤로가기·제목·검색·멤버는 호스트 헤더가 맡는다」**는 뜻이라, 채팅·공지 상세가 자기 헤더 줄을 그리지 않습니다.
전체화면(직원 앱)에는 그 앱바가 실제로 있어서 정상입니다. 위젯에는 상단 헤더가 아예 없습니다 — 켜는 순간 대화방에 갇힙니다.
그래서 기본값이 false 이고, 위젯 쪽은 이 옵션을 아예 넘기지 않습니다.
위 Playground 의 모아보기에서 네 번째와 다섯 번째가 같은 옵션인데 한쪽만 고장 난 것을 나란히 볼 수 있습니다.
위젯이 컴포넌트를 감싸는 법
ChatWidget은 상속이나 spread 가 아니라 mutate 로 얹습니다.
const component = messengerComponent({ sectionName: "messenger" });
component.isOpen = false; // ✅ getter 살아 있음
// { ...component, isOpen: false } // ❌ getter 가 한 번 평가돼 고정값이 된다spread 는 getter 를 죽입니다
filteredConversations · departments · chatInviteContacts 처럼 매번 다시 계산돼야 하는 값들이 getter 입니다. 스프레드하면 그 자리에서 한 번 평가된 정적 값이 되어, 대화가 와도 목록이 안 바뀝니다. 에러는 안 납니다.
init 도 덮어쓰지 않고 원래 것을 먼저 부른 뒤(baseInit?.call(this)) 위젯 일을 더합니다.
「대화 / 연락처」 전환
이름은 전부 SubTab 인데 실제로 렌더되는 것은 TabNav 입니다.
| 보이는 이름 | 실제 |
|---|---|
messengerSubTabs: SubTabItem[] | tabNav({ getTabs: … }) 에 넘어감 |
messengerSubTabActive | getActive |
switchMessengerSubTab(id) | onSelect |
옵션은 size: 'ml' · divider: false 이고, nav 크롬(마진·배경·하단선)은 파셜 <style> 이 걷어냅니다 — 활성 탭 밑줄만 남기고 「그룹 대화」 버튼과 한 줄에 앉히기 위해서입니다. 줄 자체(.messenger-subnav-bar)가 자기 border-b 를 갖기 때문에 divider: false 입니다.
SubTabItem 과 TabNavItem 이 구조가 비슷해 타입 검사를 통과할 뿐이니, SubTab 규칙을 여기에 적용하지 마세요.
알림 처리
askConfirm · showAlert · showError — DS 다이얼로그입니다. 방 나가기·강퇴·해산처럼 되돌릴 수 없는 동작이 많은 화면이라 확인 팝업이 여럿 있습니다. 네이티브 confirm() 으로 되돌리지 마세요(금지).
화면
activeView 하나가 다섯 화면을 가릅니다.
| 값 | 화면 |
|---|---|
conversations | 대화 목록 (기본) |
contacts | 연락처 — 팀별 묶음 |
chat | 채팅 상세 |
noticeDetail | 공지 상세 |
contactProfile | 연락처 프로필 — 1:1 채팅 · 전화하기 (직원 앱 2depth) → 모바일 대화 화면 |
대화 표시명
buildConversationItem()(messenger-helpers.ts 의 순수 함수)이 정합니다. 우선순위가 있습니다.
customName > title > (종류별 기본값)| 종류 | 기본값 | 보조 텍스트 |
|---|---|---|
direct | 상대방 이름 (없으면 「알 수 없음」) | 없음 |
group | title 또는 「그룹」 | N명 |
department | title 또는 「부서 단톡방」 | N명 |
방 이름을 바꾸면(customName) 그 값이 무조건 이깁니다 — 상대 이름이 바뀌어도 표시는 그대로입니다.
미확인 배지
위젯 모드에만 있습니다. 60초 주기 조회로 따라갑니다.
왜 폴링이 필요한가
실시간 구독(WebSocket)은 loadData() 안에서 걸리는데, 그건 패널을 한 번 열어야 돕니다. 한 번도 안 연 사용자는 실시간 증가를 못 받으므로 그동안은 주기 조회가 배지를 맡습니다. NotificationBell과 같은 주기입니다.
숫자는 99 를 넘으면 +99 로 줄입니다.
스타일이 파셜 안에 있습니다
design-system.css 도 앱 main.css 도 아닙니다
messenger-content.html 첫머리의 <style> 블록입니다 — 주석에 **「모든 앱에서 동작하도록 심었다(embedded so they work in every app)」**고 적혀 있습니다. 여덟 앱이 같은 파셜을 include 하므로 결과적으로는 동작하지만, DS 로드 체인 밖이라 토큰 정리에서 빠집니다.
이 블록은 레이어 밖이라 앱 CSS 와 Tailwind 유틸을 «모두 이깁니다». 그래서 여기 값이 있으면 정본이 조용히 밀립니다 — 말풍선이 정확히 그 상태였습니다.
말풍선·입력줄·접속 표시는 2026-09-09 에 빠져나갔습니다
.chat-bubble-* · .status-* 는 하드코딩 hex 6개를 쓰고 있었고, employee-app main.css 의 사본은 이 블록에 밀려 한 번도 그려지지 않았습니다. 지금 정본은 design-system.css 의 .ds-bubble · .ds-composer · .ds-presence 하나입니다 → 말풍선 · 입력줄
여기에 다시 쓰지 마세요. 되살리는 순간 정본이 또 밀립니다.
남아 있는 것은 메신저 레이아웃 전용 규칙들입니다(.messenger-subnav* 등). 배경은 var(--color-bg-container)(다크에서 neutral-800, 2026-10-19)로 토큰화돼 있고, 색 하드코딩은 이제 없습니다.
전체화면 (직원 앱)
전체화면에서만 켜지는 모양(캡슐형 입력줄 · 연락처 프로필 · 하단 내비와의 여백)은 → 모바일 대화 화면.
Alpine.data("_messenger", () => messengerComponent({
sectionName: "messenger", // 뒤로가기 라우팅용
hostHeader: true, // 앱 셸이 상단 앱바를 그린다
}));<div id="section-messenger" x-show="activeSection === 'messenger'" x-data="_messenger()">
<!-- include "../../packages/components/html/messenger-content.html" -->
</div>위젯 (나머지 7개 앱)
컴포넌트를 직접 만들지 말고 ChatWidget 을 쓰세요.
<div x-data="chatWidget()">
<!-- include "../../packages/components/html/chat-widget.html" -->
</div>chat-widget.html 이 안에서 messenger-content.html 을 include 합니다. hostHeader 를 넘기지 마세요.
함께 필요한 것
hereby스토어 — 문구(t()),sdk, 날짜·시간 포맷(formatTime·formatDateCustom).- DS 다이얼로그 —
ds-dialog.html이 앱에 있어야 확인 팝업이 뜹니다. - TabNav 파셜 — 서브내비 줄이
tab-nav.html을 include 합니다. - (직원 앱만)
messenger스토어 —pendingConversationId·setUnreadCount·decrementUnread. 다른 앱은 등록하지 않아도 됩니다. - (선택)
toast스토어 — 없으면 안내가 조용히 생략됩니다. 치명적이지 않은 알림에만 씁니다.
API
messengerShared(opts) / 내부적으로 messengerComponent(opts).
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
sectionName | string | — | 뒤로가기 라우팅용 섹션 이름. 위젯 모드에서는 생략 가능 |
hostHeader | boolean | false | 호스트가 상단 앱바를 그리는가. ⚠️ 위젯에서 켜지 마세요 |
주요 상태
| 멤버 | 설명 |
|---|---|
activeView | conversations · contacts · chat · noticeDetail |
useHostHeader | opts.hostHeader === true — 마크업이 이 값으로 헤더 줄을 가림 |
conversations · contacts · notices | 목록 |
totalUnread | 대화별 미확인 합계 (getter) |
messengerSubTabs · messengerSubTabActive | ⚠️ 이름과 달리 TabNav 에 넘어감 |
주요 메서드
| 멤버 | 설명 |
|---|---|
loadData() | 목록 로드 + 실시간 구독 시작 (패널을 열어야 돕니다) |
openConversation() · loadMessages() · loadOlderMessages() | 대화 열기 · 메시지 페이징 |
sendMessage() · sendAttachments() · resendMessage() | 보내기 · 첨부 · 실패 재전송 |
searchMessages() · jumpToSearchResult() · scrollToMessage() | 검색과 이동 |
createGroupChat() · submitChatInvite() · kickMember() · leaveConversation() · confirmDissolveRoom() · transferOwnershipTo() | 방 관리 |
viewNotice() · acknowledgeNotice() | 공지 |
timeAgo(d) | 「방금 · N분 전 · N시간 전 · N일 전」, 7일 넘으면 날짜 |
순수 헬퍼
messenger-helpers.ts 의 buildConversationItem(c, currentUserId) — 표시명 규칙을 담은 순수 함수입니다. 테스트가 이 함수와 mapMessageItem · updateNoticeReadEnd · filteredConversations 정렬을 고정하고 있습니다.
사용처
| 방식 | 앱 |
|---|---|
| 전체화면 | employee-app |
| 위젯 (ChatWidget) | admin · general-affairs · hr-system · inventory · management-dashboard · module-manager · team-manager |
단일 출처
| 파트 | 파일 |
|---|---|
| Alpine 컴포넌트 | packages/components/src/components/Messenger.ts |
| HTML partial (+ 스타일) | packages/components/html/messenger-content.html |
| 순수 헬퍼 | messenger-helpers.ts |
| 위젯 껍데기 | ChatWidget.ts · chat-widget |
검토 필요 사항
- 스타일이 파셜 안
<style>에 있습니다 (위). DS 로드 체인 밖이라 토큰 정리에서 빠지고, 하드코딩 hex 6종이 남아 있습니다. 같은 블록에서 배경 하나만 토큰화돼 있어 절반쯤 옮기다 만 상태입니다. - 이름이 실제와 다릅니다 —
messengerSubTabs·SubTabItem인데 TabNav 로 렌더됩니다. 타입이 구조적으로 호환돼 검사를 통과할 뿐이라, 읽는 사람이 SubTab 규칙을 적용하기 쉽습니다. hostHeader를 잘못 켜면 화면이 막히는데 가드가 없습니다. 위젯 껍데기가 이 옵션을 넘기지 않는 것에만 기대고 있습니다 — ChatWidget 쪽에서 강제로false로 덮을지 검토가 필요합니다.- 미확인 배지가 60초 폴링에 기대고 있습니다 (위). 패널을 한 번도 안 연 사용자에게는 최대 1분 지연이 있습니다.
- 한 컴포넌트가 1,996줄입니다. 대화·연락처·공지·방 관리·검색·첨부가 한곳에 있어, 순수 로직만
messenger-helpers.ts로 조금씩 빠져나오는 중입니다.
변경 이력
| 날짜 | 내용 |
|---|---|
| 2026-09-08 | Messenger 페이지 신규 작성 — 모드 × 화면 상태 × hostHeader Playground + 모아보기 5상태(같은 옵션이 모드에 따라 정상/고장인 것을 나란히). spread↔mutate 함정, SubTab↔TabNav 이름 불일치, 파셜 내장 스타일 포함. 파셜(1,164줄)은 재현하지 않았습니다 |