Skip to content

Commit 07edd4f

Browse files
authored
Merge pull request #67 from ChatFlowProject/feat/api/chat/FLOW-35
[feat/api/chat/flow 35] 채팅 관련 기능 추가
2 parents 6cd4f1c + 45b02bb commit 07edd4f

16 files changed

Lines changed: 336 additions & 221 deletions

File tree

src/service/feature/channel/hook/query/useChannelQuery.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import { useQuery } from '@tanstack/react-query';
2-
import {
3-
getChannelList,
4-
getDMList,
5-
} from '@service/feature/channel/api/channelAPI.ts';
6-
import { Channel } from '@service/feature/channel/types/channel.ts';
2+
import { getChannelList, getDMList } from '@service/feature/channel/api/channelAPI.ts';
3+
import {ChannelResponse} from '@service/feature/channel/types/channel.ts';
74

8-
// response 형식이 다름. 임시로 설정하여 사용중
95
export const useChannelListQuery = (serverId: string) => {
10-
return useQuery<Channel>({
6+
return useQuery<ChannelResponse>({
117
queryKey: ['serverChannels', serverId],
128
queryFn: () => getChannelList(serverId),
139
enabled: !!serverId,
Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
11
export type ChannelType = 'text' | 'voice' | 'event';
22

3-
// export interface Channel {
4-
// id: string;
5-
// name: string;
6-
// type: ChannelType;
7-
// category: string;
8-
// [key: string]: unknown;
9-
// }
10-
113
export interface DMDetail {
12-
channel: Channel2;
4+
channel: Channel;
135
channelMembers: ChannelMember[];
146
}
157

16-
export interface DMList extends Channel2 {
8+
export interface DMList extends Channel {
179
channelMembers: ChannelMember[];
1810
}
1911

@@ -26,8 +18,7 @@ export interface ChannelMember {
2618
createdAt: string;
2719
}
2820

29-
// 팀 서버 상세 조회에서 불러오는 channel 타입도 이것. 추후 아래 Channel 타입에서 이걸로 변경해야 할 듯
30-
export interface Channel2 {
21+
export interface Channel {
3122
id: number;
3223
name: string;
3324
position: number;
@@ -36,29 +27,36 @@ export interface Channel2 {
3627
chatId: string;
3728
}
3829

39-
export interface Channel {
40-
categoriesView: CategoriesView[];
41-
team: Team;
42-
teamMembers: TeamMembers[];
43-
}
44-
45-
export interface CategoriesView {
30+
export interface CategoryView {
4631
category: {
4732
id: number;
4833
name: string;
4934
position: number;
5035
};
51-
}
36+
channels: Channel[];
5237

53-
export interface Team {
54-
id: string;
55-
name: string;
56-
masterId: string;
57-
iconUrl: string;
5838
}
5939

60-
export interface TeamMembers {
61-
id: number;
62-
role: 'OWNER' | 'MEMBER';
63-
memberInfo: ChannelMember;
40+
export interface ChannelResponse {
41+
team: {
42+
id: string;
43+
name: string;
44+
masterId: string;
45+
iconUrl: string;
46+
};
47+
categoriesView: CategoryView[];
48+
teamMembers: {
49+
id: number;
50+
role: 'OWNER' | 'MEMBER';
51+
memberInfo: ChannelMember;
52+
}[];
6453
}
54+
55+
export interface ChannelMember {
56+
id: string;
57+
nickname: string;
58+
name: string;
59+
avatarUrl: string;
60+
state: 'ONLINE' | 'OFFLINE';
61+
createdAt: string;
62+
}

src/service/feature/chat/api/chatAPI.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ export const fetchChannels = async () => {
77
return res.data;
88
};
99

10-
export const fetchMessages = async (channelId: string) => {
11-
const res = await axios.get(`/channels/${channelId}/messages`);
12-
return res.data;
10+
export const fetchLatestMessages = async (channelId: string | undefined) => {
11+
const res = await axios.get(`/message/latest?chatId=${channelId}`);
12+
return Array.isArray(res.data) ? res.data : [];
1313
};
1414

1515
export const deleteMessage = async (messageId: string) => {
Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,82 @@
1-
import { useEffect } from 'react';
1+
import { v4 as uuidv4 } from 'uuid';
2+
import { useEffect, useCallback } from 'react';
23
import { useSocket } from '../context/useSocket';
34
import { ChatMessage } from '@service/feature/chat/schema/messageSchema.ts';
45

5-
export const useChat = (onMessage: (msg: ChatMessage) => void) => {
6+
export const useChat = (chatId: string | undefined, onMessage: (msg: ChatMessage) => void) => {
67
const { client, isConnected } = useSocket();
7-
const chatId = '25ffc7bf-874f-444e-b331-26ed864a76ba';
88

99
useEffect(() => {
10-
if (!client || !isConnected) return;
10+
let subscription: any;
11+
12+
const setupSubscription = () => {
13+
if (!client || !isConnected || !chatId) {
14+
console.log('채팅 구독 조건이 충족되지 않음:', { client: !!client, isConnected, chatId });
15+
return;
16+
}
17+
18+
try {
19+
const subscribeUrl = `/sub/message/${chatId}`;
20+
console.log('채팅 구독 시도:', subscribeUrl);
21+
22+
subscription = client.subscribe(subscribeUrl, (message) => {
23+
const parsed: ChatMessage = JSON.parse(message.body);
24+
onMessage(parsed);
25+
});
26+
27+
console.log('채팅 구독 성공');
28+
} catch (error) {
29+
console.error('STOMP 구독 중 오류 발생:', error);
30+
}
31+
};
1132

12-
const subscribeUrl = `/sub/message/${chatId}`;
13-
const subscription = client.subscribe(subscribeUrl, (message) => {
14-
const parsed: ChatMessage = JSON.parse(message.body);
15-
onMessage(parsed);
16-
});
33+
setupSubscription();
1734

1835
return () => {
19-
subscription.unsubscribe();
36+
if (subscription) {
37+
try {
38+
subscription.unsubscribe();
39+
console.log('채팅 구독 해제');
40+
} catch (error) {
41+
console.error('구독 해제 중 오류 발생:', error);
42+
}
43+
}
2044
};
21-
}, [client, isConnected, onMessage]);
45+
}, [client, isConnected, chatId, onMessage]);
2246

23-
const sendMessage = (content: string, attachments?: { type: string; url: string }[]) => {
24-
if (!client || !isConnected) return;
47+
const sendMessage = useCallback(async (content: string, attachments?: { type: string; url: string }[]) => {
48+
if (!client || !isConnected || !chatId) {
49+
console.warn('메시지를 보낼 수 없습니다:', {
50+
client: !!client,
51+
isConnected,
52+
chatId
53+
});
54+
return Promise.reject(new Error('연결 상태가 올바르지 않습니다.'));
55+
}
2556

57+
const tempId = uuidv4();
2658
const sendUrl = `/pub/message/${chatId}`;
27-
const message = { chatId, content, attachments, createdAt: new Date().toISOString()};
59+
const message = {
60+
chatId,
61+
content,
62+
attachments,
63+
createdAt: new Date().toISOString(),
64+
tempId
65+
};
2866

29-
client.publish({
30-
destination: sendUrl,
31-
body: JSON.stringify(message),
67+
return new Promise((resolve, reject) => {
68+
try {
69+
client.publish({
70+
destination: sendUrl,
71+
body: JSON.stringify(message),
72+
});
73+
resolve(tempId);
74+
} catch (error) {
75+
console.error('메시지 전송 중 오류 발생:', error);
76+
reject(error);
77+
}
3278
});
33-
};
79+
}, [client, isConnected, chatId]);
3480

35-
return { sendMessage };
81+
return { sendMessage, isConnected };
3682
};
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { useQuery } from '@tanstack/react-query';
2-
import { fetchMessages } from '../api/chatAPI';
2+
import { fetchLatestMessages } from '../api/chatAPI';
33

4-
export const useMessageHistory = (channelId: string) => {
4+
export const useMessageHistory = (channelId: string | undefined) => {
55
return useQuery({
66
queryKey: ['messages', channelId],
7-
queryFn: () => fetchMessages(channelId),
7+
queryFn: () => fetchLatestMessages(channelId),
88
enabled: !!channelId,
9+
staleTime: 1000*30
910
});
1011
};

src/service/feature/chat/schema/messageSchema.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { z } from 'zod';
22

33
const senderSchema = z.object({
44
memberId: z.string(),
5-
username: z.string(),
5+
name: z.string(),
66
avatarUrl: z.string(),
77
});
88

@@ -12,13 +12,15 @@ const attachmentSchema = z.object({
1212
});
1313

1414
export const messageSchema = z.object({
15-
chatId: z.string(),
15+
messageId: z.number(),
1616
sender: senderSchema,
1717
content: z.string().min(1, '메시지를 입력해주세요'),
18+
createdAt: z.string().datetime({ message: '올바른 날짜/시간 형식이 아닙니다' }),
19+
isUpdated: z.boolean(),
20+
isDeleted: z.boolean(),
1821
attachments: z.array(attachmentSchema).optional(),
19-
createdAt: z
20-
.string()
21-
.datetime({ message: '올바른 날짜/시간 형식이 아닙니다' }),
22+
status: z.enum(['pending', 'sent', 'error']).optional(),
23+
tempId: z.string().optional(),
2224
});
2325

2426
export type ChatMessage = z.infer<typeof messageSchema>;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// "messageId": 13,
2+
// "sender": {
3+
// "memberId": "fc810ff3-a156-410c-80db-939440507dc3",
4+
// "name": "최승은",
5+
// "avatarUrl": ""
6+
// },
7+
// "content": "ccc",
8+
// "createdAt": "2025-06-03T22:34:07.542118",
9+
// "isUpdated": false,
10+
// "isDeleted": false,
11+
// "attachments": []
12+
// },
13+
14+
export interface ChatMessage {
15+
messageId : number;
16+
sender: {
17+
memberId: string;
18+
name: string;
19+
avatarUrl: string;
20+
},
21+
content: string,
22+
createdAt: string,
23+
isUpdated: boolean,
24+
isDeleted: boolean,
25+
attachments?: { type: string; url: string }[];
26+
status?: 'pending' | 'sent' | 'error';
27+
tempId?: string;
28+
}

src/view/layout/sidebar/components/channel/ChannelCategory.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from 'react';
2-
import { DndContext } from '@dnd-kit/core';
2+
import {DndContext, DragEndEvent} from '@dnd-kit/core';
33
import {
44
SortableContext,
55
verticalListSortingStrategy,
@@ -9,21 +9,17 @@ import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
99
import ChannelItem from './ChannelItem.tsx';
1010
import { Channel } from '@service/feature/channel/types/channel.ts';
1111

12-
const ChannelCategory = ({
13-
title,
14-
type,
15-
defaultItems,
16-
}: {
12+
const ChannelCategory = ({title, type, defaultItems,}: {
1713
title: string;
1814
type: 'text' | 'voice' | 'event';
1915
defaultItems: Channel[];
2016
}) => {
2117
const [isOpen, setIsOpen] = useState(true);
2218
const [items, setItems] = useState(defaultItems);
2319

24-
const handleDragEnd = (event: any) => {
20+
const handleDragEnd = (event: DragEndEvent) => {
2521
const { active, over } = event;
26-
if (active.id !== over?.id) {
22+
if (over && active.id !== over.id) {
2723
const oldIndex = items.findIndex((i) => i.id === active.id);
2824
const newIndex = items.findIndex((i) => i.id === over.id);
2925
setItems((items) => arrayMove(items, oldIndex, newIndex));
@@ -53,6 +49,7 @@ const ChannelCategory = ({
5349
id={item.id}
5450
name={item.name}
5551
type={type}
52+
chatId={item.chatId}
5653
/>
5754
))}
5855
</div>

src/view/layout/sidebar/components/channel/ChannelItem.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,41 @@
1-
import { useDraggable } from '@dnd-kit/core';
2-
import { Hash, Radio, Volume2 } from 'lucide-react';
3-
import { clsx } from 'clsx';
1+
import { useNavigate, useParams } from 'react-router-dom';
2+
import { useSortable } from '@dnd-kit/sortable';
3+
import { CSS } from '@dnd-kit/utilities';
4+
import { Hash } from 'lucide-react';
45

5-
const ChannelItem = ({ id, name, type = 'text', selected = false, }: {
6-
id: string;
6+
interface ChannelItemProps {
7+
id: number;
78
name: string;
89
type?: 'text' | 'voice' | 'event';
9-
selected?: boolean;
10-
}) => {
11-
const icon =
12-
type === 'voice' ? <Volume2 size={16} /> :
13-
type === 'event' ? <Radio size={16} /> :
14-
<Hash size={16} />;
10+
chatId: string;
11+
}
1512

16-
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id, });
13+
const ChannelItem = ({ id, name, chatId }: ChannelItemProps) => {
14+
const navigate = useNavigate();
15+
const { serverId } = useParams<{ serverId: string }>();
16+
17+
const {attributes, listeners, setNodeRef, transform, transition,} = useSortable({ id });
1718

18-
const style = transform
19-
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, opacity: 0.8, }
20-
: undefined;
19+
const style = {
20+
transform: CSS.Transform.toString(transform),
21+
transition,
22+
};
23+
24+
const handleClick = () => {
25+
navigate(`/channels/${serverId}/${chatId}`);
26+
};
2127

2228
return (
23-
<div ref={setNodeRef} style={style}{...listeners}{...attributes}
24-
className={clsx(
25-
'flex items-center gap-2 px-2 py-1 text-sm text-[#b9bbbe] rounded hover:bg-[#3A3C41] cursor-pointer select-none',
26-
selected && 'bg-[#393C43] text-white', isDragging && 'opacity-50')}>
27-
{icon}
28-
<span className="truncate">{name}</span>
29+
<div
30+
ref={setNodeRef}
31+
style={style}{...attributes}{...listeners}
32+
className="px-2 py-1 mx-2 text-[#949ba4] hover:text-white hover:bg-[#393C43] rounded cursor-pointer"
33+
onClick={handleClick}
34+
>
35+
<div className="flex items-center gap-1.5">
36+
<Hash size={18} />
37+
<span className="text-sm font-medium">{name}</span>
38+
</div>
2939
</div>
3040
);
3141
};

0 commit comments

Comments
 (0)