Instagram Clone : React Native - part 6 [ DIRECT MESSAGES]

정관우·2021년 10월 14일
2
post-thumbnail

Message Navigator

MessageNav 구조

DM 기능을 추가하기 위해, LoggedInNav 스택 네비게이터 안에 또 다른 스택 네비게이터를 추가한다. MessagesNav 안에는 Rooms와 Room 두 개의 스크린이 있다. Rooms에서 채팅방을 선택하면, 해당 id의 채팅방으로 이동한다.

Rooms Screen

채팅방 목록을 볼 수 있는 스크린을 만든다.

useQuery

seeRooms 쿼리를 요청하여, 로그인 유저가 있는 채팅방들을 모두 불러온다. 백엔드에서 이미 로그인 유저의 id를 갖고 있기 때문에, id를 보내줄 필요없다.

Frontend - FEED : Feed Query 참고

FlatList

쿼리로 불러온 채팅방 목록들을 FlatList로 화면에 그린다.

React Native - FEED : FlatList 참고

유저 구분하기

seeRooms 쿼리의 리턴 값으로는 어떤 유저가 로그인 유저인지 알 수 없다. 백엔드에서 구현하는 것이 베스트이지만, 프론트에서도 어렵지 않게 가능하다.

useMe Hook

로그인 유저의 정보가 필요하기 때문에, 어김없이 useMe Hook을 사용한다. 고유한 값인 username으로 유저를 판별한다. find 메서드를 이용하여, 대화 상대의 정보가 담겨있는 배열을 가져올 수 있다.

// RoomItem.tsx
function RoomItem({ users, unreadTotal, id }: seeRooms_seeRooms) {
  const { data: meData } = useMe();
  const talkingTo = users?.find(
    (user) => user?.username !== meData?.me?.username
  );
   ...
  return (
    ...
        <Avatar source={{ uri: talkingTo?.avatar! }} />
        <Data>
          <Username>{talkingTo?.username}</Username>
          ...
        </Data>
      ...
  );
}
...

대화 상대의 객체를 Room으로 넘겨주어 채팅방의 헤더에 대화 상대의 이름이 보이게 한다. Room의 id를 넘겨주어 Room이 마운트 될 때 채팅을 불러올 수 있다.

// RoomItem.tsx
function RoomItem({ users, unreadTotal, id }: seeRooms_seeRooms) {
  ...
  const navigation = useNavigation();
  const goToRoom = () =>
    navigation.navigate("Room", {
      id,
      talkingTo,
    });
  return (
    <RoomContainer onPress={goToRoom}>
      ...
    </RoomContainer>
  );
}

// Room.tsx
function Room({ route, navigation } : RoomProps) {
  const { data } = useQuery(ROOM_QUERY, {
    variables: {
      id: route?.params?.id,
    },
  });
  useEffect(() => {
    navigation.setOptions({
      title: `${route?.params?.talkingTo?.username}`,
    });
  }, []);

Room Screen

useQuery

마찬가지로 seeRoom 쿼리를 요청하여, 채팅방에서 필요한 데이터를 모두 불러온다.

Frontend - FEED : Feed Query 참고

FlatList

쿼리로 불러온 채팅들을 FlatList로 화면에 그린다.

React Native - FEED : FlatList 참고

전송 메시지 구분

채팅방에서 내가 전송한 채팅과 다른 사람이 보낸 채팅을 구분해주어야한다. Rooms에서 Room으로 전달한 객체 (talkingTo)로 이 둘을 구분할 수 있다. 마찬가지로, 고유한 값인 username으로 구분한다.

// Room.tsx
...
const MessageContainer = styled.View`
  padding: 0px 10px;
  flex-direction: ${(props: { outGoing: boolean }) =>
    props.outGoing ? "row-reverse" : "row"};
  align-items: flex-end;
`;

function Room({ route, navigation }: RoomProps) {
 ...
  const renderItem: ListRenderItem<SeeRoom_seeRoom_messages | null> = ({
    item: message,
  }) => (
    <MessageContainer
      outGoing={message?.user.username !== route.params?.talkingTo?.username}
    >
      <Author>
        <Avatar source={{ uri: message?.user.avatar! }} />
      </Author>
      <Message>{message?.payload}</Message>
    </MessageContainer>
  );
 ...

최신 순으로 채팅 정렬

FlatList로 채팅을 나열한다. Room 마운트 시, 아래에서부터 최신 순으로 채팅을 정렬하고 스크롤이 내려오게 한다. 그러기 위해선, inverted 속성으로 스크롤을 뒤집은 후 채팅의 배열의 순서를 reverse로 다시 뒤집어준다.

Error : Attempted to assign to read-only property
FlatListdata는 수정할 수 없다. 따라서 원본을 헤치지 않는 immutable한 방식으로 배열을 가져온 후 배열을 reverse로 뒤집는다.

// Room.tsx
...
const messages = [...(data?.seeRoom?.messages ?? [])];
messages.reverse();
// ?? : messages가 배열이 아닐 때, 빈 배열 할당
  const messages = [...(data?.seeRoom?.messages ?? [])];
  messages.reverse();
  return (
    ...
        <FlatList
          inverted
          data={messages}
          ...
        />
		...

KeyboardAvoidingView

React Native - AUTHENTICATION : KeyboardAvoidingView 참고

가상키보드가 Input 창과 채팅을 가리지 않도록 한다.

Cache 수정

Frontend - FEED : Create Comment 참고

새로운 message를 만든 후, Apollo cache를 이용하여 로컬에서 만든 message를 바로 화면에 띄운다.

Subscriptions

Install Transport

웹 소켓 통신을 하기 위해 transport를 설치한다.

$ npm install subscriptions-transport-ws

서버는 이제 웹 소켓이나 http 통신 모두 가능하다. 문제는 요청에 따라, 두 가지 통신 중 하나를 선택하여 그에 맞는 응답을 주어야한다. Apollo-Client의 split 함수를 이용하면 된다.

Split 함수는 3개의 인자를 받는다.

  1. 요청을 구분하여 통신 방법을 리턴하는 함수
  2. 웹 소켓 링크
  3. http 링크

다음과 같이 작성한다.

// apollo.ts
...
const wsLink = new WebSocketLink({
  uri: "ws://localhost:4000/graphql",
  options: {
    reconnect: true,
  },
});

const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === "OperationDefinition" &&
      definition.operation === "subscription"
    );
  },
  wsLink,
  httpLinks
);

const client = new ApolloClient({
  link: splitLink,
  cache,
});

Authentication in WS

현재 서버에서 유저가 인증된 상태를 기대하고 있기 때문에, 웹 소켓 통신 상에서도 인증이 필요하다. connectionParams에 토큰을 입력해준다. 하지만, 링크가 연결될 때는 아직 AsyncStroage에서 토큰을 불러오기 전이기 때문에 객체를 넣어주면 안된다. 함수 형태로 토큰을 넣어주면 모든 요청마다 호출되어 웹 소켓 요청에 토큰이 담기는 것을 확인할 수 있다.

// apollo.ts
const wsLink = new WebSocketLink({
	...
  options: {
		...
    connectionParams: () => ({ 
      token: tokenVar(),
    }),
  },
});

Subscribe to query

Subscription 작성

query나 mutation과 마찬가지로 subscription을 작성해준다.

// Room.tsx
const ROOM_UPDATES = gql`
  subscription roomUpdates($id: Int!) {
    roomUpdates(id: $id) {
      ...RoomMessages
    }
  }
  ${MESSAGE_FRAGMENT}
`; 

subscribeToMore

subscription을 사용할 수 있는 두 가지 방법이 있다.

  1. useSubscription Hook

    useQuery나 useMutation과 마찬가지로 서버에 요청을 보내어 data로 응답을 받는다. 하지만, 즉시 실행되거나 동작하지 않는다. useQuery가 이미 하고 있는 일이다. 따라서, 이 방법은 잘 사용되지 않는다.

  2. subscribeToMore

    useQuery에 내장되어 있는 함수다. subscription으로부터 실시간으로 데이터를 받아 캐시를 업데이트 할 수 있다. useEffect와 함께 다음과 같이 사용한다.

    // Room.tsx
    const [subscribed, setSubscribed] = useState(false);
      useEffect(() => {
        // 채팅방이 있을 시,
        if (data?.seeRoom && !subscribed) {
          subscribeToMore({
            document: ROOM_UPDATES,
            variables: {
              id: route?.params?.id,
            },
            updateQuery, // 캐시를 업데이트 해주는 함수
          });
          setSubscribed(true);
        }
       // seeRoom의 상태가 변할 시 (메시지 추가,삭제 등..) 실행
      }, [data, subscribed]);

Update Cache

updateQuery 함수를 이용하여, 캐시를 조작하면 채팅방이 실시간으로 업데이트 된다. 예전에 했던 캐시를 조작하는 방식과 거의 비슷하지만 두 가지 차이가 있다.

  1. fragment로 만들 message 객체를 직접 만드는 것이 아닌, subscriptionData로부터 가져온다.
  2. updateQuery는 캐시에 대한 접근을 제공하지 않으므로, useApolloClient를 사용한다.
// Room.tsx
const client = useApolloClient();
  const updateQuery: UpdateQueryFn = (prevQuery, options) => {
    const {
      subscriptionData: {
        data: { roomUpdates: message },
      },
    } = options;
		
    if (message.id) {
      ... // writeFragment와 modify로 캐시 조작 
    }
  };

writeFragment와 modify의 사용법은 이전에 다룬 글을 참고하면 된다.

FEED - Create Comment - writeFragment & cache.modify 참고

Fixing bug

위의 방식대로 진행하면, subscription으로 실시간 채팅은 가능하지만 너무 많은 이벤트를 발생시켜 중복된 채팅이 올라오는 버그가 발생한다. 캐시 업데이트 도중, 중복된 키의 메시지가 생성되어 메시지가 계속해서 자가복제되는 버그가 발생한다.

기존 메시지가 있는 캐시에서 새로 메시지와 중복되는 키가 존재하면, 캐시에 추가하지 않는 방식으로 복제되는 버그를 방지할 수 있다.

// Room.tsx
...
client.cache.modify({
        id: `Room:${route.params?.id}`,
        fields: {
          messages(prev) {
            //** incomingMessage가 prev에 있으면 추가하지 않음
            // 중복된 메시지 (없으면 undefined)
            const existingMessage = prev.find(
              (aMessage: Reference) => aMessage.__ref === messageFragment?.__ref
            );
            if (existingMessage) { // 중복이 있으면
              return prev; // 아무것도 하지 않음
            }
            return [...prev, messageFragment];
          },
        },
      });
profile
작지만 꾸준하게 성장하는 개발자🌳

0개의 댓글