Skip to content

Friends

In RevueSDK you can only talk to friends. A friend is someone you and the other person have both agreed to connect with; once you are friends, you can see whether they are reachable, send them messages and invite them to a call. FriendManager handles all of it, through Revue.friends on Revue.shared, and your RevueDelegate receives the friend events.

Call every FriendManager method on the main thread.

Friends and participants

Only friends can become participants. A friend goes through these steps:

  1. Friends. After you add each other, the two of you have a friend connection: the SDK keeps a path between your devices, whether or not you are in a call (see Connection state). The friend is not in the room.
  2. Inviting. One of you invites the other (see Invite to a call and Connection). The friend is still not in the room.
  3. Connected. The invitation is accepted. Both sides receive participantDidConnect, and the friend is now a RemoteParticipant in Room.remoteParticipants (see Room and RemoteParticipant). Its Participant.identity is the friend ID (see Participant).
  4. Hung up. When the call ends, the friend leaves the room and is no longer a RemoteParticipant, but the two of you are still friends and can call again (see Room and Disconnection).

Adding friends and the friend list

Add a friend

To add someone, send a friend request to their address with FriendManager.addFriend(address:message:). The address is what they show you from Revue.revueAddress, for example as a QR code. See Connection for the code.

Accept or decline a friend request

Your RevueDelegate receives didReceiveFriendRequest with the other side's friend ID and their message.

  • To accept, call FriendManager.addFriendNorequest(friendId:). It throws FriendAddError.alreadyExists when the two of you are already friends.
  • There is no API to decline. Declining means ignoring the request: remove it from your own screen and do nothing else. The other side is not told; they keep you in their friend list and keep seeing that you have no connection.
import RevueSDK

/// Show each friend request, then accept it or just dismiss it.
final class FriendRequestInbox: RevueDelegate {
    private(set) var requests: [String: String] = [:]
    var onChange: (() -> Void)?

    func revue(_ revue: Revue, didReceiveFriendRequest friendId: String, message: String) {
        requests[friendId] = message
        onChange?()
    }

    /// Accept: the two of you become friends.
    @MainActor
    func accept(_ friendId: String) throws {
        do {
            try Revue.shared.friends.addFriendNorequest(friendId: friendId)
        } catch FriendAddError.alreadyExists {
            // Already friends, for example because you also sent them a request.
        }
        requests[friendId] = nil
        onChange?()
    }

    /// Decline: there is no API for it. Remove the request from your own screen; the other side is not told.
    func dismiss(_ friendId: String) {
        requests[friendId] = nil
        onChange?()
    }
}

The friend list

FriendManager.getFriendList() returns the friend IDs, and FriendManager.getFriendName(friendId:), FriendManager.getFriendStatusMessage(friendId:) and FriendManager.getFriendPaths(friendId:) return what you need for each row. After that, keep the list current from your RevueDelegate: didUpdateName, didUpdateStatusMessage, didUpdateStatus and didUpdatePaths each arrive with the friend ID.

import RevueSDK

struct FriendRow {
    let friendId: String
    var name: String
    var isOnline: Bool
}

/// Build the friend list from the SDK, then keep it current from RevueDelegate.
@MainActor
func loadFriendRows() -> [FriendRow] {
    let friends = Revue.shared.friends
    return friends.getFriendList().map { friendId in
        FriendRow(
            friendId: friendId,
            name: friends.getFriendName(friendId: friendId),
            isOnline: friends.getFriendPaths(friendId: friendId).isOnline
        )
    }
}

final class FriendListObserver: RevueDelegate {
    var onNameChanged: ((String, String) -> Void)?
    var onOnlineChanged: ((String, Bool) -> Void)?

    func revue(_ revue: Revue, friend friendId: String, didUpdateName name: String) {
        onNameChanged?(friendId, name)
    }

    func revue(_ revue: Revue, friend friendId: String, didUpdatePaths paths: FriendConnectionPaths) {
        onOnlineChanged?(friendId, paths.isOnline)
    }
}

Delete a friend

FriendManager.deleteFriend(friendId:) deletes a friend. When you are connected to that friend or an invitation is in progress with them, the SDK ends it first and deletes the friend after that:

When you delete The SDK first You receive
You are connected Hangs up, as RemoteParticipant.disconnect() does participantDidDisconnect
You invited them and they have not answered Cancels the invitation didEndInvitation with InvitationEndReason.cancelled
They invited you and you have not answered Rejects the invitation didEndInvitation with InvitationEndReason.rejected

If the invitation has already ended, for example because it timed out, you do not receive didEndInvitation again. If it ended just before you deleted, but the news has not reached your side yet, you receive didEndInvitation once with the real reason, such as InvitationEndReason.timeout, after the friend is deleted.

It throws a FriendDeleteError: FriendDeleteError.notFound when the friend ID is not in your friend list, and FriendDeleteError.failed(code:) when the deletion fails. The code of FriendDeleteError.failed(code:) is always -1, so do not use it to tell what went wrong. When the deletion fails, the call or invitation has already ended, and the friend is still in your friend list.

import RevueSDK

/// Delete a friend. If you are in a call or an invitation with them, the SDK ends it first.
@MainActor
func removeFriend(_ friendId: String) -> Bool {
    do {
        try Revue.shared.friends.deleteFriend(friendId: friendId)
        return true
    } catch FriendDeleteError.notFound {
        // Not a friend any more: treat it as deleted.
        return true
    } catch {
        // FriendDeleteError.failed: its code is always -1 and says nothing about the cause.
        return false
    }
}

Your own name, status message and status

Friends see the name, status message and status you set:

Set with Friends receive
Revue.setSelfName(_:) didUpdateName
Revue.setSelfStatusMessage(_:) didUpdateStatusMessage
Revue.setSelfStatus(_:) didUpdateStatus, with a UserStatus: UserStatus.none (available), UserStatus.away or UserStatus.busy

When you change your own name, status message or status, your own RevueDelegate also receives the same friend event once, with your own identity (Revue.revueId) as the friend ID. You can update your own display and your friends' from the same handler.

import RevueSDK

/// Your own name, status message and status. Friends are told; your own RevueDelegate also receives
/// the friend event once, with your own identity, so one handler can update every name on screen.
final class ProfileObserver: RevueDelegate {
    var onFriendName: ((String, String) -> Void)?
    var onOwnName: ((String) -> Void)?
    var onStatus: ((String, UserStatus) -> Void)?
    private let ownId: String

    /// Create it after initialize, when your own ID is known.
    @MainActor
    init() {
        ownId = Revue.shared.revueId
    }

    func revue(_ revue: Revue, friend friendId: String, didUpdateName name: String) {
        if friendId == ownId {
            onOwnName?(name)
        } else {
            onFriendName?(friendId, name)
        }
    }

    func revue(_ revue: Revue, friend friendId: String, didUpdateStatus status: UserStatus) {
        onStatus?(friendId, status)
    }
}

@MainActor
func updateMyProfile() {
    Revue.shared.setSelfName("Alice")
    Revue.shared.setSelfStatusMessage("In the office until 6")
    Revue.shared.setSelfStatus(.busy)
}

Friend connection

Every friend has a friend connection, in a call or not: the set of paths between your device and theirs. Your RevueDelegate receives didUpdatePaths whenever it changes, and FriendManager.getFriendPaths(friendId:) returns the current value. What the paths mean, and how they behave during a call, is covered in Connection state.

Whether a friend is online, away or busy according to the presence service is a different thing, read with FriendManager.getFriendPresenceStatus(friendId:). See Presence.

Friend messages

FriendManager.sendMessage(friendId:message:timeout:) sends a text message to a friend, outside any call. It waits until the friend's device confirms it has the message and returns a message ID; the friend's RevueDelegate receives didReceiveMessage.

Reserved message format

The SDK uses the same channel for its own messages. When a message is a JSON object whose type is msg, adr or track, the friend's SDK handles it as one of its own messages, and the friend's App does not receive didReceiveMessage. FriendManager.sendMessage(friendId:message:timeout:) does not check: it sends the message and returns a message ID as usual. Use your own key, not type, if you send JSON.

import RevueSDK
import Foundation

/// Your own message format. Its "kind" key keeps it clear of the SDK's reserved "type" values.
struct FriendChat: Codable {
    var kind = "chat"
    let text: String
}

/// Send a chat message to a friend, outside any call, and wait until their device has it.
/// Wrap your own content in your own format: a JSON object whose "type" is "msg", "adr" or "track"
/// is taken by the other side's SDK and never reaches their App.
@MainActor
func sendChat(_ text: String, to friendId: String) async -> Bool {
    guard let data = try? JSONEncoder().encode(FriendChat(text: text)),
          let payload = String(data: data, encoding: .utf8) else { return false }
    do {
        _ = try await Revue.shared.friends.sendMessage(friendId: friendId, message: payload)
        return true
    } catch FriendMessageError.friendNotReachable, FriendMessageError.sendQueueFull {
        // Nothing left the device: you can try again later.
        return false
    } catch FriendMessageError.timeout {
        // The message may have arrived without the receipt coming back. Resending can deliver it twice.
        return false
    } catch {
        return false
    }
}

/// The receiving side.
final class FriendChatReceiver: RevueDelegate {
    var onChat: ((String, String) -> Void)?

    func revue(_ revue: Revue, friend friendId: String, didReceiveMessage message: String) {
        onChat?(friendId, message)
    }
}

Invite to a call

A call starts with an invitation:

import RevueSDK

/// Invite a friend to a call.
@MainActor
func startCallWithFriend(_ friendId: String) throws {
    try Revue.shared.friends.sendInvitation(friendId: friendId, message: "video")
}

/// Accept an invitation you received. If this throws, keep the incoming-call screen so the user can retry.
@MainActor
func answerFriend(_ friendId: String) throws {
    try Revue.shared.friends.acceptInvitation(friendId: friendId)
}

/// Cancel the invitation you sent, before the friend accepts.
@MainActor
func cancelCallToFriend(_ friendId: String) {
    // .invalidState means the invitation has already ended, and didEndInvitation has already arrived.
    try? Revue.shared.friends.cancelInvitation(friendId: friendId)
}

Inviting

An invitation is in progress from didSendInvitation (on the side that invites) or didReceiveInvitation (on the side that is invited) until it ends with either didEndInvitation or participantDidConnect. Only one of the two happens, and it happens only once. After an invitation has ended, you do not receive either event for it again, even if a later FriendManager.cancelInvitation(friendId:) or FriendManager.rejectInvitation(friendId:) succeeds.

FriendManager.cancelInvitation(friendId:) and FriendManager.rejectInvitation(friendId:) throw FriendCallError.invalidState when the invitation has already ended, because the other side ended it first or it timed out. That is harmless: didEndInvitation has already arrived. FriendManager.acceptInvitation(friendId:) throws the same error when the friend is no longer inviting you.

Why an invitation ended

didEndInvitation comes with an InvitationEndReason, always from your own side:

Reason Meaning
InvitationEndReason.cancelled You cancelled the invitation you sent.
InvitationEndReason.rejected You rejected the invitation you received.
InvitationEndReason.timeout Your side stopped waiting: the waiting time ran out before the invitation was accepted or ended. When you invited, this covers both a friend who did not answer and a friend who could not be reached. When you were invited, the inviting side could not be reached.
InvitationEndReason.finished The other side ended it. If you invited, they rejected. If you were invited, they cancelled or stopped waiting.

So the side that invites never receives InvitationEndReason.rejected, and the side that is invited never receives InvitationEndReason.cancelled: the protocol cannot tell whether the other side cancelled or rejected, so the other side ending it is always InvitationEndReason.finished. Your App knows which side it is from didSendInvitation or didReceiveInvitation.

What happens The side that invites receives The side that is invited receives
The inviting side cancels InvitationEndReason.cancelled InvitationEndReason.finished
The invited side rejects InvitationEndReason.finished InvitationEndReason.rejected
Nobody acts until the waiting time runs out InvitationEndReason.timeout InvitationEndReason.finished

When nobody acts, the inviting side stops waiting first and tells the invited side, so the invited side receives InvitationEndReason.finished. The invited side receives InvitationEndReason.timeout only when the inviting side cannot be reached at all.

import RevueSDK

/// Follow every invitation from start to end. Each one ends exactly once:
/// with didEndInvitation, or with participantDidConnect.
final class InvitationTracker: RevueDelegate, RoomDelegate {
    enum Role { case caller, callee }

    private var roles: [String: Role] = [:]
    var onEnded: ((String, String) -> Void)?
    var onConnected: ((String) -> Void)?

    func revue(_ revue: Revue, friend friendId: String, didSendInvitation message: String?) {
        roles[friendId] = .caller
    }

    func revue(_ revue: Revue, friend friendId: String, didReceiveInvitation message: String?) {
        roles[friendId] = .callee
    }

    func revue(_ revue: Revue, friend friendId: String, didEndInvitation reason: InvitationEndReason) {
        let role = roles.removeValue(forKey: friendId)
        onEnded?(friendId, Self.text(for: reason, role: role))
    }

    func room(_ room: Room, participantDidConnect participant: RemoteParticipant) {
        roles[participant.identity] = nil
        onConnected?(participant.identity)
    }

    /// The reason is from your own side: what you did, or that the other side ended it.
    static func text(for reason: InvitationEndReason, role: Role?) -> String {
        switch reason {
        case .cancelled: return "You cancelled the call"
        case .rejected: return "You declined the call"
        case .timeout:
            switch role {
            case .callee: return "Lost contact with your friend"
            case .caller, nil: return "No answer"
            }
        case .finished:
            switch role {
            case .caller: return "Your friend declined"
            case .callee: return "Your friend cancelled or stopped waiting"
            case nil: return "The call ended"
            }
        }
    }
}