Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ app-example
# generated native folders
/ios
/android

# claude
CLAUDE.md
.claude/
Comment on lines +44 to +47

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It's generally good practice to end files with a newline character. This helps with version control systems and some tools that expect a newline at the end of a file.


# claude
CLAUDE.md
.claude/

2 changes: 2 additions & 0 deletions src/entities/chat/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { ChatRoomItemProps, Message, MessageItemProps } from "./model/types";
export { ChatRoomItem, MessageBubble, MessageItem, MessageTime } from "./ui";
23 changes: 23 additions & 0 deletions src/entities/chat/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ImageSource } from "expo-image";

export interface ChatRoomItemProps {
profileImage: ImageSource;
roomName: string;
lastMessage: string;
unreadCount?: number;
}

export interface Message {
id: string;
text: string;
senderId: string;
/** "HH:mm" 포맷 */
sentAt: string;
}

export interface MessageItemProps {
message: Message;
isMine: boolean;
/** received λ©”μ‹œμ§€μΌ λ•Œλ§Œ ν•„μš” */
profileImage?: ImageSource;
}
46 changes: 46 additions & 0 deletions src/entities/chat/ui/ChatRoomItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Text, View } from "react-native";

import { ProfileAvatar } from "@/shared/ui";

import type { ChatRoomItemProps } from "../model/types";

export function ChatRoomItem({
profileImage,
roomName,
lastMessage,
unreadCount = 0,
}: ChatRoomItemProps) {
return (
<View className="w-full flex-row items-center justify-between px-[10px] gap-[27px]">
{/* Left: profile + texts */}
<View className="flex-row shrink items-center gap-[27px]">
<ProfileAvatar source={profileImage} size={48} />

{/* Text area */}
<View className="shrink">
<Text
className="font-bold text-[16px] leading-[22px] text-content-primary"
numberOfLines={1}
Comment on lines +22 to +23

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The className string for roomName has an extra space before text-content-primary. This is a minor stylistic issue.

Suggested change
className="font-bold text-[16px] leading-[22px] text-content-primary"
numberOfLines={1}
className="font-bold text-[16px] leading-[22px] text-content-primary"

>
{roomName}
</Text>
<Text
className="font-regular text-[13px] leading-[22px] tracking-[-0.41px] text-content-secondary"
numberOfLines={1}
>
{lastMessage}
</Text>
</View>
</View>

{/* Right: unread count badge */}
{unreadCount > 0 && (
<View className="rounded-full bg-primary justify-center px-[8px]">
<Text className="font-regular text-[12px] leading-[22px] tracking-[-0.41px] text-white">
{unreadCount}
</Text>
</View>
)}
</View>
);
}
28 changes: 28 additions & 0 deletions src/entities/chat/ui/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Text, View } from "react-native";

interface MessageBubbleProps {
text: string;
variant: "sent" | "received";
}

export function MessageBubble({ text, variant }: MessageBubbleProps) {
const isSent = variant === "sent";

return (
<View
className={[
"max-w-[260px] rounded-[10px] px-[10px] py-[10px]",
isSent ? "bg-primary" : "bg-neutral",
].join(" ")}
>
<Text
className={[
"font-light text-[14px]",
isSent ? "text-white" : "text-content-primary",
].join(" ")}
>
{text}
</Text>
</View>
);
}
36 changes: 36 additions & 0 deletions src/entities/chat/ui/MessageItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { View } from "react-native";

import { ProfileAvatar } from "@/shared/ui";

import type { MessageItemProps } from "../model/types";
import { MessageBubble } from "./MessageBubble";
import { MessageTime } from "./MessageTime";

export function MessageItem({ message, isMine, profileImage }: MessageItemProps) {
const variant = isMine ? "sent" : "received";

if (isMine) {
return (
<View className="flex-row justify-end items-end gap-[6px] px-screen-m mb-5">
<MessageTime time={message.sentAt} variant={variant} />
{/* shrink: 버블이 μ‹œκ°„ ν…μŠ€νŠΈλ₯Ό λ°€μ–΄λ‚΄μ§€ μ•Šλ„λ‘ μ••μΆ• ν—ˆμš© */}
<View className="shrink items-end">
<MessageBubble text={message.text} variant={variant} />
</View>
</View>
);
}

return (
<View className="flex-row items-center items-end gap-[10px] px-screen-m mb-5">
<ProfileAvatar source={profileImage} size={47} />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ProfileAvatar component is rendered with size={47}. It's generally better to use consistent sizing or define a clear reason for such a specific size, especially when ProfileAvatar in ChatRoomItem uses size={48}. Consider using 48 for consistency or abstracting this into a design system variable.

{/* flex-1: 아바타 이후 남은 곡간을 μ •ν™•νžˆ νŒŒμ•…ν•΄ 버블+μ‹œκ°„μ΄ λ„˜μΉ˜μ§€ μ•Šκ²Œ 함 */}
<View className="flex-1 flex-row items-end gap-[6px]">
<View className="shrink">
<MessageBubble text={message.text} variant={variant} />
</View>
<MessageTime time={message.sentAt} variant={variant} />
</View>
</View>
);
}
19 changes: 19 additions & 0 deletions src/entities/chat/ui/MessageTime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Text } from "react-native";

interface MessageTimeProps {
time: string;
variant: "sent" | "received";
}

export function MessageTime({ time, variant }: MessageTimeProps) {
return (
<Text
className={[
"font-light text-[11px] leading-[16px] text-[#666666]",
variant === "sent" ? "text-right" : "text-left",
].join(" ")}
>
{time}
</Text>
);
}
150 changes: 150 additions & 0 deletions src/entities/chat/ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# entities/chat/ui

μ±„νŒ… 도메인 UI μ»΄ν¬λ„ŒνŠΈ λͺ¨μŒ.

---

## ChatRoomItem

μ±„νŒ…λ°© λͺ©λ‘μ—μ„œ 각 행을 ν‘œμ‹œν•˜λŠ” μ»΄ν¬λ„ŒνŠΈ.

```tsx
import { ChatRoomItem } from "@/entities/chat";

<ChatRoomItem
profileImage={require("@/shared/assets/images/profile.png")}
roomName="λ–‘μ§‘ν• λ¨Έλ‹ˆλŠ”λˆλͺ©μš•"
lastMessage="제휴 ν˜‘λ ₯ μš”μ²­ λ“œλ¦¬κ³  μ‹ΆμŠ΅λ‹ˆλ‹€!"
unreadCount={10}
/>
```

| Prop | νƒ€μž… | ν•„μˆ˜ | μ„€λͺ… |
|------|------|------|------|
| `profileImage` | `ImageSource` | βœ… | ν”„λ‘œν•„ 이미지 |
| `roomName` | `string` | βœ… | μ±„νŒ…λ°© 이름 |
| `lastMessage` | `string` | βœ… | λ§ˆμ§€λ§‰ λ©”μ‹œμ§€ |
| `unreadCount` | `number` | ❌ | 읽지 μ•Šμ€ λ©”μ‹œμ§€ 수. 0이면 λ°°μ§€ λ―Έν‘œμ‹œ. |

---

## MessageItem

받은 λ©”μ‹œμ§€Β·λ³΄λ‚Έ λ©”μ‹œμ§€λ₯Ό λͺ¨λ‘ μ²˜λ¦¬ν•˜λŠ” μ‘°ν•© μ»΄ν¬λ„ŒνŠΈ. `isMine` 값에 따라 λ ˆμ΄μ•„μ›ƒμ΄ 달라진닀.

```tsx
import { MessageItem } from "@/entities/chat";

// 받은 λ©”μ‹œμ§€
<MessageItem
message={{
id: "1",
text: "제휴 ν˜‘λ ₯ μš”μ²­ λ“œλ¦¬κ³  μ‹ΆμŠ΅λ‹ˆλ‹€!",
senderId: "other-user-id",
sentAt: "17:42",
unreadCount: 6,
}}
isMine={false}
profileImage={require("@/shared/assets/images/profile.png")}
/>

// 보낸 λ©”μ‹œμ§€
<MessageItem
message={{
id: "2",
text: "λ„€, 말씀해 μ£Όμ„Έμš”!",
senderId: "my-user-id",
sentAt: "17:43",
}}
isMine={true}
/>
```

| Prop | νƒ€μž… | ν•„μˆ˜ | μ„€λͺ… |
|------|------|------|------|
| `message` | `Message` | βœ… | λ©”μ‹œμ§€ 데이터 |
| `isMine` | `boolean` | βœ… | `true`λ©΄ 였λ₯Έμͺ½ μ •λ ¬(νŒŒλž€ 버블), `false`λ©΄ μ™Όμͺ½ μ •λ ¬(흰 버블) |
| `profileImage` | `ImageSource` | ❌ | 받은 λ©”μ‹œμ§€(`isMine=false`)일 λ•Œλ§Œ μ‚¬μš© |

### Message νƒ€μž…

```ts
interface Message {
id: string;
text: string;
senderId: string;
sentAt: string; // "HH:mm" 포맷
unreadCount?: number; // ν”„λ‘œν•„ μš°ν•˜λ‹¨ λ°°μ§€ 숫자
}
```

---

## MessageBubble

말풍선 단독 μ»΄ν¬λ„ŒνŠΈ. `MessageItem` λ‚΄λΆ€μ—μ„œ μ‚¬μš©λ˜λ©°, ν•„μš” μ‹œ λ‹¨λ…μœΌλ‘œλ„ μ‚¬μš© κ°€λŠ₯.

```tsx
import { MessageBubble } from "@/entities/chat";

<MessageBubble text="μ•ˆλ…•ν•˜μ„Έμš”!" variant="received" />
<MessageBubble text="λ„€, μ•ˆλ…•ν•˜μ„Έμš”!" variant="sent" />
```

| Prop | νƒ€μž… | μ„€λͺ… |
|------|------|------|
| `text` | `string` | λ©”μ‹œμ§€ ν…μŠ€νŠΈ |
| `variant` | `"sent" \| "received"` | `sent`=νŒŒλž€ λ°°κ²½, `received`=neutral λ°°κ²½ |

---

## MessageTime

전솑 μ‹œκ°„ ν…μŠ€νŠΈ 단독 μ»΄ν¬λ„ŒνŠΈ.

```tsx
import { MessageTime } from "@/entities/chat";

<MessageTime time="17:42" variant="received" />
<MessageTime time="17:42" variant="sent" />
```

| Prop | νƒ€μž… | μ„€λͺ… |
|------|------|------|
| `time` | `string` | `"HH:mm"` 포맷 λ¬Έμžμ—΄ |
| `variant` | `"sent" \| "received"` | μ •λ ¬ λ°©ν–₯ κ²°μ • |

---

## μ±„νŒ…λ°© ν™”λ©΄μ—μ„œμ˜ λ ˆμ΄μ•„μ›ƒ

```tsx
import { FlatList, KeyboardAvoidingView, Platform } from "react-native";
import { MessageItem } from "@/entities/chat";
import { ChatBar } from "@/features/send-message";

function ChatRoomPage() {
return (
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<FlatList
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<MessageItem
message={item}
isMine={item.senderId === myUserId}
profileImage={item.senderId !== myUserId ? senderProfile : undefined}
/>
)}
contentContainerStyle={{ gap: 12, paddingVertical: 16 }}
inverted
/>

<ChatBar onSend={handleSend} />
</KeyboardAvoidingView>
);
}
```
4 changes: 4 additions & 0 deletions src/entities/chat/ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { ChatRoomItem } from "./ChatRoomItem";
export { MessageBubble } from "./MessageBubble";
export { MessageItem } from "./MessageItem";
export { MessageTime } from "./MessageTime";
1 change: 1 addition & 0 deletions src/features/send-message/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ChatBar } from "./ui";
59 changes: 59 additions & 0 deletions src/features/send-message/ui/ChatBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { TextInput, TouchableOpacity, View } from "react-native";

import { colorTokens } from "@/shared/styles/tokens";

interface ChatBarProps {
onSend: (message: string) => void;
onAttach?: () => void;
}

export function ChatBar({
onSend,
onAttach,
}: ChatBarProps) {
const [text, setText] = useState("");

const canSend = text.trim().length > 0;

function handleSend() {
if (!canSend) return;
onSend(text.trim());
setText("");
}

return (
<View className="px-screen-m pt-[18px] pb-[21px]">
<View className="flex-row items-end rounded-[10px] bg-neutral px-[10px] py-[10px] gap-[10px]">
{/* 첨뢀(+) λ²„νŠΌ */}
<TouchableOpacity onPress={onAttach} activeOpacity={0.7}>
<Ionicons name="add" size={22} color={colorTokens.contentSecondary} />
</TouchableOpacity>

{/* ν…μŠ€νŠΈ μž…λ ₯ */}
<TextInput
className="flex-1 font-light text-[14px] leading-[22px] text-content-primary"
style={{ paddingVertical: 0 }}
value={text}
onChangeText={setText}
multiline
/>

{/* 전솑 λ²„νŠΌ */}
<TouchableOpacity
onPress={handleSend}
disabled={!canSend}
activeOpacity={0.7}
className="py-[2px]"
>
<Ionicons
name="send"
size={18}
color={canSend ? colorTokens.primary : colorTokens.contentSecondary}
/>
</TouchableOpacity>
</View>
</View>
);
}
Loading