채팅 상대의 온라인 상태
채팅 상대의 온라인 상태 여부를 보여주는 기능을 구현합니다.
이를 위하여 profile 문서의 status, lastSeen 필드를 사용합니다.
로그인을 하면 status는 "online", 로그아웃을 하면 "offline"을 저장합니다.
status가 변경될 때마다 lastSeen에 변경 시간을 저장하여 두면 최근의 로그인, 로그아웃 시간을 확인할 수 있습니다.
온라인 상태 관리
온라인 상태 변경 시점은 다음과 같습니다.
- 앱을 시작할 때 자동 로그인을 하는 경우 'online' 입니다.
- 로그인을 하면 'online'입니다.
- 로그아웃을 하면 'offline'입니다.
온라인 상태는 온라인일 경우 온라인 표시를 하고
오프라인의 경우는 최종 로그 아웃한 후 지난 시간을 표시합니다.
Firestore의 profiles 컬렉션 구조
profiles (Collection)
└── {userId} (계정을 생성하고 돌려 받은 uid)
├── email: string
├── name: string
├── status: string // ex: "online", "offline"
├── aboutMe: string // 자기소개
├── createdAt: Timestamp
├── lastSeen: Timestamp
└── uids: array of string // 소셜 로그인으로 연결된 다른 uid들 (예: Google, Kakao 등)
composable - useProfileStatus.js
사용자의 온라인 상태 여부를 실시간 업데이트 합니다.
// composables/useProfileStatus.js
import { ref, onUnmounted } from 'vue'
import { doc, onSnapshot } from 'firebase/firestore'
import { db } from '@/firebase' // Firestore 인스턴스를 가져오는 코드
export function useProfileStatus(profileId) {
const status = ref('offline');
const lastSeen = ref(null);
const profileRef = doc(db, 'profiles', profileId);
const unsubscribe = onSnapshot(profileRef, (docSnap) => {
if (docSnap.exists()) {
const data = docSnap.data();
status.value = data.status || 'offline';
lastSeen.value = data.lastSeen?.toDate() || null;
}
})
onUnmounted(() => unsubscribe())
return { status, lastSeen }
}
온라인 상태 업데이트
profiles 컬렉션 문서의 status에 online/offline 설정을 하고
상태가 변경된 시간을 lastSeen에 업데이트 합니다.
// ✅ 온라인 상태 업데이트 함수
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;
}
authStore
앱이 시작될 때 자동 로그인 하는 경우, 로그인 하는 경우 status는 online,
로그아웃하는 경우 ‘offline’으로 status를 업데이트 합니다.
로그인하면 fetchProfiles() 함수를 호출하고
이 함수에서 fetchProfile()를 호출하는데 이 함수에서 online으로 업데이트 합니다.
// 로그인
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 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);
}
};
온라인 상태 표시 UI
ChatRoom.vue의 template
composables - useTimeAgo.js
- 경과 시간 계산
// -- src/composables/useTimeAgo.js
import { ref, computed, onMounted, onUnmounted } from 'vue';
export function useTimeAgo(dateRef) {
const now = ref(Date.now());
let intervalId;
onMounted(() => {
intervalId = setInterval(() => {
now.value = Date.now();
}, 60000); // 1분마다 갱신
});
onUnmounted(() => {
clearInterval(intervalId);
});
const timeAgo = computed(() => {
const date = dateRef.value;
if (!date) return '';
const diffSeconds = Math.floor((now.value - date.getTime()) / 1000);
if (diffSeconds < 60) return `${diffSeconds}초 전`;
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}분 전`;
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}시간 전`;
// 24시간(1일) 초과 → 날짜 포맷으로
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}.${month}.${day}`;
});
return { timeAgo };
}
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>
</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 } from 'vue';
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'; // 경로에 맞게!
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;
}
});
};
onMounted(async () => {
subscribeToChatRoom(chatRoomId);
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
});
});
</script>
'Vue 3 + Firebase 기반 실시간 채팅 앱 개발' 카테고리의 다른 글
15. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 메시지 읽은 시간 표시 (0) | 2025.05.15 |
---|---|
14. Vue 3 + Firebase 기반 실시간 채팅 앱 개발 - 메시지 읽은 시간 표시 (0) | 2025.05.13 |
12. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 채팅룸 (0) | 2025.05.11 |
11. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 실시간 채팅 (0) | 2025.05.09 |
10. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 새채팅 (0) | 2025.05.08 |