개발로그

React와 Firebase를 이용한 채팅 앱 만들기 - (2) 본문

군 장병 AI·SW 역량강화

React와 Firebase를 이용한 채팅 앱 만들기 - (2)

clohoon 2024. 2. 1. 16:43

5. DM 채팅

 

Side Panel Direct Message UI

<div>
    <span style={{ display: 'flex', alignItems: 'center' }}>
        <FaRegSmile style={{ marginRight: 3 }}/> DIRECT MESSAGES(1)
    </span>

    <ul style={{ listStyleType: 'none', padding: 0 }}>
        {this.renderDirectMessages()}
    </ul>
</div>

 

 

user 목록 가져오기

접속 중인 유저 파란색으로 표시

addUsersListeners = (currentUserId) => {
    const {usersRef} = this.state;
    let usersArray = [];
    usersRef.on("child_added", DataSnapshot => {
        if(currentUserId !== DataSnapshot.key) {
            let user = DataSnapshot.val()
            user["uid"] = DataSnapshot.key
            user["status"] = "offline";
            usersArray.push(user)
            this.setState({users: usersArray})
        }
    })
}

 

 

해당 유저와 대화 할 방 아이디 생성

나의 아이디와 상대방의 아이디를 이용한다.

상대가 방을 만들든 내가 만들든 아이디가 동일해야 하므로 userId < currentUserId 로직 사용

getChatRoomId = (userId) => {
    const currentUserId = this.props.user.uid

    return userId > currentUserId
        ? `${userID}/${currentUserId}`
        : `${currentUserId}/${userId}`
}

 

 

currentChatRoom을 클릭한 방으로 변경

changeChatRoom = (user) => {
    const chatRoomId = this.getChatRoomId(user.uid);
    const chatRoomData  = {
        id: chatRoomId,
        name: user.name
    }
    this.props.dispatch(setCurrentChatRoom(chatRoomData))
}

 

 

클릭한 방 private 방 설정

this.props.dispatch(setPrivateChatRoom(true));

 

 

클릭한 방 Active 방으로 표시

this.setActiveChatRoom(user.uid);

 

 

private, public icons

{isPrivateChatRoom ? 
    <FaLock style={{marginBottom: '10px'}} /> 
    : 
    <FaLockOpen style={{marginBottom: '10px'}} />
}

 

 

파일 올릴 때 prviate, public 구분

const filePath = '${getPath()}/${file.name}';

 


 

6. 알림, 즐겨찾기 방

 

Notification

알림 기능 : 여러 채팅 방 중 내가 현재 있는 채팅방이 아닌 곳에서 다른 사람이 채팅하면 하나의 쪽지당 하나의 count가 올라가는 알림

https://react-bootstrap.netlify.app/docs/components/badge/

 

 

채팅룸 정보를 가져올 때 알림 정보 리스너도 추가

// chatRooms.js AddChatRoomsListeners 메소드
this.addNotificationListener(DataSnapshot.key);

 

 

ChatRoom Collection에서 해당 ChatRoom Id에 해당하는 정보를 가져오기

목표는 방 하나 하나에 맞는 알림 정보를 notifications state에 넣어주는 것

이미 notifications state 안에 알림 정보가 들어있는 채팅방과 그렇지 않은 채팅방을 나누어준다.

if(index === -1) {
    notifications.push({
        id: chatRoomId,
        total: DataSnapshot.numChildren(),
        lastKnownTotal: DataSnapshot.numChildren(),
        count: 0
    })
}

id : 채팅방 아이디

total : 해당 채팅방 전체 메시지 개수

lastKnownTotal : 이전에 확인한 전체 메시지 개수

count : 알림으로 사용될 숫자

DataSnapshot.numChildren() : 전체 children 개수 = 전체 메시지 개수

else {
    // 상대방이 메세지를 보낸 채팅방에 있지 않을 때
    if(chatRoomId !== currentChatRoomId) {
        lastTotal = notifications[index].lastKnownTotal
        if(DataSnapshot.numChildren() - lastTotal > 0) {
            notifications[index].count = DataSnapshot.numChildren() - lastTotal;
        }
    }

    notifications[index].total = DataSnapshot.numChildren();
}

 

 

notification 클릭해서 없애주기

this.clearNotifications();

 

 

messageRef 정리해주기

componentWillUnmount() {
    this.state.chatRoomsRef.off();

    this.state.chatRooms.forEach(chatRoom => {
        this.state.messagseRef.child(chatRoom.id).off();
    });
}

 

 

즐겨찾기 방

 

Favorite 클릭 버튼 UI

Favorite 버튼 클릭 시 is Not Favorited면 user collection에서 추가, 아니면 제거

const handleFavorite = () => {
    if(isFavorited) {
        usersRef
            .child(`${user.uid}/favorited`)
            .child(chatRoom.id)
            .remove(err => {
                if(err !== null) {
                    console.error(err);
                }
            })
        setIsFavorited(prev => !prev);
    } else {
        usersRef
            .child(`${user.uid}/favorited`).update({
                [chatRoom.id]: {
                    name: chatRoom.name,
                    description: chatRoom.description,
                    createdBy: {
                        name: chatRoom.createdBy.name,
                        image: chatRoom.createdBy.image
                    }
                }
            })
        setIsFavorited(prev => !prev);
    }
}

DB : 클릭한 유저 id -> favorited -> 채팅방 id -> createdBy(image, name), description, name

 

 

새로고침해도 Favorited가 남아있게 하기

once() : it triggers once and then does not trigger again

const addFavoriteListener = (chatRoomId, userId) => {
    usersRef
        .child(userId)
        .child("favorited")
        .once("value")
        .then(data => {
            if(data.val() !== null) {
                const chatRoomIds = Object.keys(data.val());
                const isAlreadyFavorited = chatRoomIds.includes(chatRoomId)
                setIsFavorited(isAlreadyFavorited)
            }
        })
}

 

 

Favorite 리스트 리스너 추가하기

addListeners = (userId) => {
    const {usersRef} = this.state;

    usersRef
        .child(userId)
        .child("favorited")
        .on("child_added", DataSnapshot => {
            const favoritedChatRoom = { id: DataSnapshot.key, ...DataSnapshot.val() }
            this.setState({
                favoritedChatRooms: [...this.state.favoritedChatRooms, favoritedChatRoom]
            })
        })

    usersRef
        .child(userId)
        .child("favorited")
        .on("child_removed", DataSnapshot => {
            const chatRoomToRemove = { id: DataSnapshot.key, ...DataSnapshot.val() }
            const filteredChatRooms = this.state.favoritedChatRooms.filter(chatRoom => {
                return chatRoom.id !== chatRoomToRemove.id;
            })
            this.setState({favoritedChatRooms: filteredChatRooms})
        })
}

 

 

Favorited를 위한 리스너 제거하기

componentWillUnmount() {
    if(this.props.user) {
        this.removeListener(this.props.user.uid);
    }
}

removeListener = (userId) => {
    this.state.usersRef.child(`${userId}/favorited`).off();
}

 


 

7. 채팅방 정보

 

각 사람당 글 쓴 데이터 가져오기

객체 userPosts에는 key로 유저 이름이, value로 count, image가 들어간다.

userPostsCount = (messages) => {
    let userPosts = messages.reduce((acc, message) => {
        if(message.user.name in acc) {
            acc[message.user.name].count += 1;
        } else {
            acc[message.user.name] = {
                image: message.user.image,
                count: 1
            }
        }
        return acc;
    }, {})
    this.props.dispatch(setUserPosts(userPosts))
}

 

 

userPosts 데이터 count 큰 순서대로 보여주기

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/entries

const renderUserPosts = (userPosts) =>
    Object.entries(userPosts)
        .sort((a,b) => b[1].count - a[1].count)
        .map(([key, val], i) => (
            <Media key={i}>
                <img
                    style={{borderRadius: 25}}
                    width={48}
                    height={48}
                    className="mr-3"
                    src={val.image}
                    alt={val.name}
                />
                <Media.Body>
                    <h6>{key}</h6>
                    <p>
                        {val.count} 개
                    </p>
                </Media.Body>
            </Media>
        ))

 


 

8. Typing

 

Typing 시작 시 Typing 정보 데이터베이스에 저장

const handleKeyDown = () => {
    if(event.ctrlKey && event.keyCode === 13) {
        handleSubmit();
    }

    if(content) {
        typingRef.child(chatRoom.id).child(user.uid).set(user.displayName)
    } else {
        typingRef.child(chatRoom.id).child(user.uid).remove();
    }
}

 

 

채팅 보내면 typing 정보 데이터베이스에서 지우기

typingRef.child(chatRoom.id).child(user.uid).remove();

 

 

리스너를 이용해 데이터베이스에서 Typing 정보를 가져오기

가져올 때 나의 Typing은 제외

addTypingListeners = (chatRoomId) => {
    let typingUsers= [];
    this.state.typingRef.child(chatRoomId).on("child_added",
        DataSnapshot => {
            if(DataSnapshot.key !== this.props.user.uid) {
                typingUsers = typingUsers.concat({
                    id: DataSnapshot.key,
                    name: DataSnapshot.val()
                });
                this.setState({ typingUsers });
            }
        })
}

DataSnapshot.key : 타이핑하는 유저, this.props.user.uid : 현재 로그인한 유저

 

 

데이터베이스에서 Typing 정보 제거되면 State에서도 지우기

this.state.typingRef.child(chatRoomId).on("child_removed",
    DataSnapshot => {
        const index = typingUsers.findIndex(user => user.id === DataSnapshot.key);
        if(index !== -1) {
            typingUsers = typingUsers.filter(user => user.id !== DataSnapshot.key);
            this.setState({ typingUsers });
        }
    }
)

 

 

Typing UI 추가하기

renderTypingUsers = (typingUsers) => 
    typingUsers.length > 0 &&
    typingUsers.map(user => (
        <span>{user.name}님이 채칭을 입력하고 있습니다...</span>
    ))

 

 

Typing 리스너 제거하기

addToListenerLists = (id, ref, event) => {
    // 이미 등록된 리스너인지 확인
    const index = this.state.listenerLists.findIndex(listener => {
        return (
            listener.id === id &&
            listener.ref === ref &&
            listener.event === event
        )

        if(index === -1) {
            const newListener = {id, ref, event}
            this.setState({
                listenerLists: this.state.listenerLists.concat(newListener)
            })
        }
    })
}
componentWillUnmount() {
    this.state.messagesRef.off();
    this.removeListeners(this.state.listenerLists);
}

 


 

9. Auto Scroll & Skeleton

 

메시지 보낼 때 자동으로 스크롤 내리기

잡은 자리 계속 참조할 수 있게 -> Refs를 생성 React.createRef() -> 생성한 Refs를 ref attribute를 통해 넣어주기

-> 메시지를 보낼 때마다 참조하고 있는 곳으로 스크롤 내려주기

<div ref = {node => (this.messageEndRef = node)}/>
componentDidUpdate() {
    if(this.messageEndRef) {
        this.messageEndRef.scrollIntoView({ behavior : 'smooth' })
    }
}

 

 

메시지 로딩 중 스켈레톤 처리

스켈레톤 10개 만들기 -> 컴포넌트 10개 넣어도 되지만 Array Constructor를 이용한다.

renderMessageSkeleton = (loading) =>
    loading && (
        <>
            {[...Array(10)].map((v,i) => (
                <Skeleton key={i} />
            ))}
        </>
    )

 

 

ctrl + 엔터키로 메시지 보내기

if(event.ctrlKey && event.keyCode === 13) {
	handleSubmit();
}

 


 

10. 데이터베이스 규칙, 스토리지 규칙, 배포

 

Firebase Storage Rule

악성 유저들로부터 파일을 보호하기 위해 사용

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /message {
      allow read : if request.auth != null;
      allow write : request.auth != null &&
      request.resource.contentType.matches('image/.*) &&
      request.resource.size < 10*1024*1024;
    }
    match /user_image {
      match / {userId} {
        allow read : if request.auth != null;
        allow write : request.auth != null &&
        request.auth.uid == userId &&
        request.resource.contentType.matches('image/.*') &&
        request.resource.size < 10*1024*1024;
      }
    }
  }
}

/message 경로 해당 파일들, 인증된 유저 read, write 가능, 이미지 파일만 업로드 가능, 파일 사이즈 10mb 이하 가능

 

 

Firebase Database Rule

{
    "rules" : {
        "chatRooms" : {
            ".read" : "auth != null",
            "$chatRoomId" : {
                ".write" : "auth != null",
                ".validate" : "newData.hasChildren(['id', 'name', 'createdBy', 'description'])",
                "id" : {
                    ".validate" : "newData.val() === $chatRoomId"
                },
                "name" : {
                    ".validate" : "newData.val().length > 0"
                },
                "description" : {
                    ".validate" : "newData.val().length > 0"
                }
            }
        },
        "messages" : {
            ".read" : "auth != null",
            ".write" : "auth != null",
            "content" : {
                ".validate" : "newData.val().length > 0"
            },
            "image" : {
                ".validate" : "newData.val().length > 0"
            },
            "user" : {
                ".validate" : "newData.hasChildren(['id', 'image', 'name'])"
            }
        },
        "presence" : {
            ".read" : "auth != null",
            ".write" : "auth != null"
        },
        "typing" : {
            ".read" : "auth != null",
            ".write" : "auth != null"
        },
        "users" : {
            ".read" : "auth != null",
            "$uid" : {
                "write" : "auth != null && auth.uid === $uid",
                ".validate" : "newData.hasChildren(['name', 'image'])",
                "name" : {
                	".validate" : "newData.val().length > 0"
            	},
            	"image" : {
                	".validate" : "newData.val().length > 0"
            	}
            }
        }
    }
}

 

 

애플리케이션 배포

1. firebase tool 설치 : npm install firebase-tools -g

2. firebase login -> social Oauth 로그인

3. firebase 프로젝트 시작 :  firebase init

4. Database, Storage 선택

5. 배포를 위한 빌드 : npm run build

6. firebase.json

"hosting" : {
	"public" : "./build"
}

 

7. 빌드 : firebase deploy

8. 코드 수정하면 5번부터 다시

 


 

Storage

> message

> user_image

 

데이터베이스

> chatRooms

   > 채팅방 아이디

      > createdBy : image, name

      > description

      > id

      > name

> messages

   > 채팅방 아이디

      > 메세지

         > content / image

         > timestamp

         > user : id, image, name

> presence

> typing

> users

    > 유저 아이디

        > image

        > name