Vue 3 + Firebase 기반 실시간 채팅 앱 개발

15. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 메시지 읽은 시간 표시

그랜파 개발자 2025. 5. 15. 20:25

마지막 읽은 메시지 표시

마지막 읽은 메시지 표시는 상대가 어디까지 읽었는지 시각적으로 표시하는 기능입니다.
채팅 앱에서 마지막으로 읽은 메시지에 "읽음" 표시(예: ✅ 또는 "읽음 · 3분 전")를 해주는 기능은 사용자 경험과 커뮤니케이션 명확성을 크게 향상시킵니다.

마지막 읽은 메시지 표시 구현

마지막 읽은 메시지를 나타내기 위해서는 다음과 같이 구현할 수 있습니다.

  1. profiles 컬렉션 문서의 lastReadMessageId 필드를 사용합니다.
  2. 채팅방 입장할 때, 내 프로필에 lastReadMessageId 업데이트합니다.
  3. 채팅방 입장 시, 상대방이 읽은 마지막 메시지ID를 가져와서
  4. 화면 렌더링 시, 상대방이 읽은 마지막 메시지 아래에 "여기까지 읽음" 표시합니다.

마지막 읽은 메시지 표시 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
  };
});