+
+ {isEditing ? (
+ <>
+
+
+ {statusMessage || "상태를 입력해주세요"}
+
+ onStatusMessageChange?.(e.target.value)}
+ placeholder="상태를 입력해주세요"
+ />
+
+
+ >
+ ) : (
+ statusMessage && (
+ <>
+
+ {statusMessage}
+
+
+ >
+ )
+ )}
+
+
+
+
isMe && isEditing && fileInputRef.current?.click()}
+ onContextMenu={handleContextMenu}
+ className={clsx(isMe && isEditing && "cursor-pointer")}
+ >
+
+
+
+ {showDeleteMenu && (
+ <>
+
setShowDeleteMenu(false)}
+ />
+
+
+
+ >
+ )}
+
+ {isMe && isEditing && (
+
+ )}
+
+
+
+
+ {name}
+
+
+
+ );
+}
diff --git a/messenger-whatsapp/src/components/profile/ProfileField.tsx b/messenger-whatsapp/src/components/profile/ProfileField.tsx
new file mode 100644
index 00000000..a6e6aa51
--- /dev/null
+++ b/messenger-whatsapp/src/components/profile/ProfileField.tsx
@@ -0,0 +1,42 @@
+import RightArrow from "@/assets/profile_link.svg?react";
+
+interface ProfileFieldProps {
+ label?: string;
+ value: string;
+ isEditing?: boolean;
+ onChange?: (value: string) => void;
+ href?: string;
+ placeholder?: string;
+}
+
+export default function ProfileField({
+ label,
+ value,
+ isEditing,
+ onChange,
+ href,
+ placeholder,
+}: ProfileFieldProps) {
+ return (
+
+ {label &&
{label}}
+
+ {isEditing ? (
+
onChange?.(e.target.value)}
+ placeholder={placeholder ?? `${label ?? ""}을(를) 입력해주세요`}
+ />
+ ) : (
+ value &&
{value}
+ )}
+ {href && !isEditing && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/messenger-whatsapp/src/constants/userId.ts b/messenger-whatsapp/src/constants/userId.ts
new file mode 100644
index 00000000..94739144
--- /dev/null
+++ b/messenger-whatsapp/src/constants/userId.ts
@@ -0,0 +1 @@
+export const MY_ID = 1;
diff --git a/messenger-whatsapp/src/data/mockChat.json b/messenger-whatsapp/src/data/mockChat.json
new file mode 100644
index 00000000..e69de29b
diff --git a/messenger-whatsapp/src/data/mockData.json b/messenger-whatsapp/src/data/mockData.json
new file mode 100644
index 00000000..c77f0334
--- /dev/null
+++ b/messenger-whatsapp/src/data/mockData.json
@@ -0,0 +1,432 @@
+{
+ "chatRooms": [
+ { "id": 1, "participantIds": [1, 2] },
+ { "id": 2, "participantIds": [1, 3] },
+ { "id": 3, "participantIds": [1, 6] },
+ { "id": 4, "participantIds": [1, 2, 3] },
+ { "id": 5, "participantIds": [1, 4, 5, 8] },
+ { "id": 6, "participantIds": [1, 7, 9, 11, 14] }
+ ],
+ "users": [
+ {
+ "id": 1,
+ "name": "문수인",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ {
+ "id": 2,
+ "name": "세오스",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ {
+ "id": 3,
+ "name": "이윤서",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ {
+ "id": 4,
+ "name": "신짱구",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ { "id": 5, "name": "맹구", "profileImage": "@/assets/profile_default.svg" },
+ {
+ "id": 6,
+ "name": "최유리",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ {
+ "id": 7,
+ "name": "김철수",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ {
+ "id": 8,
+ "name": "이훈이",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ {
+ "id": 9,
+ "name": "한수지",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ {
+ "id": 10,
+ "name": "신짱아",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ {
+ "id": 11,
+ "name": "장한솔",
+ "profileImage": "@/assets/profile_default.svg"
+ },
+ { "id": 14, "name": "정석", "profileImage": "@assets/profile_default.svg" }
+ ],
+ "currentUserId": 1,
+ "messages": [
+ {
+ "id": 1,
+ "chatRoomId": 1,
+ "text": "야 오늘 뭐해?",
+ "senderId": 2,
+ "timestamp": 1775264400000,
+ "readBy": [1, 2]
+ },
+ {
+ "id": 2,
+ "chatRoomId": 1,
+ "text": "집에서 뒹굴거리는 중ㅋㅋ",
+ "senderId": 1,
+ "timestamp": 1775264700000,
+ "readBy": [1, 2]
+ },
+ {
+ "id": 3,
+ "chatRoomId": 1,
+ "text": "나 점심 같이 먹을 사람 구하는 중인데",
+ "senderId": 2,
+ "timestamp": 1775265000000,
+ "readBy": [1, 2]
+ },
+ {
+ "id": 4,
+ "chatRoomId": 1,
+ "text": "어디서?",
+ "senderId": 1,
+ "timestamp": 1775265300000,
+ "readBy": [1, 2]
+ },
+ {
+ "id": 5,
+ "chatRoomId": 1,
+ "text": "홍대 쪽으로 나올 수 있어?",
+ "senderId": 2,
+ "timestamp": 1775265600000,
+ "readBy": [1, 2]
+ },
+ {
+ "id": 6,
+ "chatRoomId": 1,
+ "text": "ㅇㅇ 한 시간 후에 가능",
+ "senderId": 1,
+ "timestamp": 1775269800000,
+ "readBy": [1, 2]
+ },
+ {
+ "id": 7,
+ "chatRoomId": 1,
+ "text": "ㅊㅋ 그럼 12시에 홍대입구 3번 출구 앞에서 봐",
+ "senderId": 2,
+ "timestamp": 1775270100000,
+ "readBy": [1, 2]
+ },
+ {
+ "id": 8,
+ "chatRoomId": 1,
+ "text": "ㅇㅋ",
+ "senderId": 1,
+ "timestamp": 1775270400000,
+ "readBy": [1, 2]
+ },
+
+ {
+ "id": 9,
+ "chatRoomId": 2,
+ "text": "수인아 과제 다 했어?",
+ "senderId": 3,
+ "timestamp": 1775278800000,
+ "readBy": [1, 3]
+ },
+ {
+ "id": 10,
+ "chatRoomId": 2,
+ "text": "거의 다 했는데 마지막 부분이 안 풀림 ㅠ",
+ "senderId": 1,
+ "timestamp": 1775279100000,
+ "readBy": [1, 3]
+ },
+ {
+ "id": 11,
+ "chatRoomId": 2,
+ "text": "어떤 부분?",
+ "senderId": 3,
+ "timestamp": 1775279400000,
+ "readBy": [1, 3]
+ },
+ {
+ "id": 12,
+ "chatRoomId": 2,
+ "text": "zustand persist 설정하는 게 계속 에러 남",
+ "senderId": 1,
+ "timestamp": 1775279700000,
+ "readBy": [1, 3]
+ },
+ {
+ "id": 13,
+ "chatRoomId": 2,
+ "text": "아 그거 version 올려봐 나도 그랬었는데 그렇게 해결했어",
+ "senderId": 3,
+ "timestamp": 1775280000000,
+ "readBy": [1, 3]
+ },
+ {
+ "id": 14,
+ "chatRoomId": 2,
+ "text": "진짜?? 해볼게 고마워!!",
+ "senderId": 1,
+ "timestamp": 1775280300000,
+ "readBy": [1, 3]
+ },
+ {
+ "id": 15,
+ "chatRoomId": 2,
+ "text": "ㅎㅎ 화이팅~",
+ "senderId": 3,
+ "timestamp": 1775304000000,
+ "readBy": [1, 3]
+ },
+ {
+ "id": 46,
+ "chatRoomId": 2,
+ "text": "아 근데 리액트 컴파일러 경고 어떻게 해결했어?",
+ "senderId": 3,
+ "timestamp": 1775307600000,
+ "readBy": [3]
+ },
+
+ {
+ "id": 16,
+ "chatRoomId": 3,
+ "text": "유리야 이번 주 토요일에 시간 돼?",
+ "senderId": 1,
+ "timestamp": 1774695600000,
+ "readBy": [1, 6]
+ },
+ {
+ "id": 17,
+ "chatRoomId": 3,
+ "text": "오후에는 돼! 왜?",
+ "senderId": 6,
+ "timestamp": 1774697400000,
+ "readBy": [1, 6]
+ },
+ {
+ "id": 18,
+ "chatRoomId": 3,
+ "text": "전시회 같이 가고 싶어서 ㅎㅎ 혜화 쪽에 요즘 핫한 데 있거든",
+ "senderId": 1,
+ "timestamp": 1774697700000,
+ "readBy": [1, 6]
+ },
+ {
+ "id": 19,
+ "chatRoomId": 3,
+ "text": "오 좋아! 몇 시에?",
+ "senderId": 6,
+ "timestamp": 1774698000000,
+ "readBy": [1, 6]
+ },
+ {
+ "id": 20,
+ "chatRoomId": 3,
+ "text": "2시 어때?",
+ "senderId": 1,
+ "timestamp": 1774698300000,
+ "readBy": [1, 6]
+ },
+ {
+ "id": 21,
+ "chatRoomId": 3,
+ "text": "ㅇㅇ 완전 좋아 기대된다~!",
+ "senderId": 6,
+ "timestamp": 1774698600000,
+ "readBy": [1, 6]
+ },
+
+ {
+ "id": 22,
+ "chatRoomId": 4,
+ "text": "얘들아 저녁 뭐 먹을 거야?",
+ "senderId": 1,
+ "timestamp": 1775192400000,
+ "readBy": [1, 2, 3]
+ },
+ {
+ "id": 23,
+ "chatRoomId": 4,
+ "text": "삼겹살 먹고 싶은데",
+ "senderId": 2,
+ "timestamp": 1775192700000,
+ "readBy": [1, 2, 3]
+ },
+ {
+ "id": 24,
+ "chatRoomId": 4,
+ "text": "나도!!!",
+ "senderId": 3,
+ "timestamp": 1775193000000,
+ "readBy": [1, 2, 3]
+ },
+ {
+ "id": 25,
+ "chatRoomId": 4,
+ "text": "그럼 7시에 신촌에서 봐?",
+ "senderId": 1,
+ "timestamp": 1775203200000,
+ "readBy": [1, 2, 3]
+ },
+ {
+ "id": 26,
+ "chatRoomId": 4,
+ "text": "ㅇㅋ",
+ "senderId": 2,
+ "timestamp": 1775203500000,
+ "readBy": [1, 2, 3]
+ },
+ {
+ "id": 27,
+ "chatRoomId": 4,
+ "text": "오케이~",
+ "senderId": 3,
+ "timestamp": 1775203800000,
+ "readBy": [1, 2, 3]
+ },
+ {
+ "id": 28,
+ "chatRoomId": 4,
+ "text": "근데 어디 맛집 알아?",
+ "senderId": 2,
+ "timestamp": 1775214000000,
+ "readBy": [1, 2, 3]
+ },
+ {
+ "id": 29,
+ "chatRoomId": 4,
+ "text": "내가 찜해둔 데 있어 맛있을 거야",
+ "senderId": 1,
+ "timestamp": 1775214300000,
+ "readBy": [1, 2, 3]
+ },
+
+ {
+ "id": 30,
+ "chatRoomId": 5,
+ "text": "[공지] 이번 주 방범대 임무\n\n1. 공원 미끄럼틀 밑에 숨겨둔 맹구의 돌 컬렉션 지키기\n\n2. 유리네 집 토끼 인형 안부 확인하기\n\n3. 짱구네 흰둥이 산책 같이 시켜주기 (간식 지참 필수)",
+ "senderId": 4,
+ "timestamp": 1775106000000,
+ "readBy": [1, 4, 5, 8]
+ },
+ {
+ "id": 31,
+ "chatRoomId": 5,
+ "text": "반장님 역시!! 저 돌 컬렉션 지킬게요",
+ "senderId": 5,
+ "timestamp": 1775106300000,
+ "readBy": [1, 4, 5, 8]
+ },
+ {
+ "id": 32,
+ "chatRoomId": 5,
+ "text": "흰둥이 산책은 내가 할게~ 간식도 챙겨갈게",
+ "senderId": 8,
+ "timestamp": 1775106600000,
+ "readBy": [1, 4, 5, 8]
+ },
+ {
+ "id": 33,
+ "chatRoomId": 5,
+ "text": "그럼 나는 유리네 집 담당!",
+ "senderId": 1,
+ "timestamp": 1775109600000,
+ "readBy": [1, 4, 5, 8]
+ },
+ {
+ "id": 34,
+ "chatRoomId": 5,
+ "text": "역시 수인이 명예 방범대원답다~~ 오늘 초코비 쏠게",
+ "senderId": 4,
+ "timestamp": 1775109900000,
+ "readBy": [1, 4, 5, 8]
+ },
+ {
+ "id": 35,
+ "chatRoomId": 5,
+ "text": "짱구야 유리네 토끼 인형 눈이 약간 삐뚤어진 것 같은데?",
+ "senderId": 1,
+ "timestamp": 1775110200000,
+ "readBy": [4, 5, 8]
+ },
+ {
+ "id": 36,
+ "chatRoomId": 5,
+ "text": "어어 그거 원래 그래요 유리가 직접 꿰맨 거라서",
+ "senderId": 5,
+ "timestamp": 1775113200000,
+ "readBy": [4, 5, 8]
+ },
+
+ {
+ "id": 37,
+ "chatRoomId": 6,
+ "text": "디지몬 어드벤처 리마스터 나왔다!! 다들 봤어?",
+ "senderId": 11,
+ "timestamp": 1775008800000,
+ "readBy": [1, 7, 9, 11, 14]
+ },
+ {
+ "id": 38,
+ "chatRoomId": 6,
+ "text": "나 어제 정주행 했어 ㅠㅠ 아구몬 너무 귀여워",
+ "senderId": 9,
+ "timestamp": 1775009100000,
+ "readBy": [1, 7, 9, 11, 14]
+ },
+ {
+ "id": 39,
+ "chatRoomId": 6,
+ "text": "레전드지... 어린 시절 추억 소환",
+ "senderId": 7,
+ "timestamp": 1775009400000,
+ "readBy": [1, 7, 9, 11, 14]
+ },
+ {
+ "id": 40,
+ "chatRoomId": 6,
+ "text": "다 같이 보는 날 잡자",
+ "senderId": 14,
+ "timestamp": 1775009700000,
+ "readBy": [1, 7, 9, 11, 14]
+ },
+ {
+ "id": 41,
+ "chatRoomId": 6,
+ "text": "ㄱㄱ 다음 주 금요일 어때?",
+ "senderId": 1,
+ "timestamp": 1775010000000,
+ "readBy": [1, 7, 9, 11, 14]
+ },
+ {
+ "id": 42,
+ "chatRoomId": 6,
+ "text": "좋아!! 치킨이지 당연히",
+ "senderId": 7,
+ "timestamp": 1775010600000,
+ "readBy": [7, 9, 11, 14]
+ },
+ {
+ "id": 43,
+ "chatRoomId": 6,
+ "text": "저도요~! 뭐 먹을지도 미리 정해요 ㅎㅎ",
+ "senderId": 9,
+ "timestamp": 1775010900000,
+ "readBy": [7, 9, 11, 14]
+ },
+ {
+ "id": 44,
+ "chatRoomId": 6,
+ "text": "오메가몬 언제나오냐",
+ "senderId": 11,
+ "timestamp": 1775012400000,
+ "readBy": [7, 9, 11, 14]
+ }
+ ]
+}
diff --git a/messenger-whatsapp/src/data/mockFriends.json b/messenger-whatsapp/src/data/mockFriends.json
new file mode 100644
index 00000000..116f5c07
--- /dev/null
+++ b/messenger-whatsapp/src/data/mockFriends.json
@@ -0,0 +1,89 @@
+[
+ {
+ "id": 1,
+ "name": "문수인",
+ "profileImage": "",
+ "statusMessage": "수면중이에요!",
+ "phone": "010-1234-5678",
+ "links": [
+ { "type": "링크", "url": "https://behance.net/munsuIn" },
+ { "type": "링크", "url": "https://github.com/munsuIn" }
+ ]
+ },
+ { "id": 2, "name": "세오스", "profileImage": "" },
+ { "id": 3, "name": "이윤서", "profileImage": "" },
+ {
+ "id": 4,
+ "name": "신짱구",
+ "profileImage": "/profiles/profile_jjanggu.jpg",
+ "statusMessage": "오늘도 화이팅!"
+ },
+ {
+ "id": 5,
+ "name": "맹구",
+ "profileImage": "/profiles/profile_manggu.jpg",
+ "statusMessage": "주말에 뭐하지"
+ },
+ {
+ "id": 6,
+ "name": "최유리",
+ "profileImage": "",
+ "statusMessage": "커피 한 잔이 필요해"
+ },
+ { "id": 7, "name": "김철수", "profileImage": "" },
+ {
+ "id": 8,
+ "name": "이훈이",
+ "profileImage": "",
+ "statusMessage": "열심히 살자"
+ },
+ { "id": 9, "name": "한수지", "profileImage": "" },
+ { "id": 10, "name": "신짱아", "profileImage": "" },
+ {
+ "id": 11,
+ "name": "장한솔",
+ "profileImage": "",
+ "statusMessage": "코딩 중..."
+ },
+ {
+ "id": 12,
+ "name": "신태일",
+ "profileImage": "/profiles/profile_taeyeol.jpg",
+ "statusMessage": "😴"
+ },
+ { "id": 13, "name": "신나리", "profileImage": "" },
+ {
+ "id": 14,
+ "name": "정석",
+ "profileImage": "/profiles/profile_jeongsuk.jpg",
+ "statusMessage": "맛있는 거 먹고 싶다"
+ },
+ { "id": 15, "name": "매튜", "profileImage": "" },
+ {
+ "id": 16,
+ "name": "한소라",
+ "profileImage": "",
+ "statusMessage": "오늘 날씨 좋다"
+ },
+ { "id": 17, "name": "리키", "profileImage": "" },
+ {
+ "id": 18,
+ "name": "이미나",
+ "profileImage": "",
+ "statusMessage": "여행 가고 싶어"
+ },
+ { "id": 19, "name": "최산해", "profileImage": "" },
+ {
+ "id": 20,
+ "name": "이재하",
+ "profileImage": "",
+ "statusMessage": "공부하는 중"
+ },
+ { "id": 21, "name": "서정우", "profileImage": "" },
+ {
+ "id": 22,
+ "name": "홍예지",
+ "profileImage": "",
+ "statusMessage": "행복한 하루 되세요 🌸"
+ }
+]
diff --git a/messenger-whatsapp/src/hooks/useFilteredChatRooms.ts b/messenger-whatsapp/src/hooks/useFilteredChatRooms.ts
new file mode 100644
index 00000000..925879ec
--- /dev/null
+++ b/messenger-whatsapp/src/hooks/useFilteredChatRooms.ts
@@ -0,0 +1,61 @@
+import { useMemo, useState } from "react";
+import { useChatStore } from "@/store/useChatStore";
+import type { FilterType } from "@/components/chip/ChatFilter";
+import { MY_ID } from "@/constants/userId";
+
+export function useFilteredChatRooms() {
+ const { chatRooms, messages, favorites } = useChatStore();
+ const [filter, setFilter] = useState
("all");
+ const [openSwipeId, setOpenSwipeId] = useState(null);
+
+ const unreadMessageCount = useMemo(
+ () => messages.filter((m) => !m.readBy.includes(MY_ID)).length,
+ [messages],
+ );
+
+ const groupCount = useMemo(
+ () => chatRooms.filter((room) => room.participantIds.length >= 3).length,
+ [chatRooms],
+ );
+
+ // 메시지 배열을 한 번만 순회해서 방별 최신 타임스탬프 맵 생성
+ const lastMessageTimeMap = useMemo(() => {
+ const map = new Map();
+ for (const m of messages) {
+ if ((map.get(m.chatRoomId) ?? 0) < m.timestamp) {
+ map.set(m.chatRoomId, m.timestamp);
+ }
+ }
+ return map;
+ }, [messages]);
+
+ const filteredRooms = useMemo(
+ () =>
+ chatRooms
+ .filter((room) => {
+ if (filter === "unread")
+ return messages.some(
+ (m) => m.chatRoomId === room.id && !m.readBy.includes(MY_ID),
+ );
+ if (filter === "group") return room.participantIds.length >= 3;
+ if (filter === "favorites") return favorites.includes(room.id);
+ return true;
+ })
+ .sort(
+ (a, b) =>
+ (lastMessageTimeMap.get(b.id) ?? 0) -
+ (lastMessageTimeMap.get(a.id) ?? 0),
+ ),
+ [chatRooms, messages, filter, favorites, lastMessageTimeMap],
+ );
+
+ return {
+ filteredRooms,
+ filter,
+ setFilter,
+ unreadMessageCount,
+ groupCount,
+ openSwipeId,
+ setOpenSwipeId,
+ };
+}
diff --git a/messenger-whatsapp/src/hooks/useMessageGrouping.ts b/messenger-whatsapp/src/hooks/useMessageGrouping.ts
new file mode 100644
index 00000000..9bd4adff
--- /dev/null
+++ b/messenger-whatsapp/src/hooks/useMessageGrouping.ts
@@ -0,0 +1,50 @@
+import type { Message } from "@/store/useChatStore";
+import type { Friend } from "@/store/useFriendsStore";
+import { isSameDay, getUnreadCount } from "@/utils/chatUtils";
+import { formatMinute } from "@/utils/formatTime";
+
+export interface MessageMeta {
+ msg: Message;
+ isSent: boolean;
+ showDate: boolean;
+ showTime: boolean;
+ senderName: string | undefined;
+ unreadCount: number;
+}
+
+export function groupMessages(
+ messages: Message[],
+ currentUserId: number,
+ participantIds: number[],
+ friends: Friend[],
+): MessageMeta[] {
+ const isGroup = participantIds.length >= 3;
+
+ return messages.map((msg, index) => {
+ const isSent = msg.senderId === currentUserId;
+ const prev = messages[index - 1];
+ const next = messages[index + 1];
+
+ const showDate = index === 0 || !isSameDay(prev.timestamp, msg.timestamp);
+
+ const showTime =
+ !next ||
+ (next.senderId === currentUserId) !== isSent ||
+ formatMinute(next.timestamp) !== formatMinute(msg.timestamp);
+
+ const isFirstInSequence = !prev || prev.senderId !== msg.senderId;
+ const senderName =
+ !isSent && isGroup && isFirstInSequence
+ ? friends.find((f) => f.id === msg.senderId)?.name
+ : undefined;
+
+ return {
+ msg,
+ isSent,
+ showDate,
+ showTime,
+ senderName,
+ unreadCount: getUnreadCount(msg, participantIds),
+ };
+ });
+}
diff --git a/messenger-whatsapp/src/hooks/useMultiLineDetection.ts b/messenger-whatsapp/src/hooks/useMultiLineDetection.ts
new file mode 100644
index 00000000..9853f65b
--- /dev/null
+++ b/messenger-whatsapp/src/hooks/useMultiLineDetection.ts
@@ -0,0 +1,22 @@
+import { useEffect, useRef, useState } from "react";
+
+/**
+ * 엘리먼트의 높이가 단일 행 높이를 초과하면 isMultiLine = true
+ * @param singleLineHeight 1줄일 때의 기준 높이(px)
+ */
+export const useMultiLineDetection = (singleLineHeight: number) => {
+ const ref = useRef(null);
+ const [isMultiLine, setIsMultiLine] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const observer = new ResizeObserver(() => {
+ setIsMultiLine(el.offsetHeight > singleLineHeight);
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [singleLineHeight]);
+
+ return { ref, isMultiLine };
+};
diff --git a/messenger-whatsapp/src/hooks/useSwipeGesture.ts b/messenger-whatsapp/src/hooks/useSwipeGesture.ts
new file mode 100644
index 00000000..61c2c389
--- /dev/null
+++ b/messenger-whatsapp/src/hooks/useSwipeGesture.ts
@@ -0,0 +1,120 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+interface UseSwipeGestureOptions {
+ actionWidth: number;
+ threshold: number;
+ itemId: number;
+ openId: number | null | undefined;
+ onOpen: (id: number) => void;
+}
+
+export function useSwipeGesture({
+ actionWidth,
+ threshold,
+ itemId,
+ openId,
+ onOpen,
+}: UseSwipeGestureOptions) {
+ const [offsetX, setOffsetX] = useState(0);
+ const [animating, setAnimating] = useState(false);
+
+ const startX = useRef(0);
+ const startY = useRef(0);
+ const isHorizontal = useRef(null);
+ const baseOffset = useRef(0);
+ const dragged = useRef(false);
+ const offsetRef = useRef(0);
+ offsetRef.current = offsetX;
+
+ // onOpen을 ref에 저장해서 stale closure 방지
+ const onOpenRef = useRef(onOpen);
+ onOpenRef.current = onOpen;
+
+ useEffect(() => {
+ if (openId !== itemId && offsetRef.current !== 0) {
+ setAnimating(true);
+ setOffsetX(0);
+ }
+ }, [openId, itemId]);
+
+ const close = useCallback(() => {
+ setAnimating(true);
+ setOffsetX(0);
+ }, []);
+
+ const dragStart = useCallback((clientX: number, clientY: number) => {
+ startX.current = clientX;
+ startY.current = clientY;
+ isHorizontal.current = null;
+ baseOffset.current = offsetRef.current;
+ dragged.current = false;
+ setAnimating(false);
+ }, []);
+
+ const dragMove = useCallback(
+ (clientX: number, clientY: number) => {
+ const dx = clientX - startX.current;
+ const dy = clientY - startY.current;
+
+ if (isHorizontal.current === null) {
+ if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 5)
+ isHorizontal.current = true;
+ else if (Math.abs(dy) > 5) isHorizontal.current = false;
+ }
+
+ if (isHorizontal.current) {
+ dragged.current = true;
+ setOffsetX(Math.max(-actionWidth, Math.min(0, baseOffset.current + dx)));
+ }
+ },
+ [actionWidth],
+ );
+
+ const dragEnd = useCallback(() => {
+ if (!isHorizontal.current) return;
+ setAnimating(true);
+ if (offsetRef.current < -threshold) {
+ setOffsetX(-actionWidth);
+ onOpenRef.current(itemId);
+ } else {
+ setOffsetX(0);
+ }
+ }, [actionWidth, threshold, itemId]);
+
+ const handleTouchStart = useCallback(
+ (e: React.TouchEvent) => dragStart(e.touches[0].clientX, e.touches[0].clientY),
+ [dragStart],
+ );
+ const handleTouchMove = useCallback(
+ (e: React.TouchEvent) => dragMove(e.touches[0].clientX, e.touches[0].clientY),
+ [dragMove],
+ );
+ const handleTouchEnd = useCallback(() => dragEnd(), [dragEnd]);
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ dragStart(e.clientX, e.clientY);
+ const onMove = (ev: MouseEvent) => dragMove(ev.clientX, ev.clientY);
+ const onUp = () => {
+ dragEnd();
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ },
+ [dragStart, dragMove, dragEnd],
+ );
+
+ return {
+ offsetX,
+ animating,
+ dragged,
+ offsetRef,
+ close,
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
+ handleMouseDown,
+ };
+}
diff --git a/messenger-whatsapp/src/index.css b/messenger-whatsapp/src/index.css
new file mode 100644
index 00000000..fce40099
--- /dev/null
+++ b/messenger-whatsapp/src/index.css
@@ -0,0 +1,109 @@
+@import "tailwindcss";
+@import "./styles/bubble.css";
+
+@theme {
+ /* Colors */
+ --color-main-green: #00ad59;
+ --color-main-bg: #f3f0eb;
+ --color-gray-01: #f3f4f5;
+ --color-gray-02: #e5e8eb;
+ --color-gray-03: #e1e1e1;
+ --color-gray-04: #6b7684;
+ --color-gray-05: #4e5968;
+ --color-gray-06: #191f28;
+ --color-gray-07: #626d7b;
+
+ /* Font family */
+ --font-pretendard: "Pretendard", sans-serif;
+}
+
+@font-face {
+ font-family: "Pretendard Variable";
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: swap;
+ src: url("/fonts/PretendardVariable.woff2") format("woff2");
+}
+
+@theme {
+ /* Headline 1: 22px / 16px = 1.375em */
+ --text-headline-1: 1.375em;
+ --text-headline-1--line-height: 1.5;
+ --text-headline-1--letter-spacing: -0.025em;
+ --text-headline-1--font-weight: 600;
+
+ /* Headline 2: 18px / 16px = 1.125em */
+ --text-headline-2: 1.125em;
+ --text-headline-2--line-height: 1.5;
+ --text-headline-2--letter-spacing: -0.025em;
+ --text-headline-2--font-weight: 600;
+
+ /* Body 01: 16px / 16px = 1em */
+ --text-body-01: 1em;
+ --text-body-01--line-height: 1.5;
+ --text-body-01--letter-spacing: -0.025em;
+ --text-body-01--font-weight: 600;
+
+ /* Body 02: 16px / 16px = 1em */
+ --text-body-02: 1em;
+ --text-body-02--line-height: 1.5;
+ --text-body-02--letter-spacing: -0.025em;
+ --text-body-02--font-weight: 400;
+
+ /* Body 03: 14px / 16px = 0.875em */
+ --text-body-03: 0.875em;
+ --text-body-03--line-height: 1.5;
+ --text-body-03--letter-spacing: -0.025em;
+ --text-body-03--font-weight: 500;
+
+ /* Body 04: 14px / 16px = 0.875em */
+ --text-body-04: 0.875em;
+ --text-body-04--line-height: 1.5;
+ --text-body-04--letter-spacing: -0.025em;
+ --text-body-04--font-weight: 400;
+
+ /* Body 05: 12px / 16px = 0.75em */
+ --text-body-05: 0.75em;
+ --text-body-05--line-height: 1.6; /* 160% */
+ --text-body-05--letter-spacing: -0.025em;
+ --text-body-05--font-weight: 400;
+
+ /* Caption 1: 12px / 16px = 0.75em */
+ --text-caption-1: 0.75em;
+ --text-caption-1--line-height: 1.5;
+ --text-caption-1--letter-spacing: -0.01em;
+ --text-caption-1--font-weight: 400;
+
+ /* Caption 2: 10px / 16px = 0.625em */
+ --text-caption-2: 0.625em;
+ --text-caption-2--line-height: 1.6; /* 160% */
+ --text-caption-2--letter-spacing: -0.01em;
+ --text-caption-2--font-weight: 400;
+}
+
+/* Apply Pretendard as default font */
+body {
+ font-family: var(--font-pretendard);
+ background-color: var(--color-gray-01);
+}
+
+button {
+ cursor: pointer;
+}
+
+svg {
+ cursor: pointer;
+}
+
+/* 스크롤바 숨기기 유틸리티 */
+@utility no-scrollbar {
+ /* IE, Edge */
+ -ms-overflow-style: none;
+ /* Firefox */
+ scrollbar-width: none;
+
+ /* Chrome, Safari, Opera */
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
diff --git a/messenger-whatsapp/src/main.tsx b/messenger-whatsapp/src/main.tsx
new file mode 100644
index 00000000..bef5202a
--- /dev/null
+++ b/messenger-whatsapp/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/messenger-whatsapp/src/pages/ChatList.tsx b/messenger-whatsapp/src/pages/ChatList.tsx
new file mode 100644
index 00000000..0de21ce9
--- /dev/null
+++ b/messenger-whatsapp/src/pages/ChatList.tsx
@@ -0,0 +1,63 @@
+import { useEffect } from "react";
+import { useOutletContext } from "react-router-dom";
+import type { HeaderConfig } from "@/components/Layouts/MainLayout";
+import ChatRoomItem from "@/components/chat/ChatRoomItem";
+import ChipFilter from "@/components/chip/ChatFilter";
+import { useFilteredChatRooms } from "@/hooks/useFilteredChatRooms";
+import Search from "@/assets/pageheader_search.svg?react";
+import More_Square from "@/assets/pageheader_moresquare.svg?react";
+import Plus from "@/assets/pageheader_add.svg?react";
+
+const ChatList = () => {
+ const { setHeaderConfig } = useOutletContext<{
+ setHeaderConfig: (c: HeaderConfig) => void;
+ }>();
+
+ const {
+ filteredRooms,
+ filter,
+ setFilter,
+ unreadMessageCount,
+ groupCount,
+ openSwipeId,
+ setOpenSwipeId,
+ } = useFilteredChatRooms();
+
+ useEffect(() => {
+ setHeaderConfig({
+ title: "대화",
+ right: (
+ <>
+
+
+
+ >
+ ),
+ });
+ }, [setHeaderConfig]);
+
+ return (
+
+
+
+
+
+ {filteredRooms.map((room) => (
+
+ ))}
+
+
+ );
+};
+
+export default ChatList;
diff --git a/messenger-whatsapp/src/pages/ChatRoom.tsx b/messenger-whatsapp/src/pages/ChatRoom.tsx
new file mode 100644
index 00000000..b10ce4c9
--- /dev/null
+++ b/messenger-whatsapp/src/pages/ChatRoom.tsx
@@ -0,0 +1,104 @@
+import { Fragment, useEffect, useMemo, useRef } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import CameraIcon from "@/assets/camera.svg?react";
+import CallIcon from "@/assets/call.svg?react";
+import Bubble from "@/components/chat/Bubble";
+import InputBox from "@/components/common/Input";
+import ChipDate from "@/components/chip/ChatDate";
+import PageHeader from "@/components/common/PageHeader";
+import TopBar from "@/components/common/TopBar";
+import { useChatStore } from "@/store/useChatStore";
+import { useFriendsStore } from "@/store/useFriendsStore";
+import { groupMessages } from "@/hooks/useMessageGrouping";
+import { getRoomName } from "@/utils/chatUtils";
+
+export default function ChatRoom() {
+ const { roomId } = useParams();
+ const navigate = useNavigate();
+ const roomIdNum = Number(roomId);
+ const {
+ messages: allMessages,
+ chatRooms,
+ currentUserId,
+ sendMessage,
+ markAsRead,
+ swapPerspective,
+ resetPerspective,
+ } = useChatStore();
+ const { friends } = useFriendsStore();
+ const messages = allMessages.filter((m) => m.chatRoomId === roomIdNum);
+ const room = chatRooms.find((r) => r.id === roomIdNum);
+ const participantIds = room?.participantIds ?? [];
+ const roomName = getRoomName(friends, participantIds, currentUserId);
+ const groupedMessages = useMemo(
+ () => groupMessages(messages, currentUserId, participantIds, friends),
+ [messages, currentUserId, participantIds, friends],
+ );
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ resetPerspective();
+ markAsRead(roomIdNum);
+ return () => resetPerspective();
+ }, [roomIdNum]);
+
+ useEffect(() => {
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
+ }, [messages]);
+
+ return (
+
+
+
navigate(-1)}
+ onTitleClick={() => swapPerspective(roomIdNum)}
+ right={
+ <>
+
+
+ >
+ }
+ />
+
+ {groupedMessages.map(
+ (
+ { msg, isSent, showDate, showTime, senderName, unreadCount },
+ index,
+ ) => {
+ const prev = groupedMessages[index - 1];
+ const senderChanged = prev && prev.isSent !== isSent;
+
+ return (
+
+
+ {showDate && (
+
+
+
+ )}
+
+
+
+
+
+ );
+ },
+ )}
+
+ sendMessage(text, roomIdNum)} />
+
+ );
+}
diff --git a/messenger-whatsapp/src/pages/EditProfile.tsx b/messenger-whatsapp/src/pages/EditProfile.tsx
new file mode 100644
index 00000000..04b21fb0
--- /dev/null
+++ b/messenger-whatsapp/src/pages/EditProfile.tsx
@@ -0,0 +1,101 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useOutletContext } from "react-router-dom";
+import type { HeaderConfig } from "@/components/Layouts/MainLayout";
+import ProfileCard from "@/components/profile/ProfileCard";
+import ProfileField from "@/components/profile/ProfileField";
+import { useFriendsStore } from "@/store/useFriendsStore";
+import { MY_ID } from "@/constants/userId";
+
+export default function EditProfile() {
+ const { setHeaderConfig } = useOutletContext<{
+ setHeaderConfig: (c: HeaderConfig) => void;
+ }>();
+
+ const navigate = useNavigate();
+ const { friends, updateFriend } = useFriendsStore();
+ const me = friends.find((f) => f.id === MY_ID);
+
+ const [name, setName] = useState(me?.name ?? "");
+ const [phone, setPhone] = useState(me?.phone ?? "");
+ const [statusMessage, setStatusMessage] = useState(me?.statusMessage ?? "");
+ const [profileImage, setProfileImage] = useState(me?.profileImage ?? "");
+ const [linkValues, setLinkValues] = useState(
+ me?.links?.map((l) => ({ type: l.type, url: l.url })) ?? [],
+ );
+
+ useEffect(() => {
+ const handleSave = () => {
+ updateFriend(MY_ID, {
+ name,
+ phone,
+ statusMessage,
+ profileImage,
+ links: linkValues,
+ });
+ navigate(-1);
+ };
+
+ setHeaderConfig({
+ title: "",
+ showBack: true,
+ right: (
+
+ ),
+ });
+ }, [
+ setHeaderConfig,
+ name,
+ phone,
+ statusMessage,
+ profileImage,
+ linkValues,
+ updateFriend,
+ navigate,
+ ]);
+
+ return (
+
+
setProfileImage(v ?? "")}
+ />
+
+
+
+ {linkValues.length > 0 && (
+
+
링크
+ {linkValues.map((link, i) => (
+
+ setLinkValues((prev) =>
+ prev.map((l, idx) => (idx === i ? { ...l, url: v } : l)),
+ )
+ }
+ placeholder="링크를 입력해주세요"
+ />
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/messenger-whatsapp/src/pages/Friends.tsx b/messenger-whatsapp/src/pages/Friends.tsx
new file mode 100644
index 00000000..d8efdbb3
--- /dev/null
+++ b/messenger-whatsapp/src/pages/Friends.tsx
@@ -0,0 +1,45 @@
+import { useEffect } from "react";
+import { useOutletContext } from "react-router-dom";
+import type { HeaderConfig } from "@/components/Layouts/MainLayout";
+import SearchIcon from "@/assets/pageheader_search.svg?react";
+import SettingIcon from "@/assets/pageheader_setting.svg?react";
+import AddUserIcon from "@/assets/pageheader_adduser.svg?react";
+import Friend from "@/components/friends/Friend";
+import FriendsList from "@/components/friends/FriendsList";
+import { useFriendsStore } from "@/store/useFriendsStore";
+import { MY_ID } from "@/constants/userId";
+
+export default function Friends() {
+ const { setHeaderConfig } = useOutletContext<{
+ setHeaderConfig: (c: HeaderConfig) => void;
+ }>();
+ const friends = useFriendsStore((s) => s.friends);
+ const me = friends.find((f) => f.id === MY_ID);
+ const friendCount = friends.length - 1;
+
+ useEffect(() => {
+ setHeaderConfig({
+ title: "친구",
+ right: (
+ <>
+
+
+
+ >
+ ),
+ });
+ }, [setHeaderConfig]);
+
+ return (
+
+ );
+}
diff --git a/messenger-whatsapp/src/pages/Profile.tsx b/messenger-whatsapp/src/pages/Profile.tsx
new file mode 100644
index 00000000..2854ed9b
--- /dev/null
+++ b/messenger-whatsapp/src/pages/Profile.tsx
@@ -0,0 +1,62 @@
+import { useEffect } from "react";
+import { useNavigate, useOutletContext, useParams } from "react-router-dom";
+import type { HeaderConfig } from "@/components/Layouts/MainLayout";
+import ProfileCard from "@/components/profile/ProfileCard";
+import ProfileField from "@/components/profile/ProfileField";
+import { useFriendsStore } from "@/store/useFriendsStore";
+import { MY_ID } from "@/constants/userId";
+
+export default function Profile() {
+ const { setHeaderConfig } = useOutletContext<{
+ setHeaderConfig: (c: HeaderConfig) => void;
+ }>();
+
+ const { userId } = useParams();
+ const navigate = useNavigate();
+
+ const id = userId ? Number(userId) : MY_ID;
+ const isMe = id === MY_ID;
+
+ const friends = useFriendsStore((s) => s.friends);
+ const profile = friends.find((f) => f.id === id);
+
+ useEffect(() => {
+ setHeaderConfig({
+ title: "",
+ showBack: true,
+ right: isMe ? (
+
+ ) : undefined,
+ });
+ }, [setHeaderConfig, isMe, navigate]);
+
+ return (
+
+
+
+ {profile?.name &&
}
+ {profile?.phone && (
+
+ )}
+ {profile?.links && profile.links.length > 0 && (
+
+
링크
+ {profile.links.map((link, i) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/messenger-whatsapp/src/store/useChatStore.ts b/messenger-whatsapp/src/store/useChatStore.ts
new file mode 100644
index 00000000..f8d600fa
--- /dev/null
+++ b/messenger-whatsapp/src/store/useChatStore.ts
@@ -0,0 +1,104 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import mockData from "@/data/mockData.json";
+import { MY_ID } from "@/constants/userId";
+
+export type ChatRoom = {
+ id: number;
+ participantIds: number[];
+};
+
+export type Message = {
+ id: number;
+ chatRoomId: number;
+ text: string;
+ senderId: number;
+ timestamp: number;
+ readBy: number[];
+};
+
+type ChatStore = {
+ chatRooms: ChatRoom[];
+ currentUserId: number;
+ messages: Message[];
+ favorites: number[];
+ sendMessage: (text: string, chatRoomId: number) => void;
+ markAsRead: (chatRoomId: number) => void;
+ swapPerspective: (chatRoomId: number) => void;
+ resetPerspective: () => void;
+ toggleFavorite: (chatRoomId: number) => void;
+};
+
+export const useChatStore = create()(
+ persist(
+ (set) => ({
+ chatRooms: mockData.chatRooms,
+ currentUserId: mockData.currentUserId,
+ messages: mockData.messages,
+ favorites: [],
+
+ sendMessage: (text, chatRoomId) =>
+ set((state) => ({
+ messages: [
+ ...state.messages,
+ {
+ id: Date.now(),
+ chatRoomId,
+ text,
+ senderId: state.currentUserId,
+ timestamp: Date.now(),
+ readBy: [state.currentUserId],
+ },
+ ],
+ })),
+
+ markAsRead: (chatRoomId) =>
+ set((state) => ({
+ messages: state.messages.map((m) =>
+ m.chatRoomId === chatRoomId && !m.readBy.includes(MY_ID)
+ ? { ...m, readBy: [...m.readBy, MY_ID] }
+ : m,
+ ),
+ })),
+
+ resetPerspective: () => {
+ set({ currentUserId: MY_ID });
+ },
+
+ toggleFavorite: (chatRoomId) =>
+ set((state) => ({
+ favorites: state.favorites.includes(chatRoomId)
+ ? state.favorites.filter((id) => id !== chatRoomId)
+ : [...state.favorites, chatRoomId],
+ })),
+
+ swapPerspective: (chatRoomId: number) => {
+ set((state) => {
+ const { chatRooms, currentUserId, messages } = state;
+
+ // 1. 현재 채팅방 정보를 가져와서 다음 사용자(nextId) 결정
+ const room = chatRooms.find((r) => r.id === chatRoomId);
+ if (!room) return state;
+
+ const ids = room.participantIds;
+ const nextId = ids[(ids.indexOf(currentUserId) + 1) % ids.length];
+
+ // 2. 모든 메시지를 순회하며 readBy에 nextId가 없으면 추가
+ const updatedMessages = messages.map((m) =>
+ m.chatRoomId === chatRoomId && !m.readBy.includes(nextId)
+ ? { ...m, readBy: [...m.readBy, nextId] }
+ : m,
+ );
+
+ // 3. 상태 업데이트
+ return {
+ ...state,
+ currentUserId: nextId,
+ messages: updatedMessages,
+ };
+ });
+ },
+ }),
+ { name: "chat-store", version: 9 },
+ ),
+);
diff --git a/messenger-whatsapp/src/store/useFriendsStore.ts b/messenger-whatsapp/src/store/useFriendsStore.ts
new file mode 100644
index 00000000..dcbf90fc
--- /dev/null
+++ b/messenger-whatsapp/src/store/useFriendsStore.ts
@@ -0,0 +1,37 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import mockFriends from "@/data/mockFriends.json";
+
+export type FriendLink = {
+ type: string;
+ url: string;
+};
+
+export type Friend = {
+ id: number;
+ name: string;
+ profileImage: string;
+ statusMessage?: string;
+ phone?: string;
+ links?: FriendLink[];
+};
+
+type FriendsStore = {
+ friends: Friend[];
+ updateFriend: (id: number, fields: Partial>) => void;
+};
+
+export const useFriendsStore = create()(
+ persist(
+ (set) => ({
+ friends: mockFriends,
+ updateFriend: (id, fields) =>
+ set((state) => ({
+ friends: state.friends.map((f) =>
+ f.id === id ? { ...f, ...fields } : f,
+ ),
+ })),
+ }),
+ { name: "friends-store", version: 4 },
+ ),
+);
diff --git a/messenger-whatsapp/src/styles/bubble.css b/messenger-whatsapp/src/styles/bubble.css
new file mode 100644
index 00000000..8de61906
--- /dev/null
+++ b/messenger-whatsapp/src/styles/bubble.css
@@ -0,0 +1,25 @@
+.bubble {
+ position: relative;
+ padding: 8px 14px;
+ white-space: pre-wrap;
+}
+
+.bubble-sent {
+ background-color: var(--color-main-green);
+ color: white;
+ border-radius: 24px;
+}
+
+.bubble-sent.bubble-multiline {
+ border-radius: 16px;
+}
+
+.bubble-received {
+ background-color: var(--color-gray-03);
+ color: var(--color-gray-06);
+ border-radius: 24px;
+}
+
+.bubble-received.bubble-multiline {
+ border-radius: 16px;
+}
diff --git a/messenger-whatsapp/src/utils/chatUtils.ts b/messenger-whatsapp/src/utils/chatUtils.ts
new file mode 100644
index 00000000..54e2dfdc
--- /dev/null
+++ b/messenger-whatsapp/src/utils/chatUtils.ts
@@ -0,0 +1,33 @@
+import type { Message } from "@/store/useChatStore";
+import type { Friend } from "@/store/useFriendsStore";
+
+/** 두 타임스탬프가 같은 날인지 비교 */
+export const isSameDay = (a: number, b: number): boolean => {
+ const da = new Date(a);
+ const db = new Date(b);
+ return (
+ da.getFullYear() === db.getFullYear() &&
+ da.getMonth() === db.getMonth() &&
+ da.getDate() === db.getDate()
+ );
+};
+
+/** 채팅방 표시 이름 (나 제외 참여자 이름을 쉼표로 연결) */
+export const getRoomName = (
+ friends: Friend[],
+ participantIds: number[],
+ currentUserId: number,
+): string =>
+ friends
+ .filter((f) => f.id !== currentUserId && participantIds.includes(f.id))
+ .map((f) => f.name)
+ .join(", ");
+
+/** 메시지를 아직 읽지 않은 참여자 수 (보낸 사람 제외) */
+export const getUnreadCount = (
+ msg: Message,
+ participantIds: number[],
+): number =>
+ participantIds.filter(
+ (id) => id !== msg.senderId && !msg.readBy.includes(id),
+ ).length;
diff --git a/messenger-whatsapp/src/utils/formatTime.ts b/messenger-whatsapp/src/utils/formatTime.ts
new file mode 100644
index 00000000..488bf2ad
--- /dev/null
+++ b/messenger-whatsapp/src/utils/formatTime.ts
@@ -0,0 +1,50 @@
+const DAYS = ["일", "월", "화", "수", "목", "금", "토"];
+
+/**
+ * 날짜 포맷 (날짜 구분 칩)
+ * ex) 2026. 03. 18. 화
+ */
+export const getFormattedDate = (date: Date): string => {
+ const yyyy = date.getFullYear();
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
+ const dd = String(date.getDate()).padStart(2, "0");
+ const day = DAYS[date.getDay()];
+ return `${yyyy}. ${mm}. ${dd}. ${day}`;
+};
+
+/**
+ * 오전/오후 + 12시간제 포맷 (버블 타임스탬프, 채팅 목록)
+ * ex) 오전 9:05
+ */
+export const formatTime = (ts: number): string => {
+ const d = new Date(ts);
+ const hours = d.getHours();
+ const minutes = String(d.getMinutes()).padStart(2, "0");
+ const ampm = hours < 12 ? "오전" : "오후";
+ const h = hours % 12 || 12;
+ return `${ampm} ${h}:${minutes}`;
+};
+
+/**
+ * 채팅 목록 마지막 메시지 시간 (오늘이면 시간, 아니면 날짜)
+ * ex) 오후 3:45 / 4/3
+ */
+export const formatLastMessageTime = (ts: number): string => {
+ const d = new Date(ts);
+ const now = new Date();
+ const isToday =
+ d.getFullYear() === now.getFullYear() &&
+ d.getMonth() === now.getMonth() &&
+ d.getDate() === now.getDate();
+ if (isToday) return formatTime(ts);
+ return `${d.getMonth() + 1}월 ${d.getDate()}일`;
+};
+
+/**
+ * 분 단위 비교용 포맷 (메시지 그룹핑)
+ * ex) 9:5
+ */
+export const formatMinute = (ts: number): string => {
+ const d = new Date(ts);
+ return `${d.getHours()}:${d.getMinutes()}`;
+};
diff --git a/messenger-whatsapp/tsconfig.app.json b/messenger-whatsapp/tsconfig.app.json
new file mode 100644
index 00000000..0c7ea2d8
--- /dev/null
+++ b/messenger-whatsapp/tsconfig.app.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2023",
+ "useDefineForClassFields": true,
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client", "vite-plugin-svgr/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/messenger-whatsapp/tsconfig.json b/messenger-whatsapp/tsconfig.json
new file mode 100644
index 00000000..1ffef600
--- /dev/null
+++ b/messenger-whatsapp/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/messenger-whatsapp/tsconfig.node.json b/messenger-whatsapp/tsconfig.node.json
new file mode 100644
index 00000000..8a67f62f
--- /dev/null
+++ b/messenger-whatsapp/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/messenger-whatsapp/vercel.json b/messenger-whatsapp/vercel.json
new file mode 100644
index 00000000..0f32683a
--- /dev/null
+++ b/messenger-whatsapp/vercel.json
@@ -0,0 +1,3 @@
+{
+ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
+}
diff --git a/messenger-whatsapp/vite.config.ts b/messenger-whatsapp/vite.config.ts
new file mode 100644
index 00000000..46a29db4
--- /dev/null
+++ b/messenger-whatsapp/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+import svgr from "vite-plugin-svgr";
+import path from "path";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss(), svgr()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: { port: 5000 },
+});