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

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

그랜파 개발자 2025. 5. 13. 20:24

메시지 읽은 시간 표시

내 메시지가 상대방에게 읽혔을 때 "읽은 시간" 표시하는 기능입니다.
읽은 시간을 표시하면 좋은 이유는 상대방이 메시지를 확인했는지 명확히 알 수 있기 때문입니다.

읽은 시간 표시를 통해 상대방이 내 메시지를 봤는지 확인 가능하고
“읽음” 표시만으로는 부족할 때 구체적인 시간이 신뢰감을 줍니다.(예: 읽음 · 5분 전)
상대가 응답이 없을 때도, '아, 아직 읽지 않았구나', '읽긴 했지만 바쁜가 보네' 등 합리적인 추측 가능합니다.

그러므로 읽음 시간은 작지만 신뢰감과 사용자 만족도를 높이는 핵심 요소 중 하나입니다.

메시지 읽은 시간 표시

Firestore의 chatRooms 컬렉션의 messages 서브 컬렉션 문서의 readAt 필드를 사용합니다.

메시지를 보낼 때 readAt은 null 로 저장이 되고
채팅룸에 들어갈 때 readAt이 null인 안 읽은 메시지 읽음 처리합니다.
읽음 처리하는 방법은 1:1 채팅이므로 채팅룸의 userIds 배열에서 serderId가 내가 아니고,
readAt이 null인 경우 readAt에 현재 시간 설정하는 것입니다.

채팅룸에서 메시지 목록을 보여줄 때 readAt을 사용하여 읽은 시간을 표시합니다.

chatStore

  const sendMessage = async (chatRoomId, senderId, receiverId, text) => {
    const messagesRef = collection(db, 'chatRooms', chatRoomId, 'messages');

    // 메시지 객체
    const messageData = {
      text,
      senderId,
      createdAt: serverTimestamp(),
      readAt: null
    };

    await addDoc(messagesRef, messageData);
  };

ChatRoom.vue의 script

  • 메시지 읽음 처리
onMounted(async () => {
  subscribeToChatRoom(chatRoomId);

  // 내가 이 채팅방에 들어오면 안 읽은 메시지를 읽음 처리
  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;
      }
    });
  });
});

ChatRoom.vue의 template

  • 메시지 읽음 표시
  • 날짜 포맷
const formatTime = (timestamp) => {
  if (!timestamp) return '';
  const date = timestamp;
  return format(date, 'HH:mm'); // 24시간 형식 예: 14:05
};

date-fns

읽은 시간 표시 형식을 위하여 date-fns 설치해야 합니다.
date-fns는 JavaScript에서 날짜와 시간을 다룰 수 있도록 도와주는 경량의 날짜 유틸리티 라이브러리입니다.

npm install date-fns

 

사용예

import { format } from 'date-fns';

const now = new Date();
console.log(format(now, 'yyyy-MM-dd HH:mm')); // 2025-05-13 14:30

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>
          </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 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);

  // 내가 이 채팅방에 들어오면 안 읽은 메시지를 읽음 처리
  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>