React Native 16 - 채팅앱
Post

React Native 16 - 채팅앱

결과 화면

ios는 실물 기기로, android는 에뮬레이터로 녹화하였습니다.

회원가입 및 로그인

실시간 채팅 및 push알림

  • background, quit, foreground상태에 따른 push 알림

  • 이미지와 음성메시지 보내고 확인. 확인시 1 사라짐

ios

android

사진 자세히보기, 로그아웃

정리

구현기능

  • 텍스트,이미지 및 음성 메시지를 전송할 수 있는 1:1 채팅앱
  • 기능

    • 회원가입,로그인
    • 프로필 이미지 등록
    • 사용자 리스트
    • 채팅방
    • 텍스트, 이미지, 오디오 메시지 전송
    • 메시지 읽음 표시
    • 푸시 알림
  • 목표

    • Typescript 기반 React Naitve 프로젝트를 CLI를 이용해서 초기화
    • 멀티미디어 다루기 (이미지, 오디오 녹음 및 재생)
    • Firebase를 이용하여 serverless 환경에서 앱 개발 ( Authentication, Firestore, Storage, Cloud Functions)
    • Firebase Cloud Messaging을 이용한 푸시 노티피케이션 전송

firebase

  • react native firebase 설정
  • rnfirebase
  • npm install --save @react-native-firebase/app @react-native-firebase/auth @react-native-firebase/firestore

react-navigation 패키지 설치

  • npm install --save @react-navigation/native-stack @react-navigation/native react-native-screens react-native-safe-area-context

회원가입 페이지 구현

  • 형식 확인하기 . validator 사용
    • npm install validator
    • npm install @types/validator –save
      1
      2
      3
      
      if (!validator.isEmail(email)) {
      return " 올바른 이메일이 아닙니다.";
      }
      
  • SignupScreen
    • name,email,password 확인 및 경고문 ```

    <TextInput value={password} style={styles.input} secureTextEntry //비밀번호 입력 시 가려주는역할 onChangeText={onChangePasswordText} /> ```

  • firestore 설정

    • authentication, firestore database 설정

채팅방

  • npm i lodash
  • npm i @types/lodash --save
  • await firestore().collection(Collections.CHATS)
    • .add는 문서 상관없이 추가
    • .doc는 문서명 따로 설정 가능
  • useChat hook 작성

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    
    import { useCallback, useEffect, useState } from 'react';
    import firestore from '@react-native-firebase/firestore';
    import _ from 'lodash';
    
    import { Chat, Collections, User } from '../types';
    
    // userIds를 받아서 규칙에 따라 lodash를 사용하여 정렬해주는 함수
    const getChatKey = (userIds: string[]) => {
      // userId값을 오름차순으로 정렬하는 것
      return _.orderBy(userIds, userId => userId, 'asc');
    };
    
    // 사용자가 포함된 채팅방이 있다면 불러오고, 없다면 새로생성
    const useChat = (userIds: string[]) => {
      const [chat, setChat] = useState<Chat | null>(null);
      const [loadingChat, setLoadingChat] = useState(false);
    
      const loadChat = useCallback(async () => {
        try {
          setLoadingChat(true);
          // userIds랑 우리가 준 userIds가 같은 채팅방이 생성되어있는지 체크
          const chatSnapshot = await firestore()
            .collection(Collections.CHATS)
            .where('userIds', '==', getChatKey(userIds))
            .get();
    
          if (chatSnapshot.docs.length > 0) {
            const doc = chatSnapshot.docs[0];
            setChat({
              id: doc.id,
              userIds: doc.data().userIds as string[],
              users: doc.data().users as User[],
            });
            return;
          }
    
          // userId에 userIds가 포함된 데이터만 가져오게됨.
          const usersSnapshot = await firestore()
            .collection(Collections.USERS)
            .where('userId', 'in', userIds)
            .get();
          const users = usersSnapshot.docs.map(doc => doc.data() as User);
          const data = {
            userIds: getChatKey(userIds),
            users,
          };
    
          const doc = await firestore().collection(Collections.CHATS).add(data);
          setChat({
            id: doc.id,
            ...data,
          });
        } finally {
          setLoadingChat(false);
        }
      }, [userIds]);
    
      useEffect(() => {
        loadChat();
      }, [loadChat]);
    
      return {
        chat,
        loadingChat,
      };
    };
    
    export default useChat;
    

메시지 보내기 및 불러오기 기능

  • firestore의 subcollection을 이용해서 메시지 전송 기능 구현
  • subcollection

    • 메시지 chats 컬렉션의 유저목록과 분리하여야함
    • chat다큐먼트 collection안에 subcollection을 만들어서 사용 가능
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    // 서브컬렉션 열어주어 저장하기
    const doc = await firestore()
      .collection(Collections.CHATS)
      .doc(chat.id)
      .collection(Collections.MESSAGES)
      .add(data);
    
    // 이전 메시지 + 새로운 메시지 업데이트
    setMessages((prevMessages) =>
      [
        {
          id: doc.id,
          ...data,
        },
      ].concat(prevMessages)
    );
    
  • 메시지 불러오기

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    const loadMessages = useCallback(async (chatId: string) => {
      try {
        setLoadingMessages(true);
        const messagesSnapshot = await firestore()
          .collection(Collections.CHATS)
          .doc(chatId)
          .collection(Collections.MESSAGES)
          .orderBy("createdAt", "desc")
          .get();
    
        const ms = messagesSnapshot.docs.map<Message>((doc) => {
          const data = doc.data();
          return {
            id: doc.id,
            user: data.user,
            text: data.text,
            createdAt: data.createdAt.toDate(), //db의 date를 date타입으로 바꿔주기 위해 toDate()사용
          };
        });
        setMessages(ms);
      } finally {
        setLoadingMessages(false);
      }
    }, []);
    
    useEffect(() => {
      if (chat?.id != null) {
        loadMessages(chat.id);
      }
    }, [chat?.id, loadMessages]);
    

채팅 메시지 구현

  • inverted FlatList
    • inverted 옵션을 주면 스크롤을 윗방향으로 할 수 있음
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      
      <FlatList
                inverted
                style={styles.messageList}
                data={messages}
                renderItem={({ item: message }) => {
                  return (
                    <View>
                      <Text>{message.user.name}</Text>
                      <Text>{message.text}</Text>
                      <Text>{message.createdAt.toISOString()}</Text>
                    </View>
                  );
                }}
              />
      

시간 출력

  • npm i moment
  • 포맷 형식 작성해주면됨
1
<Text style={styles.timeText}>{moment(createdAt).format("HH:mm")}</Text>

실시간 새로운 메시지 받기 구현

  • firestore 사이트 참고
  • onSnapshot() 메소드 사용하기
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    firestore()
      .collection(Collections.CHATS)
      .doc(chat.id)
      .collection(Collections.MESSAGES)
      .orderBy("createdAt", "desc")
      .onSnapshot((snapshot) => {
        // 메시지가 추가될때만 내용변경
        const newMessages = snapshot
          .docChanges()
          .filter(({ type }) => type === "added")
          .map((docChange) => {
            const { doc } = docChange;
            const docData = doc.data();
            const newMessage: Message = {
              id: doc.id,
              text: docData.text,
              user: docData.user,
              createdAt: docData.createdAt.toDate(),
            };
            return newMessage;
          });
        addNewMessage(newMessages);
      });
    
  • lodash → uniqBy

    • 새로운 메시지가 중복되더라도 같은메시지가 있으면 lodash uniqBy로 제거

      1
      2
      3
      4
      5
      
      const addNewMessage = useCallback((newMessages: Message[]) => {
        setMessages((prevMessages) => {
          return _.uniqBy(newMessages.concat(prevMessages), (m) => m.id);
        });
      }, []);
      

프로필 이미지 등록

  • 이미지 크롭 라이브러리 이용
    • react-native-image-crop-picker
    • 위 사이트를 참고하여 ios와 android 셋팅하기
      1
      2
      3
      4
      5
      6
      
      const onPressProfile = useCallback(async () => {
      const image = await ImageCropPicker.openPicker({
        cropping: true,
        cropperCircleOverlay: true, //원모양으로 사진이 잘림
      });
      }, []);
      
  • firebase storage에 이미지 업로드
    • firebase storage 시작 및 패키지 설치
    • npm i @react-native-firebase/storage
    • firebase 관련 패키지 버전을 똑같이 맞춰주어야함
      1
      2
      3
      4
      
      	"@react-native-firebase/app": "^17.4.2",
          "@react-native-firebase/auth": "^17.4.2",
          "@react-native-firebase/firestore": "^17.4.2",
          "@react-native-firebase/storage": "^17.4.2",
      
  • 프로필 보여주기 및 확대기능
    • react-native-image-viewing

메시지 읽음 표시 구현

  • firestore server timestamp이용
    • 마지막으로 채팅방에 들어온 시간 chat db에 기록
    • 시간을 비교
      • 메시지 전송시간 > 채팅방에 들어온 시간 : 안읽음
      • 메시지 전송시간 ≤ 채팅방에 들어온 시간 : 읽음
  • firestore 실시간 업데이트를 이용해서 메시지 읽은 사용자 정보 실시간으로 가져오기
  • 메시지를 읽지 않은 사람 수 표시

음성메시지 전송

react-native-audio-recorder-player

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import React, { useCallback, useRef, useState } from "react";
import { PermissionsAndroid, Platform, StyleSheet } from "react-native";
import AudioRecorderPlayer, {
  AVEncodingOption,
  AudioEncoderAndroidType,
} from "react-native-audio-recorder-player";

const MicButton = () => {
  const [recording, setRecording] = useState(false);
  const audioRecorderPlayerRef = useRef(new AudioRecorderPlayer());
  const startRecord = useCallback(async () => {
    // android permission
    if (Platform.OS === "android") {
      const grants = await PermissionsAndroid.requestMultiple([
        PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
        PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
        PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
      ]);

      const granted =
        grants[PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE] ===
          PermissionsAndroid.RESULTS.GRANTED &&
        grants[PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE] ===
          PermissionsAndroid.RESULTS.GRANTED &&
        grants[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] ===
          PermissionsAndroid.RESULTS.GRANTED;

      if (!granted) {
        return;
      }
    }
    //녹음실행
    await audioRecorderPlayerRef.current.startRecorder(undefined, {
      //   android와 ios 오디오타입 통일
      AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
      AVFormatIDKeyIOS: AVEncodingOption.aac,
    });
    audioRecorderPlayerRef.current.addRecordBackListener(() => {});
    setRecording(true);
  }, []);

  const stopRecord = useCallback(async () => {
    const uri = await audioRecorderPlayerRef.current.stopRecorder();
    audioRecorderPlayerRef.current.removeRecordBackListener();
    setRecording(false);
  }, []);
};

export default MicButton;
  • 마이크 권한 요청
  • 음성 녹음 기능 구현
    • android는 npm run android로 실행시 녹음이 안되므로 android studio에서 열어야함.
    • open으로 프로젝트의 android폴더를 open → (tools)device manager → 재생
    • emulator의 점세개(extended controls) 클릭 → microphone → virtual microphone uses host audio input 활성화
  • firebase storage에 음성 업로드하기
  • 음성 메시지 전송하기
  • 음성 메시지 재생 및 멈춤 기능 구현
  • 남은 재생시간 표시

react-native-firebase-@messaging

  • ios
    • 실제 디바이스, apple 개발자 등록이 필요함
  • android
    • android 에뮬레이터나 실제 디바이
  • FCM(Firebase Cloud Messaging)
    • 비용없이 Server-to-Device, Device-to-Device로 알림을 전송할 수 있는 플랫폼
  • 알림 수신 시 앱 상태
    • Foreground : 앱이 실행되고 현재 보여지는 상태
    • Background : 앱은 실행되었으나 최소화 되어있는 상태
    • Quit : 앱이 완전히 종료된 상태
  • 메시지 타입에 따른 알림표시
    • Foreground : 알림표시 불가능
    • Background: Notification, Notification + Data 알림표시가능
    • Quit : Notification, Notification + Data 알림표시가능
  • 핸드폰에서 변동사항이 firebase로 들어감 → cloud functions에서 다른 사용자에게 전송
  • https://rnfirebase.io/messaging/usage

    • yarn add @react-native-firebase/messaging
    • 다른 firestore 라이브러리들과 버전 똑같이 맞추어주기 → yarn
    • ios > rm -rf Pods 로 지워준 후, pod install
  • 알림권한 요청 (ios, android 13+ : 13버전이전은 이미권한이 되어있음)
  • Push Notification 전송을 위해 fcm토큰 등록하기

메시지 도착 실시간 알림

백엔드

  • firebase cloud functions 프로젝트 초기화 https://firebase.google.com/docs/functions?hl=ko

  • https://firebase.google.com/docs/functions/firestore-events?hl=ko

    cloud functions을 사용하기 위해서는 firebase무료(spark)요금제로는 사용 불가능 → 사용할수록 요금이 부과되는 Blaze요금제를 사용해야 함

    • firestore trigger이용시 문서가 업데이트,생성,삭제 될때 서버에서 실행될 수 있는 코드 구현가능
  • 사용 npm install -g firebase-tools https://firebase.google.com/docs/functions/get-started?hl=ko 링크 참고하여 프로젝트 setting
    • login → functions기능선택 → 기존 프로젝트 선택 → 사용언어선택
    • 패키지 설치나 실행시 ChatAppServer > functions 폴더 내에서 작업
  • npm i eslint-config-prettier -s
    • eslint와 prettier가 충돌할 때 (4칸띄울지 2칸띄울지 등) 한가지를 선택하는 패키지
    • .eslint.rc파일에서 다음내용추가
      1
      2
      3
      
      extends: [
          "prettier",
        ],
      
  • npm i firebase-admin

    • function에서 문서를 읽기위해 사용
  • 서버에서 firestore 실시간 업데이트 수신하기
  • 새로운 메시지 생성 시 Push Notification 전송
    • npm run deploy
      • 서버에 배포가 됨. 약 3분소요
      • firebase → 프로젝트 → functions → 함수의 점3개 → 로그보기 에서 잘보내지는지 확인가능

  • 메시지 수신 시 Notification 보여주기
  • Notification 터치하면 채팅 스크린으로 이동
    • background에 있는경우와 quit상태에 있는경우 다르게 처리해주어야함.
  • foreground에서 알림띄우기
    • messaging의 onMessage사용
    • react-native-toast-message 패키지 사용하여 앱 내에서 알림띄우기
  • ios APNs(Apple Push Notifications service)설정
    • ios Messaging setup
    • https://rnfirebase.io/messaging/usage/ios-setup
      • capabilities 추가
      • APNs 키 등록
        • developer.apple.com
        • 프로그램 리소스 → 키 등록 → 다운로드(재다운로드불가능)
        • firebase 프로젝트 설정 → 클라우드 메시징 → APN인증 키 등록 (키id, 팀id-이름옆 코드), 키 파일 등록