공부/ReactNative

[ReactNative] 채팅 애플리케이션(Firebase) 3 | 처음 배우는 리액트네이티브 9장

hyunh404 2025. 2. 5. 04:29
728x90



메인 화면

 

 

: 인증 상태가 해제되면 렌더링 되는 화면

 

 

1. 내비게이션

  • MainStack 내비게이션 : 채널 생성 화면과 채널 화면
  • MainTab 내비게이션 : MainStack 내비게이션에서 화면으로 사용

 

👇내비게이션 코드 보기

더보기
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ChannelList, Profile } from '../screens';

const Tab = createBottomTabNavigator();

const MainTab = () => {
  return (
    <Tab.Navigator>
        <Tab.Screen name='Channel List' component={ChannelList} />
        <Tab.Screen name='Profile' component={Profile} />
    </Tab.Navigator>
  )
}

export default MainTab
import { createStackNavigator } from '@react-navigation/stack';
import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components/native';
import { Channel, ChannelCreation } from '../screens';
import MainTab from './MainTab';

const Stack = createStackNavigator();

const MainStack = () => {
    const theme = useContext(ThemeContext);

    return (
        <Stack.Navigator
            initialRouteName='Main'
            screenOptions={{
                headerTitleAlign: 'center',
                headerTintColor: theme.headerTintColor,
                cardStyle: { backgroundColor: theme.background },
                headerBackTitleVisible: false,
            }}
        >
            <Stack.Screen name='Main' component={MainTab} />
            <Stack.Screen name='Channel Creation' component={ChannelCreation} />
            <Stack.Screen name='Channel' component={Channel} />
        </Stack.Navigator>
    )
}

export default MainStack

 

 

현재는 임시로 스택 내비게이션을 MainStack으로 맞춰둔 상태이다.

따라서 이번에는

인증 상태에 따라 MainStack과 AuthStack 내비게이션을 렌더링해보려 한다.

 

  • 애플리케이션 시작 ▶ AuthStack 내비게이션 렌더링
  • 로그인/회원가입 인증 성공 ▶ MainStack 내비게이션 렌더링
  • 로그아웃 후 인증 상태가 사라지면 ▶ AuthStack 내비게이션 렌더링

 

⭐ 여러 곳에서 상태를 변경해야할경우 Context API를 이용해서 전역적으로 관리하는 것이 좋다.

 

 

이번에는 UserContext를 만들고 인증 상태에 따른 내비게이션 렌더링을 구현해보겠다.

 

👇UserContext 코드 보기

더보기
import React, { createContext, useState } from 'react';

const UserContext = createContext({
    user: { email: null, uid: null },
    dispatch: () => { },
});

const UserProvider = ({ children }) => {
    const [user, setUser] = useState({});
    const dispatch = ({ email, uid }) => {
        setUser({ email, uid });
    };
    const value = { user, dispatch };
    return (
        <UserContext.Provider value={value}>{children}</UserContext.Provider>
    );
};

export { UserContext, UserProvider };

 

 

이제 UserContext의 user 상태에 따라 인증 여부를 확인하고 MainStack과 AuthStack 렌더링을 변경할 수 있다.

UserContext의 user에 uid와 email 값이 존재하면 인증된 것으로 판단하고 MainStack 내비게이션을 렌더링하도록 코드를 작성했다.

 

{user?.uid && user?.email ? <MainStack /> : <AuthStack />}

 

 

따라서 로그인을 하면 메인 화면이 렌더링 되는 것을 확인할 수 있었다.

이제 로그아웃을 정의해 다시 인증 화면이 렌더링 되는 것을 확인해보겠다.

 

 

로그아웃 함수를 먼저 정의해주었다.

export const logout = async () => {
    return await Auth.signOut();
};

 

👇Logout 코드 보기

더보기
import React, { useContext } from 'react';
import styled from 'styled-components/native';
import { UserContext } from '../contexts';
import { logout } from '../utils/firebase';
import { Button } from '../components';

const Container = styled.View`
    flex: 1;
    background-color: ${({ theme }) => theme.background};
`;

const Profile = () => {
    const { dispatch } = useContext(UserContext);

    const _handleLogoutButtonPress = async () => {
        try {
            await logout();
        } catch (e) {
            console.log('[Profile] logout: ', e.message);
        } finally {
            dispatch({});
        }
    }
    return (
        <Container>
            <Button title='logout' onPress={_handleLogoutButtonPress} />
        </Container>
    )
}

export default Profile

 

 

로그아웃 버튼을 만들어 UserContext의 dispatch 함수를 이용해 user의 상태를 변경하고

AuthStack 내비게이션이 렌더링되도록 만들었다.

 

 

728x90

 

 

2. 프로필 화면

  • 사용자의 정보 확인
  • 사진 변경 기능
  • 로그아웃 기능

 

프로필 정보를 받아오기 위해 파이어베이스에 저장된 user 정보를 가져오는 함수를 정의해보겠다.

 

기본적인 uid, name, email은 Firebase Authentication에서 가져오고,

사용자 프로필 이미지는 현재 스토리지를 사용하지 않기 때문에 

firestore에 있는 이미지 url을 받아오고, 업데이트 또한 firestore에 업데이트 하는 방식을 이용하려 한다.

 

 

기본적인 정보를 가져오는 함수의 동작 원리는 다음과 같다.

  • Auth.currentUser를 사용하여 현재 로그인된 사용자의 uid, displayName, email을 가져옴
  • Firestore에서 "users" 컬렉션의 uid 문서를 참조하여 해당 사용자의 데이터를 조회
  • 사용자가 Firestore에 존재하면 photo 필드 값을 가져와서 함께 반환
  • Firestore에 해당 사용자가 존재하지 않으면 photoUrl: null을 반환하여 기본 프로필 사진을 사용

 

👇Profile getCurrentUser 함수 코드 보기

더보기
export const getCurrentUser = async () => {
    const { uid, displayName, email } = Auth.currentUser;

    const userRef = doc(db, "users", uid);
    const userSnap = await getDoc(userRef);

    if (userSnap.exists()) {
        const userData = userSnap.data();
        return { uid, name: displayName, email, photoUrl: userData.photo };
    } else {
        return { uid, name: displayName, email, photoUrl: null };
    }
};

 

 

업데이트 함수 또한 firestore를 이용해서 정의해주었다.

동작 원리는 다음과 같다.

  • 현재 로그인된 Auth.currentUser를 확인해 로그인된 사용자가 없으면 에러 발생
  • convertImageToBase64(photoUri) 함수를 사용해 이미지 URI를 Base64 인코딩된 문자열로 변환
  • Firestore "users" 컬렉션의 uid 문서에 Base64로 변환된 이미지 데이터를 photo 필드에 저장
  • { merge: true } 옵션을 사용하여 기존 데이터는 유지하면서 photo 필드만 업데이트

 

👇Profile updateUserPhoto 함수 코드 보기

더보기
export const updateUserPhoto = async (photoUri) => {
    const user = Auth.currentUser;
    if (!user) throw new Error("No authenticated user");

    // 이미지 URI를 Base64로 변환
    const imageBase64 = await convertImageToBase64(photoUri);

    // Firestore의 "users" 컬렉션에서 현재 사용자 문서 업데이트
    const userRef = doc(db, "users", user.uid);
    await setDoc(userRef, { photo: imageBase64 }, { merge: true });

    return {name: user.displayName, email: user.email, photoUrl: imageBase64};
};

 

 

 

사용자 정보를 받아오고 업데이트 하는 함수를 정의했으니

화면에 정보를 표시하기 위한 작업을 해보겠다.

 

먼저 교재와는 다르게 현재 정의한 함수가 비동기 함수이기 때문에 사용자 정보를 불러오는 값을 즉시 할당할 수가 없다.

 

 

따라서

useEffect와 useState를 이용해서 비동기 데이터를 불러온 후 상태에 저장해야한다.

 

 

photoUrl을 저장할 상태변수와 user 정보를 저장할 상태변수를 정의하고

미리 정의해둔 함수들을 불러와 값을 저장함으로써 정상적으로 화면에 원하는 정보를 띄울 수 있었다.

 

 

또한 이미지 업데이트 시 실시간으로 UI에 반영하기 위해 세터함수를 이용해서 업데이트 된 url을 바로 변경 후 나타내주었다.

 

👇Profile 값 저장 코드 보기

더보기
const [photoUrl, setPhotoUrl] = useState(null);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);

    // Firestore에서 유저 정보 가져오기
    useEffect(() => {
        const fetchUser = async () => {
            try {
                const userData = await getCurrentUser();
                setUser(userData);
                setPhotoUrl(userData.photoUrl);
            } catch (e) {
                console.error("Error fetching user data:", e.message);
            } finally {
                setLoading(false);
            }
        };

        fetchUser();
    }, [photoUrl]);

    const _handleLogoutButtonPress = async () => {
        try {
            spinner.start();
            await logout();
        } catch (e) {
            console.log('[Profile] logout: ', e.message);
        } finally {
            dispatch({});
            spinner.stop();
        }
    };

    const _handlePhotoChange = async url => {
        try {
            spinner.start();
            const updatedUser = await updateUserPhoto(url);
            setPhotoUrl(updatedUser.photoUrl);
        } catch (e) {
            Alert.alert('Photo Error', e.message);
        } finally {
            spinner.stop();
        }
    };

프로필 정보

 

 

추가적으로 프로필 화면에서 사용자 이름과 이메일을 수정하는 기능은 제공하지 않기 때문에

수정이 불가능하도록 만들어주겠다.

 

disabled를 활용해서 input 컴포넌트를 비활성 시켰다.

 

input 비활성

 

 

 

3. 헤더 변경

⚠️ 
교재에 나온대로
route의 state를 활용하는 방법은 가능은 하지만,
route.state를 직접 감지하는 방식은 최신 네비게이션 구조에서는 예상대로 동작하지 않을 수도 있다.

더 안정적인 방법은 screenOptions을 Tab.Screen에 직접 설정하거나,
useNavigation().getState()를 활용하는 것이다.

 

 

따라서

나는 route를 활용하지 않고 직접 탭 내비게이션에 정의해 헤더를 수정했다.

 

헤더 타이틀 변경

 

 

 

4. 채널 생성 화면

이제 채널을 생성하는 화면을 만들고 생성된 채널을 파이어베이스로 관리해보겠다.

 

 

먼저 채널 생성 버튼을 만들어야 한다.

 

Channel 탭 상단 옵션에 headerRight를 정의해 Add 버튼을 만들어 주었다.

 

<Tab.Screen
    name='Channels'
    component={ChannelList}
    options={{
        tabBarIcon: ({ focused }) =>
            TabBarIcon({
            focused,
            name: focused ? 'chat-bubble' : 'chat-bubble-outline',
            }),
        headerRight: () => (
            <MaterialIcons
                name="add"
                size={26}
                style={{ margin: 10 }}
                onPress={() => navigation.navigate('Channel Creation')}
            />
        ),
    }}
/>

채널 생성 버튼

 

 

앞서 만들었던 Input과 Button 컴포넌트들을 활용해 채널을 생성하는 화면을 간단하게 만들 수 있었다.

 

👇채널 생성 화면 코드 보기

더보기
import React, { useEffect, useRef, useState } from 'react';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import styled from 'styled-components/native';
import { Button, Input } from '../components';

const Container = styled.View`
    flex: 1;
    background-color: ${({ theme }) => theme.background};
    justify-content: center;
    align-items: center;
    padding: 0 20px;
`;

const ErrorText = styled.Text`
    align-items: flex-start;
    width: 100%;
    height: 20px;
    margin-bottom: 10px;
    line-height: 20px;
    color: ${({ theme }) => theme.errorText};
`;

const ChannelCreation = () => {
    const [title, setTitle] = useState('');
    const [description, setDescription] = useState('');
    const descriptionRef = useRef();
    const [errorMessage, setErrorMessage] = useState('');
    const [disabled, setDisabled] = useState(true);

    useEffect(() => {
        setDisabled(!(title && !errorMessage));
    }, [title, description, errorMessage]);

    const _handleTitleChange = title => {
        setTitle(title);
        setErrorMessage(title.trim() ? '' : 'Please enter the title.')
    };

    const _handleCreateButtonPress = () => { };

    return (
        <KeyboardAwareScrollView
            contentContainerStyle={{ flex: 1 }}
            extraHeight={20}
        >
            <Container>
                <Input
                    label="Title"
                    value={title}
                    onChangeText={_handleTitleChange}
                    onSubmitEditing={() => {
                        setTitle(title.trim());
                        descriptionRef.current.focus();
                    }}
                    onBlur={() => setTitle(title.trim())}
                    placeholder="Title"
                    returnKeyType="next"
                    maxLength={20}
                />
                <Input
                    ref={descriptionRef}
                    label="Description"
                    value={description}
                    onChangeText={text => setDescription(text)}
                    onSubmitEditing={() => {
                        setDescription(description.trim());
                        _handleCreateButtonPress();
                    }}
                    onBlur={() => setDescription(description.trim())}
                    placeholder="Description"
                    returnKeyType="done"
                    maxLength={40}
                />
                <ErrorText>{errorMessage}</ErrorText>
                <Button
                    title="Create"
                    onPress={_handleCreateButtonPress}
                    disabled={disabled}
                />
            </Container>
        </KeyboardAwareScrollView>
    )
}

export default ChannelCreation

채널 생성 화면

 

 

 

이제 파어베이스에 채널을 생성하도록 하는 함수를 정의할 차례이다.

  • Firestore의 doc()을 사용하면 새 문서 ID를 자동 생성할 수 있다.
  • id, title, description, createdAt을 데이터 필드로 생성
  • setDoc()을 사용하여 Firestore에 문서 저장
  • 생성된 채널 ID를 반환해 화면에서 사용할 수 있도록 한다.

 

👇파이어베이스 채널 생성 코드 보기

더보기
export const createChannel = async ({ title, description }) => {
    try {
        const auth = getAuth();
        const user = auth.currentUser;

        if (!user) {
            throw new Error("User is not authenticated.");
        }

        const channelsCollection = collection(db, "channels");
        const newChannelRef = doc(channelsCollection);
        const id = newChannelRef.id;

        const newChannel = {
            id,
            title,
            description,
            createdAt: Date.now(),
            createdBy: user.uid,
        };

        await setDoc(newChannelRef, newChannel);

        return id;
    } catch (error) {
        console.error("Error creating channel:", error);
        throw error;
    }
};

 

 

이렇게 채널 생성이 완료되면 새로 생성된 채널로 이동하기위해

navigation의 replace 함수를 이용해서 이동 함수를 정의해주었다.

 

또한, 채널 생성이 완료되면 채널 화면으로 이동하면서

현재 입장하는 채널의 ID와 제목을 params로 함께 전달했다.

const _handleCreateButtonPress = async () => {
        try {
            spinner.start();
            const id = await createChannel({ title, description });
            navigation.replace('Channel', { id, title });
        } catch (e) {
            Alert.alert('Creation Error', e.message)
        } finally {
            spinner.stop();
        }
    };

채널 생성

 

 

 

5. 채널 목록 화면

1️⃣ FlatList 컴포넌트

 

이제는 FlatList 컴포넌트를 이용해서 생성된 채널들을 렌더링하도록 만들어보겠다.

 

임의의 데이터를 FlatList 컴포넌트에 항목으로 사용할 데이터로 설정했다.

render Item에 작성되는 함수는 파라미터로 항목의 데이터를 가진 item이 포함된 객체가 전달되고,

파라미터로 전달되는 데이터를 이용해서 채널 화면으로 이동하도록 했다.

 

목록

 

 

2️⃣ windowSize

 

💡
화면의 크기에 따라 렌더링되는 항목의 수와
터미널을 통해 확인돠는 렌더링된 데이터의 양에 차이가 있을 수 있다.

 

  • FlatList 컴포넌트에서 렌더링되는 데이터의 수는 windowSize 속성에 의해 결정됨
  • windowSize의 기본값 = 21
  • windowSize값을 감소 ▶ 렌더링되는 데이터 줄음, 메모리의 소비를 줄이고 성능 향상됨 ▷ 미리 렌더링되지 않은 부분은 순간적으로 빈 내용이 나타날 수 있다는 단점 존재

 

채널 목록 화면에서는 windowSize값을 3으로 변경해 현재 화면의 앞뒤로 한 화면만큼만 렌더링되도록 하겠다.

 

 

3️⃣ React.memo

 

windowSize를 수정해서 렌더링되는 양을 줄였지만

여전히 비효율적인 부분이 존재한다.

 

스크롤이 이동하면 windowSize 값에 맞춰 현재 화면과 이전, 이후 데이터를 렌더링하는 것이 맞지만

이미 렌더링된 항목도 다시 렌더링되는 것을 확인할 수 있다.

 

 

이를 해결하기 위해 React.memo를 사용할 수 있다.

React.memo를 이용하면 비효율적인 반복 작업을 해결할 수 있다.

 

useMemo React.memo
불필요한 함수 재연산 방지 컴포넌트 리렌더링 방지

 

 

 

이제 Item 컴포넌트는 props가 변경될 때까지 리렌더링되지 않도록 만들었다.

 

👇React.memo 적용 코드 보기

더보기
const Item = React.memo(
    ({ item: { id, title, description, createdAt }, onPress }) => {
        const theme = useContext(ThemeContext);

        return (
            <ItemContainer onPress={() => onPress({ id, title })}>
                <ItemTextContainer>
                    <ItemTitle>{title}</ItemTitle>
                    <ItemDescription>{description}</ItemDescription>
                </ItemTextContainer>
                <ItemTime>{createdAt}</ItemTime>
                <MaterialIcons
                    name='keyboard-arrow-right'
                    size={24}
                    color={theme.listIcon}
                />
            </ItemContainer>
        );
    }
);

 

 

4️⃣ 채널 데이터 수신

 

이번에는 채널 목록 화면에서 파이어베이스 데이터를 받아 목록을 렌더링해보려한다.

 

목록 데이터를 받아오기 위해 먼저 데이터를 미리 생성해주었다.

 

 

useEffect 함수를 이용해서 채널 목록 화면이 마운트될 때

onSnapshot 함수로 데이터베이스에서 데이터를 수신하도록 했다.

 

onSnapshot 함수는 수신 대기 상태에서 데이터베이스에 문서가 추가되거나 수정될 때마다 지정된 함수가 호출된다.

 

👇데이터 받아오는 코드 보기

더보기
const [channels, setChannels] = useState([]);

    useEffect(() => {
        const channelsRef = collection(db, "channels");
        const q = query(channelsRef, orderBy("createdAt", "desc"));
        
        const unsubscribe = onSnapshot(q, (snapshot) => {
            const channelData = snapshot.docs.map(doc => ({
                id: doc.id,
                ...doc.data(),
            }));
            setChannels(channelData);
        });

        return () => unsubscribe();
    }, []);

목록 불러오기

 

 

 

5️⃣ moment 라이브러리

 

  • 타임스탬프를 사용자가 알아볼 수 있는 시간 형태로 변경
  • 시간 및 날짜와 관련된 함수를 쉽게 작성하도록 도움
  • 날짜 관련 라이브러리 중 가장 많은 기능을 제공하고 사용됨
 npm install moment

 

 

 

라이브러리를 설치해준 후 moment 라이브러리를 이용해

createdAt 필드에 저장된 타임스탬프를 보기 쉬운 형태로 변경해주겠다.

 

const getDateOrTime = ts => {
    const now = moment().startOf('day');
    const target = moment(ts).startOf('day');
    return moment(ts).format(now.diff(target, 'days') > 0 ? 'MM/DD' : 'HH:mm');
}

 

시간 형태 변경

 

 

 

6. 채널 화면

이제 사용자가 메세지를 주고받을 수 있는 채널 화면을 만들어야한다.

 

메시지 데이터도 채널 데이터와 마찬가지로 최신 데이터부터 받아오기 위해

createdAt 필드 값의 내림차순으로 정렬하고

헤더 타이틀을 채널의 이름이 렌더링되도록 했다.

 

 

👇데이터 받아오는 코드 보기

더보기
const [messages, setMessages] = useState([]);

    useEffect(() => {
        if (!params?.id) return;

        const messagesRef = collection(db, "channels", params.id, "messages");
        const q = query(messagesRef, orderBy("createdAt", "desc"));

        const unsubscribe = onSnapshot(q, (snapshot) => {
            const list = snapshot.docs.map(doc => ({
                id: doc.id,
                ...doc.data(),
            }));
            setMessages(list);
        });

        return () => unsubscribe();
    }, [params.id]);

    useLayoutEffect(() => {
        navigation.setOptions({ headerTitle: params.title || "Channel" });
    }, [navigation, params.title]);

 

 

 

7. 메세지 전송

파이어베이스에 실시간으로 메세지를 저장하는 함수를 정의하고

Input 컴포넌트로 메세지를 입력받아 문서를 생성하도록 했다.

 

👇실시간 메세지 전송 코드 보기

더보기
export const createMessage = async ({ channelId, text }) => {
    try {
        if (!channelId || !text) {
            throw new Error("Missing required fields: channelId or text");
        }

        const messagesRef = collection(db, "channels", channelId, "messages");

        const newMessage = {
            text,
            createdAt: serverTimestamp(),
        };

        const docRef = await addDoc(messagesRef, newMessage);
        return docRef.id;
    } catch (error) {
        console.error("Error sending message:", error);
        throw error;
    }
};

메세지 전송

 

 

 

8. GiftedChat 컴포넌트

  • 다양한 설정이 가능하도록 많은 속성 제공
  • 입력된 내용을 설정된 사용자의 정보 및 자동으로 생성된 ID와 함께 전달하는 기능
  • 전송 버튼을 수정하는 기능
  • 스크롤의 위치에 따라 스크롤 위치를 변경하는 버튼을 렌더링하는 기능 등

 

 

메세지를 주고받는 화면은 일반적인 모바일 화면과 스크롤 방향이 반대이다.

 

채팅 앱은 최신데이터가 가장 아래에 나타나고 스크롤 방향은 위로 올라가도록 화면이 구성된다.

 

  • FlatList 컴포넌트의 inverted 속성 : 스크롤 방향이 변경됨

 

FlatList에 inverted={true} 속성을 적용하면 메세지가 아래에서 나타나는 것을 확인할 수 있다.

 

inverted 속성

 

 

 npm install react-native-gifted-chat

 

 

라이브러리를 설치하고

기존에 화면을 구성하던 FlatList 컴포넌트와 Input 컴포넌트를 제거한 후 GiftedChat 컴포넌트를 이용해서 화면을 구성했다.

 

GiftedChat 컴포넌트의 user에 사용자의 정보를 입력해두면 onSend에 정의한 함수가 호출될 때 입력된 메시지와 사용자의 정보를 포함한 객체를 전달하게 된다.

 

👇GiftedChat 코드 보기

더보기
import React, { useContext, useEffect, useLayoutEffect, useState } from 'react';
import styled, { ThemeContext } from 'styled-components/native';
import { db, createMessage, getCurrentUser } from '../utils/firebase';
import { collection, query, orderBy, onSnapshot, addDoc } from 'firebase/firestore';
import { GiftedChat, Send } from 'react-native-gifted-chat';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { ActivityIndicator, Alert, Text } from 'react-native';

const Container = styled.View`
    flex: 1;
    background-color: ${({ theme }) => theme.background};
`;

const SendButton = props => {
    const theme = useContext(ThemeContext);

    return (
        <Send
            {...props}
            disabled={!props.text}
            containerStyle={{
                width: 44,
                height: 44,
                alignItems: 'center',
                justifyContent: 'center',
                marginHorizontal: 4,
            }}
        >
            <MaterialIcons
                name='send'
                size={24}
                color={props.text ? theme.sendButtonActive : theme.sendButtonInactive}
            />
        </Send>
    );
};

const Channel = ({ navigation, route: { params } }) => {
    const theme = useContext(ThemeContext);
    const [user, setUser] = useState(null);
    const [messages, setMessages] = useState([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        const fetchUser = async () => {
            try {
                const currentUser = await getCurrentUser();
                if (currentUser) {
                    setUser(currentUser);
                } else {
                    console.error("No authenticated user found.");
                }
            } catch (error) {
                console.error("Error fetching user:", error);
            } finally {
                setLoading(false);
            }
        };

        fetchUser();
    }, []);

    useEffect(() => {
        if (!params?.id) return;

        const messagesRef = collection(db, "channels", params.id, "messages");
        const q = query(messagesRef, orderBy("createdAt", "desc"));

        const unsubscribe = onSnapshot(q, (snapshot) => {
            const list = snapshot.docs.map(doc => ({
                _id: doc.id,
                ...doc.data(),
            }));
            setMessages(list);
        });

        return () => {
            unsubscribe();
        };
    }, [params.id]);

    useLayoutEffect(() => {
        navigation.setOptions({ headerTitle: params.title || "Channel" });
    }, [navigation, params.title]);

    const _handleMessageSend = async (messageList) => {
        if (!user) {
            Alert.alert("Error", "User not authenticated.");
            return;
        }
    
        const newMessage = messageList[0];
    
        if (!newMessage || !newMessage.text) {
            Alert.alert("Error", "Message is empty.");
            return;
        }
    
        try {
            await createMessage({
                channelId: params.id,
                message: newMessage,
            });
        } catch (e) {
            Alert.alert("Send Message Error", e.message);
            console.error("Message send error:", e);
        }
    };

    if (loading) {
        return (
            <Container>
                <ActivityIndicator size="large" color={theme.primary} />
            </Container>
        );
    }

    if (!user) {
        return (
            <Container>
                <Text style={{ color: theme.text, fontSize: 18 }}>
                    No authenticated user found.
                </Text>
            </Container>
        );
    }

    return (
        <Container>
            <GiftedChat
                listViewProps={{
                    style: { backgroundColor: theme.background },
                }}
                placeholder='Enter a message...'
                messages={messages}
                user={{
                    _id: user?.uid || "unknown",
                    name: user?.name || "Unknown",
                    avatar: user?.photoUrl || "",
                }}
                onSend={_handleMessageSend}
                alwaysShowSend={true}
                renderUsernameOnMessage={true}
                scrollToBottom={true}
                renderSend={props => <SendButton {...props} />}
            />
        </Container>
    );
};

export default Channel;

 

 

수정된 코드에 맞춰 파이어베이스에 메세지 데이터를 저장하는 함수도 변경해주겠다.

전달되는 파라미터가 message 객체로 변경되었고

생성되는 메시지 문서도 전달된 객체에 포함된 _id의 값으로 사용하도록 수정했다.

 

👇createMessage 코드 보기

더보기
export const createMessage = async ({ channelId, message }) => {
    try {
        if (!channelId || !message || !message._id) {
            throw new Error("Missing required fields: channelId or message._id");
        }

        const messagesRef = collection(db, "channels", channelId, "messages");
        const messageDoc = doc(messagesRef, message._id);

        const newMessage = {
            ...message,
            createdAt: serverTimestamp(),
        };

        await setDoc(messageDoc, newMessage);

        return message._id;
    } catch (error) {
        console.error("Error sending message:", error);
        throw error;
    }
};

메세지 전송
대화 나누기

 

 

 

마지막으로,

메세지 전송 시간이 정확히 표시되지 않고 Invalid Date로만 표시되는 오류를 발견하고 date 함수를 약간 수정해주었다.

 

원인을 찾아본 결과 아래와 같은 이유로 시간이 제대로 표시되지 않는 다는 것을 확인할 수 있었다.

💡
Firestore에서 createdAt을 serverTimestamp()로 설정하지만, onSnapshot으로 가져올 때 toDate()를 사용하여 변환해주어햐 한다.

 

 

 

따라서

메세지를 가져오는 함수에서 toDate를 이용해 형식을 변경해주었더니 정상적으로 시간이 뜨는 것을 확인할 수 있었다.

 

 useEffect(() => {
        if (!params?.id) return;

        const messagesRef = collection(db, "channels", params.id, "messages");
        const q = query(messagesRef, orderBy("createdAt", "desc"));

        const unsubscribe = onSnapshot(q, (snapshot) => {
            const list = snapshot.docs.map(doc => {
                const data = doc.data();
                return {
                    _id: doc.id,
                    ...data,
                    createdAt: data.createdAt ? data.createdAt.toDate() : new Date(),
                };
            });
            setMessages(list);
        });

        return () => {
            unsubscribe();
        };
    }, [params.id]);

시간 형식 변경

 

 

 

 

이렇게 해서 채팅 애플리케이션을 로그인, 회원가입, 채팅방 목록, 채팅까지 만들어보았다.

 

전체 코드는 깃허브 링크를 아래에 걸어두고 글을 마치겠다.

 

https://github.com/BB545/ReactNative_ExpoProject/tree/master/react-native-simple-chat
728x90