-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT/#11] chat components #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
e711bdd
3579381
82dfb96
805aefd
11dd947
a089ae7
6f67759
17b9495
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,3 +41,7 @@ app-example | |
| # generated native folders | ||
| /ios | ||
| /android | ||
|
|
||
| # claude | ||
| CLAUDE.md | ||
| .claude/ | ||
| 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"; |
| 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; | ||
| } |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| > | ||
| {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> | ||
| ); | ||
| } | ||
| 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> | ||
| ); | ||
| } |
| 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} /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| {/* 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> | ||
| ); | ||
| } | ||
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } | ||
| ``` |
| 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"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { ChatBar } from "./ui"; |
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.