마지막 읽은 메시지 표시
마지막 읽은 메시지 표시는 상대가 어디까지 읽었는지 시각적으로 표시하는 기능입니다.
채팅 앱에서 마지막으로 읽은 메시지에 "읽음" 표시(예: ✅ 또는 "읽음 · 3분 전")를 해주는 기능은 사용자 경험과 커뮤니케이션 명확성을 크게 향상시킵니다.
마지막 읽은 메시지 표시 구현
마지막 읽은 메시지를 나타내기 위해서는 다음과 같이 구현할 수 있습니다.
- profiles 컬렉션 문서의 lastReadMessageId 필드를 사용합니다.
- 채팅방 입장할 때, 내 프로필에 lastReadMessageId 업데이트합니다.
- 채팅방 입장 시, 상대방이 읽은 마지막 메시지ID를 가져와서
- 화면 렌더링 시, 상대방이 읽은 마지막 메시지 아래에 "여기까지 읽음" 표시합니다.
마지막 읽은 메시지 표시 UI
chatRooms.vue의 template
마지막 읽은 메시지 업데이트
chatRooms.vue의 script
authStore
마지막 메시지 업데이트 함수
ChatRoom.vue
<!-- src/views/ChatRoom.vue -->
<template>
<v-container fluid>
<v-row>
<v-col cols="12" sm="10" md="8" class="mx-auto">
<h2 class="text-h6 my-4">
{{ otherUserName }}님과의 채팅
<v-chip
:color="status === 'online' ? 'green' : 'grey'"
class="ml-2"
small
text-color="white">
{{ status === 'online' ? 'Online' : `마지막 접속: ${timeAgo}` }}
</v-chip>
</h2>
<v-sheet height="60vh" class="overflow-y-auto">
<div ref="chatContainer"
class="chat-scroll-area"
style="height: 100%; overflow-y: auto;">
<div
v-for="msg in messages"
:key="msg.id"
:class="{
'justify-end': msg.senderId === auth.user.uid,
'justify-start': msg.senderId !== auth.user.uid,}"
class="d-flex">
<template v-if="msg.type === 'audio'">
<audio :src="msg.audioUrl" controls class="ma-1" />
</template>
<template v-else>
<v-sheet
max-width="60%"
:color="msg.senderId === auth.user.uid ? 'primary' : '#e1e1e1'"
:class="msg.senderId === auth.user.uid ? 'text-white' : 'text-black'"
class="pa-3 rounded-lg ma-1"
style="white-space: pre-line;"
elevation="1">
<div class="text-body-2">{{ msg.text }}</div>
</v-sheet>
</template>
<!-- 읽음 표시 -->
<div
v-if="msg.readAt"
class="text-caption text-grey ml-2 d-flex flex-column justify-end"
>
{{ formatTime(msg.readAt) }}
</div>
<!-- 마지막 읽은 메시지 표시 -->
<div
v-if="msg.id === otherUserLastReadMessageId"
class="text-caption text-black ml-2 d-flex flex-column justify-end">
여기까지 읽음
</div>
</div>
</div>
</v-sheet>
<v-form @submit.prevent="submitMessage" class="mt-4">
<v-textarea
v-model="text"
label="메시지를 입력하세요"
auto-grow
rows="1"
max-rows="5"
outlined
dense
append-inner-icon="mdi-send"
@click:append-inner="submitMessage"
/>
</v-form>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { ref, onMounted, nextTick, watch } from 'vue';
import { db, storage } from '@/firebase';
import { updateDoc, doc,
addDoc, collection, serverTimestamp
} from 'firebase/firestore';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/authStore';
import { useChatStore } from '@/stores/chatStore';
import { useChat } from '@/composables/useChat';
import { useProfileStatus } from '@/composables/useProfileStatus'
import { useTimeAgo } from '@/composables/useTimeAgo'; // 경로에 맞게!
import { format } from 'date-fns'; // 설치 필요 npm i date-fns
const auth = useAuthStore();
const route = useRoute();
const chatRoomId = route.query.roomId;
const otherUserName = route.query.otherUserName || '상대방';
const otherUserId = route.query.otherUserId
const chat = useChatStore();
const { status, lastSeen } = useProfileStatus(otherUserId)
const { timeAgo } = useTimeAgo(lastSeen);
const text = ref('');
const chatContainer = ref(null);
const { messages, subscribeToChatRoom } = useChat(auth.user.uid);
const otherUserLastReadMessageId = ref(null);
const submitMessage = async () => {
if (!text.value.trim()) return;
await chat.sendMessage(chatRoomId, auth.user.uid, otherUserId, text.value);
text.value = '';
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
});
};
const formatTime = (timestamp) => {
if (!timestamp) return '';
const date = timestamp;
return format(date, 'HH:mm'); // 24시간 형식 예: 14:05
};
onMounted(async () => {
subscribeToChatRoom(chatRoomId);
// 상대방이 읽은 마지막 메시지ID 가져오기
otherUserLastReadMessageId.value = await auth.getOtherUserLastReadMessageId(otherUserId);
// 나의 프로필에 마지막 읽은 메시지 업데이트
if (messages.value.length > 0) {
const lastMessage = messages.value[messages.value.length - 1];
await auth.updateLastReadMessage(lastMessage.id);
}
// 내가 이 채팅방에 들어오면 안 읽은 메시지를 읽음 처리
watch(messages, async (newMessages) => {
for (const msg of newMessages) {
if (msg.senderId !== auth.user.uid && !msg.readAt) {
const msgRef = doc(db, 'chatRooms', chatRoomId, 'messages', msg.id);
await updateDoc(msgRef, {
readAt: Date.now(),
});
}
}
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
});
});
});
</script
authStore
// src/stores/authStore.js
import { defineStore } from 'pinia';
import { ref, reactive, computed } from 'vue';
import { useRouter } from 'vue-router'
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
onAuthStateChanged,
sendPasswordResetEmail,
updatePassword,
EmailAuthProvider,
reauthenticateWithCredential
} from 'firebase/auth';
import {
collection, doc, setDoc,
getDoc, updateDoc, getDocs,
query, where, serverTimestamp
} from 'firebase/firestore';
import { auth, db } from '@/firebase';
import { useChatRooms } from '@/composables/useChatRooms';
export const useAuthStore = defineStore('auth', () => {
const router = useRouter();
const { stopListening } = useChatRooms();
const user = ref(null);
const loading = ref(false);
const profile = ref(null);
const profiles = reactive([]);
const isAuthenticated = computed(() => !!user.value);
// 회원가입
const register = async (email, password) => {
try {
loading.value = true;
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
user.value = userCredential.user;
// 계정 만들기 후 Profile 저장
const profileData = {
userId: userCredential.user.uid,
email,
name: '',
status: '',
aboutMe: '',
createdAt: new Date(),
uids: [userCredential.user.uid],
};
await registerProfile(profileData);
router.push('/profile'); // 프로필 수정으로
} catch (error) {
alert('회원가입 실패: ' + error.message);
} finally {
loading.value = false;
}
};
// firestore에 계정 설정 저장
const registerProfile = async (profileData) => {
if (!user) return;
try {
const profileRef = doc(db, 'profiles', user.value.uid);
await setDoc(profileRef, profileData);
await fetchProfiles();
} catch (error) {
alert('Failed to register user: ' + error);
}
};
// 모든 사용자의 프로필 읽기
const fetchProfiles = async () => {
try {
profiles.length = 0;
const profileRef = collection(db, 'profiles');
const querySnapshot = await getDocs(profileRef);
querySnapshot.forEach(doc => profiles.push({ id: doc.id, ...doc.data() }));
fetchProfile(user.value.uid);
} catch (error) {
console.log('fetchProfiles: ' + error.message);
}
};
// profile 정보 읽기
const fetchProfile = async (profileId) => {
try {
const profileRef = doc(db, 'profiles', profileId);
const profileSnap = await getDoc(profileRef);
if (profileSnap.exists()) {
profile.value = { id: profileId, ...profileSnap.data() };
await setOnlineStatus('online');
} else {
console.log(`fetchProfile: ${profileId} 계정 설정 정보가 없습니다.`);
}
} catch (error) {
alert('Failed to fetch user: ' + error.message);
}
};
// 계정 설정 수정
const updateProfile = async (updatedProfile) => {
try {
loading.value = true;
const profileDocRef = doc(db, 'profiles', updatedProfile.id);
await updateDoc(profileDocRef, {
name: updatedProfile.name,
status: updatedProfile.status,
aboutMe: updatedProfile.aboutMe,
// 다른 필드도 필요에 따라 추가 가능
});
profile.value = { ...updatedProfile }; // 로컬 상태도 반영
loading.value = false;
alert('계정 정보를 수정했습니다.');
} catch (error) {
loading.value = false;
alert('계정 정보 수정 중 오류 발생: ' + error.message);
}
}
// 로그인
const login = async (email, password) => {
try {
loading.value = true;
const userCredential = await signInWithEmailAndPassword(auth, email, password);
user.value = userCredential.user;
fetchProfiles();
router.push('/');
} catch (error) {
alert('로그인 실패: ' + error.message);
} finally {
loading.value = false;
}
};
// 로그아웃
const logout = async () => {
try {
if (user.value) {
stopListening(); // 실시간 구독 해제
setOnlineStatus('offline');
await signOut(auth);
router.push('/');
user.value = null;
}
} catch (error) {
alert('로그아웃 실패: ' + error.message);
}
};
// 로그인 상태 유지
const initializeAuth = () => {
onAuthStateChanged(auth, async (currentUser) => {
user.value = currentUser;
if (currentUser) {
fetchProfiles();
// 브라우저 닫기/새로고침 시 offline 처리
window.addEventListener('beforeunload', async () => {
await setOnlineStatus('offline')
})
}
});
};
// 비밀번호 재설정
const resetPassword = async (email) => {
try {
await sendPasswordResetEmail(auth, email);
alert('비밀번호 재설정 이메일을 보냈습니다.');
} catch (error) {
alert('재설정 실패: ' + error.message);
}
};
// 비밀번호 변경
const changePassword = async (currentPassword, newPassword) => {
try {
const credential = EmailAuthProvider.credential(auth.currentUser.email, currentPassword);
await reauthenticateWithCredential(auth.currentUser, credential);
await updatePassword(auth.currentUser, newPassword);
alert('비밀번호가 변경되었습니다.');
} catch (error) {
alert('비밀번호 변경 실패: ' + error.message);
}
};
const signInWithGoogle = async () => {
try {
const provider = new GoogleAuthProvider();
const result = await signInWithPopup(auth, provider);
user.value = result.user;
console.log("Google 로그인 성공:", user.value);
} catch (error) {
console.error("Google 로그인 실패:", error.message);
throw error;
}
};
const linkOAuthToProfile = async (oauthUid) => {
if (!profile.value || !profile.value.id) {
console.warn('프로필이 설정되지 않았습니다.');
return;
}
const profileRef = doc(db, 'profiles', profile.value.id);
// uids 배열에 해당 uid가 없으면 추가
if (!profile.value.uids.includes(oauthUid)) {
try {
await updateDoc(profileRef, {
uids: arrayUnion(oauthUid),
});
profile.value.uids.push(oauthUid); // 로컬에도 반영
console.log('OAuth UID가 uids 배열에 추가되었습니다.');
} catch (error) {
console.error('UID 추가 실패:', error);
}
} else {
console.log('이미 존재하는 UID입니다.');
}
};
const getUidByEmail = async (email) => {
const profileRef = collection(db, 'profiles');
const q = query(profileRef, where('email', '==', email));
const snapshot = await getDocs(q);
if (snapshot.empty) return null;
// 첫 번째 사용자 가져오기
const profile = snapshot.docs[0].data();
const uids = profile.uids;
// 첫 번째 uid 반환 (또는 원한다면 모든 uid를 고려할 수도 있음)
return uids?.[0] || null;
}
// ✅ 온라인 상태 업데이트 함수
const setOnlineStatus = async (status) => {
if (!user.value) return;
const profileRef = doc(db, 'profiles', profile.value.id)
await updateDoc(profileRef, {
status,
lastSeen: serverTimestamp(),
});
// 로드된 프로필 수정
profile.value.status = status;
}
const updateLastReadMessage = async (messageId) => {
if (!user.value) return;
const profileRef = doc(db, 'profiles', profile.value.id);
await updateDoc(profileRef, { lastReadMessageId: messageId });
};
const getOtherUserLastReadMessageId = async (otherUserId) => {
const profileDoc = await getDoc(doc(db, 'profiles', otherUserId));
if (profileDoc.exists()) {
return profileDoc.data().lastReadMessageId || null;
}
return null;
};
return {
user,
isAuthenticated,
loading,
profile,
profiles,
register,
updateProfile,
fetchProfile,
login,
logout,
initializeAuth,
resetPassword,
changePassword,
signInWithGoogle,
linkOAuthToProfile,
getUidByEmail,
setOnlineStatus,
updateLastReadMessage,
getOtherUserLastReadMessageId
};
});
'Vue 3 + Firebase 기반 실시간 채팅 앱 개발' 카테고리의 다른 글
16. Vue 3 + Firebase 기반 채팅 앱 개발 - 읽지 않은 메시지 수 배지 표시 (0) | 2025.05.16 |
---|---|
14. Vue 3 + Firebase 기반 실시간 채팅 앱 개발 - 메시지 읽은 시간 표시 (0) | 2025.05.13 |
13. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 채팅 상대의 온라인 상태 (0) | 2025.05.12 |
12. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 채팅룸 (0) | 2025.05.11 |
11. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 실시간 채팅 (0) | 2025.05.09 |