포스트

Chat app (4)

user 검색

Firebase에 있는 유져를 검색한다.

1
2
3
4
5
6
7
static func fetchUsers(completion: @escaping([User]) -> Void) {
        collection_User.getDocuments { snapshot, error in
            guard let snapshot = snapshot else { return }
            let users = snapshot.documents.map( {User(dictionary: $0.data())})
            completion(users)
        }
    }

document에 접속하여 유저 데이터를 가져오는 함수를 작성.

NewChatVC에 user변수와 fetchUser함수 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private var users: [User] = [] {
        didSet {
            self.tableView.reloadData()
        }
    }

private func fetchUsers() {
        showLoader(true)
        UserServices.fetchUsers { users in
            self.showLoader(false)
            self.users = users
            print(users)
        }
    }

실행하면 현재 등록된 유져에 대한 추가한 내용이 print 된다.

UserViewModel 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct UserViewModel {
    
    let user: User
    
    var fullname: String { return user.fullname }
    var username: String { return user.username }
    
    var profileImageView: URL? {
        return URL(string: user.profileImageURL)
    }
    
    
    init(user: User) {
        self.user = user
    }
    
}

UserCell에 추가

1
2
3
4
5
6
7
8
9
10
11
12
var viewModel: UserViewModel? {
        didSet {
            configure()
        }
    }

private func configure() {
        guard let viewModel = viewModel else { return }
        self.fullname.text = viewModel.fullname
        self.username.text = viewModel.username
        self.profileImageView.sd_setImage(with: viewModel.profileImageView)
    }

이후 Newchat VC에 셀 적용

1
2
3
4
5
6
7
8
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as! UserTableViewCell
        
        let user = users[indexPath.row] // added
        cell.viewModel = UserViewModel(user: user) // added
        
        return cell
    }

simulator_screenshot_72AD878F-8D3D-44F7-93B1-FC6D309A9EED

확인 완료.

User에서 자기자신은 제외하고 출력

1
2
3
4
5
6
7
8
9
10
11
12
13
private func fetchUsers() {
        showLoader(true)
        UserServices.fetchUsers { users in
            self.showLoader(false)
            self.users = users
            
            
            guard let uid = Auth.auth().currentUser?.uid else { return }
            guard let index = self.users.firstIndex(where: {$0.uid == uid}) else { return }
            self.users.remove(at: index)
            print(users)
        }
    }

이렇게 현재 유져의 uid에 해당하는 index를 제거.

User에게 채팅을 보내는 프로토콜 구현

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
// NewChatVC
protocol NewChatViewControllerDelegate: AnyObject {
    func controller(_ vc: NewChatViewController, wnatChatWithUser otherUser: User)
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let user = users[indexPath.row]
        delegate?.controller(self, wnatChatWithUser: user)
    }


// ConversationVC
@objc func handleNewChat() {
        let controller = NewChatViewController()
        controller.delegate = self // added
        let nav = UINavigationController(rootViewController: controller)
        present(nav,animated: true)
    }

extension ConversationViewController: NewChatViewControllerDelegate {
    func controller(_ vc: NewChatViewController, wnatChatWithUser otherUser: User) {
        vc.dismiss(animated: true)
        print(otherUser.fullname)
    }
}

ChatVC에 나이외의 유저를 추가

1
2
3
4
5
6
private var otherUser: User

init(otherUser: User) {
        self.otherUser = otherUser
        super.init(collectionViewLayout: UICollectionViewFlowLayout())
    }

다시 ConversationViewController로 돌아가서

didselectRowAt에 있던 내용을

1
2
3
4
5
6
7
8
9
10
private func openChat(user: User) { // new
        let controller = ChatViewController(otherUser: user)
        navigationController?.pushViewController(controller, animated: true)
    }

func controller(_ vc: NewChatViewController, wantChatWithUser otherUser: User) {
        vc.dismiss(animated: true)
        print(otherUser.fullname)
        openChat(user: otherUser) // added
    }    

이렇게 하면

Jun-06-2024 20-42-03

화면전환을 하면서 채팅창이 보여진다.

Socket을 사용하여 메세지 기능 구현

CleanShot 2024-06-06 at 20 44 08@2x

기본 틀을 만들어 준다.

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
struct MessageServices {
    static func fetchMessages() {
        
    }
    
    static func fetchRecentMessages() {
        
    }
    
    static func uploadMessage(message: String, currentUser: User, otherUser: User) {
        let dataFrom: [String: Any]  = [
            "text": message,
            "fromID": currentUser.uid,
            "toID": otherUser.uid,
            "timeStamp": Timestamp(date: Date()),
            
            "username": otherUser.username,
            "fullname": otherUser.fullname,
            "profileImageURL": otherUser.profileImageURL
        ]
        
        let dataTo: [String: Any]  = [
            "text": message,
            "fromID": currentUser.uid,
            "toID": otherUser.uid,
            "timeStamp": Timestamp(date: Date()),
            
            "username": currentUser.username,
            "fullname": currentUser.fullname,
            "profileImageURL": currentUser.profileImageURL
        ]
        
        
    }
}

여기서 주목해야할건 uploadMessage에 대한 내용.

dictionary 형태로 하고, 이내용을 통헤 firebase에 저장이 되고, 또 로드를 하게된다.

Constansts에 관련 내용도 추가

let collection_Message = Firestore.firestore().collection("messages")

메세지를 올리는 함수의 내용은 다음과 같다

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
static func uploadMessage(message: String, currentUser: User, otherUser: User, completion: ((Error?) -> Void)?) {
        let dataFrom: [String: Any]  = [
            "text": message,
            "fromID": currentUser.uid,
            "toID": otherUser.uid,
            "timeStamp": Timestamp(date: Date()),
            
            "username": otherUser.username,
            "fullname": otherUser.fullname,
            "profileImageURL": otherUser.profileImageURL
        ]
        
        let dataTo: [String: Any]  = [
            "text": message,
            "fromID": currentUser.uid,
            "toID": otherUser.uid,
            "timeStamp": Timestamp(date: Date()),
            
            "username": currentUser.username,
            "fullname": currentUser.fullname,
            "profileImageURL": currentUser.profileImageURL
        ]
        
        collection_Message.document(currentUser.uid).collection(otherUser.uid).addDocument(data: dataFrom) { _ in
            collection_Message.document(otherUser.uid).collection(currentUser.uid).addDocument(data: dataTo, completion: completion)
            collection_Message.document(currentUser.uid).collection("recent-message").document(otherUser.uid).setData(dataFrom)
            collection_Message.document(otherUser.uid).collection("recent-message").document(currentUser.uid).setData(dataTo)
        }
    }
  • dataFrom
    • 현재 사용자가 보낸 메세지의 데이터를 나타내며, 상대방의 사용자 정보를 포함
  • dataTo
    • 상대방이 받은 메세지의 데이터를 나타내며, 현재 사용자의 정보를 포함

collection_Message 컬렉션에 두 개의 Documents를 저장.

  1. 현재 사용자의 컬렉션에 메세지 추가
  2. 상대방의 컬렉션에 같은 메세지 추가
  3. 최근 메세지를 업데이트
    • setData를 사용함으로써 기존 데이터를 덮어 씌우게 된다.
    • 각 사용자의 recent-message라는 서브 컬렉션에 최근 메세지 정보를 저장.

ChatVC에서 해당 함수 호출

1
2
3
4
5
6
7
8
9
private var currentUser: User
private var otherUser: User
    
    // MARK: - Lifecycle
    init(currentUser: User, otherUser: User) {
        self.currentUser = currentUser
        self.otherUser = otherUser
        super.init(collectionViewLayout: UICollectionViewFlowLayout())
    }

init에 현재 유저가 추가되면서 관련 되어있던 메서드들을 변경해준다.

1
2
3
4
private func openChat(currentUser: User, otherUser: User) {
        let controller = ChatViewController(currentUser: currentUser, otherUser: user)
        navigationController?.pushViewController(controller, animated: true)
    }

실행하여 작동확인

simulator_screenshot_BB5F86D2-F1E1-4BC9-880A-2FA1F63C9219

hello라는 메세지를 보내본다.

제대로 작동한다면 collection에 추가가 되어있을것이다.

CleanShot 2024-06-06 at 21 34 06@2x

하나밖에 추가가 안되어 다시 코드를 보다가 문제를 찾았다.

1
2
3
4
private func openChat(currentUser: User, otherUser: User) {
        let controller = ChatViewController(currentUser: currentUser, otherUser: otherUser) // modified
        navigationController?.pushViewController(controller, animated: true)
    }

otherUser가 user로 되어있어서 생긴 문제.

해결완료.

CleanShot 2024-06-06 at 21 55 57@2x

확인 완료.

Message 모델만들기

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
struct Message {
    let text: String
    let fromId: String
    let toID: String
    let timeStamp: Timestamp
    let username: String
    let fullname: String
    let profileImageURL: String
    
    var isFromCurrentUser: Bool
    
    init(dictionary: [String: Any]) {
        self.text = dictionary["text"] as? String ?? ""
        self.fromId = dictionary["fromId"] as? String ?? ""
        self.toID = dictionary["toID"] as? String ?? ""
        self.username = dictionary["username"] as? String ?? ""
        self.fullname = dictionary["fullname"] as? String ?? ""
        self.profileImageURL = dictionary["profileImageURL"] as? String ?? ""
        
        self.timeStamp = dictionary["timeStamp"] as? Timestamp ?? Timestamp(date: Date())
        
        self.isFromCurrentUser = fromId == Auth.auth().currentUser?.uid
    }
}

여기서 특이점이라면

init할때 self.isFromCurrentUser = fromId == Auth.auth().currentUser?.uid 이렇게 해서했다는것.

현재 유져가 보낸건지 아닌지에대한 True or false를 위와 같이 구분한다.

구분방법은

  1. Firebase Authentication을 통해 현재 사용자 확인
    • Auth.auth().currentUser는 현재 로그인된 사용자를 리턴
    • 사용자가 로그인되어 있지 않다면 currentUser는 nil이 된다.
    • currentUser?.uid를 통해 현재 사용자의 고유 ID를 가져온다.
  2. 메시지의 보낸 사람 ID와 현재 사용자 ID 비교
    • fromId와 currentUser?.uid를 비교하여 동일하면, 현재 사용자가 메시지를 보낸 것이므로 self.isFromCurrentUser를 true로 설정
    • 그렇지 않으면 self.isFromCurrentUser를 false로 설정

이렇게 된다.

Messsage ViewModel 만들기

우선 만들기 전에 메세지를 가져오는 함수를 구현한다

1
2
3
4
5
6
7
8
9
10
11
12
static func fetchMessages(otherUser: User, completion: @escaping([Message]) -> Void) {
        guard let uid = Auth.auth().currentUser?.uid else { return }
        
        var messages = [Message]()
        let query = collection_Message.document(uid).collection(otherUser.uid).order(by: "timeStamp", descending: true)
        
        query.addSnapshotListener { snapshot, _ in
            guard let documentChanges = snapshot?.documentChanges.filter({$0.type == .added}) else { return }
            messages.append(contentsOf: documentChanges.map({Message(dictionary: $0.document.data())}))
            completion(messages)
        }
    }

query는 timeStamp를 기준으로 내림순으로 정렬를 하게 한다.

최신 메세지가 먼저 오게 하기 위함

query.addSnapshotListener를 통해 사용하여 쿼리에 대한 실시간 수신 대기를 설정

  • snapshot이 변경될 때마다(즉, 새로운 메시지가 추가될 때마다) 호출
  • documentChanges 배열에서 .added 타입의 변경만 필터링.
    • 새로 추가된 문서(메시지)만을 가져오기 위함.
  • 필터링된 문서 데이터를 Message 객체로 변환하여 messages 배열에 추가
  • 업데이트된 messages 배열을 escaping Closure를 통해 전달.

그리고 ChatVC에서 호출하는 메서드를 구현

1
2
3
4
5
6
private func fetchMessages() {
        MessageServices.fetchMessages(otherUser: otherUser) { messages in
            self.messages = messages
            print(messages)
        }
    }
1
[ChatApp.Message(text: "Hello", fromID: "ItlrMBBVskOUuenmDxNwocCowzS2", toID: "S91QM1IxdZYXNvxuBpgIFdYOHyf2", timeStamp: <FIRTimestamp: seconds=1717678529 nanoseconds=784600000>, username: "dd", fullname: "D D", profileImageURL:

이런식으로 출력이 되는걸 확인!

이제 진짜 ViewModel 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct MessageViewModel {
    let message: Message
    
    var messageText: String { return message.text }
    var messageBackgroundColor: UIColor { return message.isFromCurrentUser ? #colorLiteral(red: 0.4196078431, green: 0.831372549, blue: 0.431372549, alpha: 1) : #colorLiteral(red: 0.9058823529, green: 0.9098039216, blue: 0.9137254902, alpha: 1) }
    var messageColor: UIColor { return message.isFromCurrentUser ? .white : .black }
    
    var rightAnchorActive: Bool { return message.isFromCurrentUser }
    var leftAnchorActive: Bool { return !message.isFromCurrentUser }
    var shouldHideProfileImage: Bool { return message.isFromCurrentUser }
    
    var profileImageURL: URL? { return URL(string: message.profileImageURL)}
    var timeStampString: String? {
        let date = message.timeStamp.dateValue()
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "hh:mm a"
        return dateFormatter.string(from: date)
    }
    
    init(message: Message) {
        self.message = message
    }
    
}

이걸 하면서 느끼지만 여기 강의에서의 ViewModel은 확실히 성격이 다르다.

ViewModel과 데이터 바인딩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// chatcell

 func configure() {
        guard let viewModel = viewModel else { return }
        bubbleContainer.backgroundColor = viewModel.messageBackgroundColor
        textView.text = viewModel.messageText
        textView.tintColor = viewModel.messageColor
        
        bubbleRightAnchor.isActive = viewModel.rightAnchorActive
        dateRightAnchor.isActive = viewModel.rightAnchorActive
        
        bubbleLeftAnchor.isActive = viewModel.leftAnchorActive
        dateLeftAnchor.isActive = viewModel.leftAnchorActive
        
        profileImageView.sd_setImage(with: viewModel.profileImageURL)
        profileImageView.isHidden = viewModel.shouldHideProfileImage
        
        guard let timeStampString = viewModel.timeStampString else { return }
        datelabel.text = timeStampString
    }

ChatVc에 적용

1
2
3
4
5
6
private func fetchMessages() {
        MessageServices.fetchMessages(otherUser: otherUser) { messages in
            self.messages = messages
            self.collectionView.reloadData() // added
        }
    }

CleanShot 2024-06-06 at 23 26 03@2x

보낸쪽의 메세지가 시간이 조금 안으로 들어갔으나 전달이 되는걸 확인

Jun-06-2024 23-27-27

dateRightAnchor = datelabel.rightAnchor.constraint(equalTo: bubbleContainer.leftAnchor, constant: -12)

-12인데 12로 되어서 안으로 말리는것도 수정완료.

보낸사람의 textColor도 흰색이어야하는데 검은색인것도 수정완료

textView.textColor = viewModel.messageColor

TextColor인데 TintColor로 되어있었다.

최근 보낸 메시지 Fetch

1
2
3
4
5
6
7
8
9
10
11
12
static func fetchRecentMessages(completion: @escaping([Message]) -> Void) {
        guard let uid = Auth.auth().currentUser?.uid else { return }
        
        let query = collection_Message.document(uid).collection("recent-message").order(by: "timeStamp")
        
        query.addSnapshotListener { snapshot, _ in
            guard let documentChanges = snapshot?.documentChanges else { return }
            
            let messages = documentChanges.map({Message(dictionary: $0.document.data())})
            completion(messages)
        }
    }

그리고 ConversationVC에 다음과 같이 호출하는 함수를 구현

1
2
3
4
5
private func fetchConversations() {
        MessageServices.fetchRecentMessages { conversations in
            print(conversations)
        }
    }

우선 프린트로 확인

1
[ChatApp.Message(text: "Hello", fromID: "ItlrMBBVskOUuenmDxNwocCowzS2", toID: "ItlrMBBVskOUuenmDxNwocCowzS2", timeStamp: <FIRTimestamp: seconds=1717677153 nanoseconds=38753000>, username: "ttt", fullname: "Test", profileImageURL:

출력이 되는걸 확인.

다음과 같이 변수를 만들어 준다.

1
2
3
4
5
6
7
 private var conversations: [Message] = []{
        didSet {
            tableView.reloadData()
        }
    }
    
    private var conversationDictionary = [String: Message]()

대화내용을 저장할 배열 conversation 이건 값이 들어올때마다 reload를 하기위해 didSet을 사용한다.

그리고 conversationDictionary를 사용하기전

Message 로가서

var chatPartnerID: String { return isFromCurrentUser ? toID : fromID } 를 추가.

대화 내용을 불러오는 메서드를 수정

1
2
3
4
5
6
7
8
9
private func fetchConversations() {
        MessageServices.fetchRecentMessages { conversations in
            conversations.forEach { conversation in
                self.conversationDictionary[conversation.chatPartnerID] = conversation
            }
            
            self.conversations = Array(self.conversationDictionary.values)
        }
    }

위에 언급하지 않았던 conversationDictionary는 내가 다른사람과 대화한 대상과, 그에 해당하는 메세지를 담는 딕셔너리형배열이다.

MessageViewModel 에서

1
2
var fullname: String { return message.fullname }
var username: String { return message.username }

이걸 추가해준다.

추가하는 이유는 보낸사람의 이름을 확인하기 위함.

그 확인은 ChatCell에서

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var viewModel: MessageViewModel? {
        didSet{
            configure()
        }
    }

private func configure() {
        guard let viewModel = viewModel else { return }
        
        self.profileImageView.sd_setImage(with: viewModel.profileImageURL)
        self.fullname.text = viewModel.fullname
        self.recentMessage.text = viewModel.messageText
        self.dateLabel.text = viewModel.timeStampString
    }

이렇게 추가해주었다.

simulator_screenshot_AF9CCC4F-C39A-4EE8-94CA-07056E6C4CC8

클릭했을때 대화창 열기

ConversationVC에서 didSelectRowAt 메서드를 수정해주면된다.

1
2
3
4
5
6
7
8
9
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let conversation = conversations[indexPath.row]
        
        showLoader(true)
        UserServices.fetchUser(uid: conversation.chatPartnerID) { [self] otherUser in
            showLoader(false)
            openChat(currentUser: user, otherUser: otherUser)
        }
    }

Jun-07-2024 01-06-57

이제 유져를 클릭하면 그 유져와의 대화가 열리게 된다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.