Skip to content
Merged
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
51 changes: 44 additions & 7 deletions app/routes/rooms/api/attachments.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,51 @@
import { axiosInstance } from "../../../api/axios";
export type AttachmentType = "IMAGE" | "FILE";
export type AttachmentUsage = "CHAT" | "PUBLIC";

export async function uploadAttachment(file: File) {
const formData = new FormData();
formData.append("file", file);
export interface ChatAttachmentUploadResponse {
attachmentId: number;
attachmentType: "IMAGE" | "FILE";
contentType: string;
originalName: string;
fileSize: number;
accessUrl: string; // presigned (TTL)
status: "READY";
createdAt: string;
}

const res = await axiosInstance.post("/api/v1/attachments", formData, {
/**
* 주의:
* - fetch를 쓸 때 Content-Type을 직접 multipart/form-data로 세팅하지 마세요.
* 브라우저가 boundary 포함해서 자동으로 잡아줘야 합니다.
*/
export async function uploadAttachment(params: {
token: string;
file: File;
attachmentType: AttachmentType;
usage: AttachmentUsage; // 보통 "CHAT"
baseUrl: string; // 예: import.meta.env.VITE_API_BASE_URL
}): Promise<ChatAttachmentUploadResponse> {
const { token, file, attachmentType, usage, baseUrl } = params;

const form = new FormData();
form.append("attachmentType", attachmentType);
form.append("usage", usage);
form.append("file", file, file.name);

const res = await fetch(`${baseUrl}/api/v1/attachments`, {
method: "POST",
headers: {
"Content-Type": "multipart/form-data",
Authorization: `Bearer ${token}`,
// Content-Type 넣지 말기!
},
body: form,
});

return res.data;
// 실패시 throw
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Attachment upload failed: ${res.status} ${text}`);
}

const data = (await res.json()) as ChatAttachmentUploadResponse;
return data;
}
142 changes: 129 additions & 13 deletions app/routes/rooms/chatting-room.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { useEffect, useMemo, useRef, useState } from "react";

import { tokenStorage } from "../../lib/token";
import NavigationHeader from "../../components/common/NavigateHeader";
import ChatComposer from "./components/ChatComposer";
import AttachmentSheet, { type AttachmentAction } from "./components/AttachmentSheet";
import AttachmentSheet, {
type AttachmentAction,
} from "./components/AttachmentSheet";
import useKeyboardOffset from "../../hooks/KeyboardOffset";
import MessageRenderer from "./components/MessageRender";
import { formatKoreanDateTime } from "../../utils/dateTime";
import CollaborationSummaryBar from "./components/CollaborationBar";
import { useHideBottomTab } from "../../hooks/useHideBottomTab";
import { useHideHeader } from "../../hooks/useHideHeader";

import {
createOrGetDirectRoom,
getChatRoomDetail,
type ChatRoomDetailResponse,
getChatMessages,
type ChatMessage,
createOrGetDirectRoom,
} from "./api/rooms";

import { useAuthStore } from "../../stores/auth-store";
import useAttachmentUpload from "../rooms/hooks/useAttachmentUpload";
import { tokenStorage } from "../../lib/token";

type Props = {
brandId: number;
Expand All @@ -38,19 +38,21 @@ export default function ChattingRoom({ brandId }: Props) {
const inputRef = useRef<HTMLInputElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);

const myUserId = Number(tokenStorage.getUserId() ?? 0);
const accessToken = tokenStorage.getAccessToken();
const baseUrl = import.meta.env.VITE_API_BASE_URL;

useHideBottomTab(true);
useHideHeader(true);

const accessToken = tokenStorage.getAccessToken?.();
const myUserId = useAuthStore((s) => Number(s.me?.id ?? 0));

const partnerName = detail?.opponentName ?? "";
const partnerAvatarUrl = detail?.opponentProfileImageUrl ?? "";
const isCollaborating = detail?.isCollaborating ?? false;

const collabTitle = detail?.campaignSummary?.campaignTitle ?? "";
const collabSubtitle = detail?.campaignSummary?.brandName ?? "";
const collabThumb = detail?.campaignSummary?.campaignImageUrl ?? partnerAvatarUrl;
const collabThumb =
detail?.campaignSummary?.campaignImageUrl ?? partnerAvatarUrl;
const summaryBarHeight = isCollaborating ? 64 : 0;

const createdAt = useMemo(() => {
Expand All @@ -72,7 +74,10 @@ export default function ChattingRoom({ brandId }: Props) {

const run = async () => {
try {
const result = await createOrGetDirectRoom({ brandId, creatorId: myUserId });
const result = await createOrGetDirectRoom({
brandId,
creatorId: myUserId,
});
setRoomId(result.roomId);
} catch (e) {
console.error("createOrGetDirectRoom failed:", e);
Expand All @@ -83,6 +88,26 @@ export default function ChattingRoom({ brandId }: Props) {
run();
}, [accessToken, brandId, myUserId]);

const imageInputRef = useRef<HTMLInputElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);

const { upload } = useAttachmentUpload({
baseUrl,
accessToken,
defaultUsage: "CHAT",
});

const handleAttachmentAction = (key: "suggest" | "image" | "file") => {
if (key === "image") {
imageInputRef.current?.click();
return;
}
if (key === "file") {
fileInputRef.current?.click();
return;
}
};

useEffect(() => {
if (!accessToken) return;
if (!roomId) return;
Expand Down Expand Up @@ -169,6 +194,78 @@ export default function ChattingRoom({ brandId }: Props) {
requestAnimationFrame(() => inputRef.current?.focus());
};

const handlePickImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;

try {
const uploaded = await upload({ file, attachmentType: "IMAGE" });

const tempMessage: ChatMessage = {
messageId: -Date.now(),
roomId,
senderId: myUserId,
senderType: "USER",
messageType: "IMAGE",
content: null,
attachment: {
attachmentId: uploaded.attachmentId,
attachmentType: "IMAGE",
contentType: uploaded.contentType,
originalName: uploaded.originalName,
fileSize: uploaded.fileSize,
accessUrl: uploaded.accessUrl,
status: "READY",
},
systemMessage: null,
createdAt: new Date().toISOString(),
clientMessageId: crypto.randomUUID(),
};

setMessages((prev) => [...prev, tempMessage]);
setIsSheetOpen(false);
} catch (err) {
console.error(err);
}
};

const handlePickFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;

try {
const uploaded = await upload({ file, attachmentType: "FILE" });

const tempMessage: ChatMessage = {
messageId: -Date.now(),
roomId,
senderId: myUserId,
senderType: "USER",
messageType: "FILE",
content: null,
attachment: {
attachmentId: uploaded.attachmentId,
attachmentType: "FILE",
contentType: uploaded.contentType,
originalName: uploaded.originalName,
fileSize: uploaded.fileSize,
accessUrl: uploaded.accessUrl,
status: "READY",
},
systemMessage: null,
createdAt: new Date().toISOString(),
clientMessageId: crypto.randomUUID(),
};

setMessages((prev) => [...prev, tempMessage]);
setIsSheetOpen(false);
} catch (err) {
console.error(err);
}
};

if (!accessToken) {
return (
<div className="h-screen-full bg-white">
Expand Down Expand Up @@ -198,10 +295,29 @@ export default function ChattingRoom({ brandId }: Props) {

return (
<div className="h-screen-full bg-gradient-to-b from-[#F6F6FF] via-[#F3F3FA] to-[#E8E8FB]">
<input
ref={imageInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handlePickImage}
/>

<input
ref={fileInputRef}
type="file"
style={{ display: "none" }}
onChange={handlePickFile}
/>

<NavigationHeader title={partnerName} onBack={() => history.back()} />

{detail?.campaignSummary && (
<CollaborationSummaryBar thumbnailUrl={collabThumb} title={collabTitle} subtitle={collabSubtitle} />
<CollaborationSummaryBar
thumbnailUrl={collabThumb}
title={collabTitle}
subtitle={collabSubtitle}
/>
)}

<div
Expand All @@ -220,7 +336,6 @@ export default function ChattingRoom({ brandId }: Props) {
message={m}
timeText={createdAt}
avatarSrc={isMe ? undefined : partnerAvatarUrl}
isCollaborating={isCollaborating}
/>
);
})}
Expand All @@ -243,6 +358,7 @@ export default function ChattingRoom({ brandId }: Props) {
actions={actions}
onClose={handleCloseSheet}
onAction={(key) => {
handleAttachmentAction(key);
console.log("action:", key);
setIsSheetOpen(false);
}}
Expand Down
2 changes: 0 additions & 2 deletions app/routes/rooms/components/MessageRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,12 @@ type Props = {
message: ChatMessage;
timeText?: string;
avatarSrc?: string;
isCollaborating?: boolean;
};

export default function MessageRenderer({
message,
timeText,
avatarSrc,
//isCollaborating,
}: Props) {

switch (message.messageType) {
Expand Down
76 changes: 76 additions & 0 deletions app/routes/rooms/hooks/useAttachmentUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useCallback, useState } from "react";
import { uploadAttachment, type ChatAttachmentUploadResponse, type AttachmentType, type AttachmentUsage } from "../api/attachments";

/**
* 업로드 훅
* - uploading / error / lastUploaded 상태 제공
* - upload(file, options) 호출하면 attachment 업로드 수행
*/
export default function useAttachmentUpload(params: {
baseUrl: string;
token: string | null;
defaultUsage?: AttachmentUsage; // 기본 "CHAT"
}) {
const { baseUrl, token, defaultUsage = "CHAT" } = params;

const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUploaded, setLastUploaded] =
useState<ChatAttachmentUploadResponse | null>(null);

const upload = useCallback(
async (args: {
file: File;
attachmentType?: AttachmentType; // 미지정 시 자동 판별
usage?: AttachmentUsage;
}) => {
if (!token) {
throw new Error("로그인이 필요합니다. (token이 없습니다)");
}

const { file } = args;
const usage = args.usage ?? defaultUsage;

const attachmentType: AttachmentType =
args.attachmentType ??
(file.type.startsWith("image/") ? "IMAGE" : "FILE");

setUploading(true);
setError(null);

try {
const uploaded = await uploadAttachment({
token,
file,
attachmentType,
usage,
baseUrl,
});

setLastUploaded(uploaded);
return uploaded;
} catch (e) {
const msg = e instanceof Error ? e.message : "업로드 실패";
setError(msg);
throw e;
} finally {
setUploading(false);
}
},
[token, baseUrl, defaultUsage]
);

const reset = useCallback(() => {
setError(null);
setLastUploaded(null);
setUploading(false);
}, []);

return {
uploading,
error,
lastUploaded,
upload,
reset,
};
}
12 changes: 12 additions & 0 deletions app/routes/rooms/hooks/useAuthScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useEffect } from "react";

export function useAutoScroll(
listRef: React.RefObject<HTMLElement | null>,
deps: React.DependencyList
) {
useEffect(() => {
const el = listRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, deps);

Check warning on line 11 in app/routes/rooms/hooks/useAuthScroll.ts

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'listRef'. Either include it or remove the dependency array

Check warning on line 11 in app/routes/rooms/hooks/useAuthScroll.ts

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies

Check warning on line 11 in app/routes/rooms/hooks/useAuthScroll.ts

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'listRef'. Either include it or remove the dependency array

Check warning on line 11 in app/routes/rooms/hooks/useAuthScroll.ts

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect was passed a dependency list that is not an array literal. This means we can't statically verify whether you've passed the correct dependencies
}
Loading
Loading