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:
- 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.
- Inviting. One of you invites the other (see Invite to a call and Connection). The friend is still not in the room.
- Connected. The invitation is accepted. Both sides receive
participantDidConnect, and the friend is now aRemoteParticipantinRoom.remoteParticipants(see Room and RemoteParticipant). ItsParticipant.identityis the friend ID (see Participant). - 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 throwsFriendAddError.alreadyExistswhen 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.
timeoutis how long to wait for that confirmation, from 5 to 30 seconds, 10 by default. A value outside that range is clamped, as for messages in a call (see LocalParticipant).FriendMessageError.friendNotReachableandFriendMessageError.sendQueueFullmean the message did not leave your device: you can try again later.FriendMessageError.timeout(messageId:)means no confirmation arrived in time. The message may still have arrived, so sending it again can deliver it twice. The receiving side does not remove duplicates; put your own ID in the message if that matters.
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:
- You invite a friend with
FriendManager.sendInvitation(friendId:message:).messageis free text that the SDK passes along without reading it. - The friend accepts with
FriendManager.acceptInvitation(friendId:)or rejects withFriendManager.rejectInvitation(friendId:). Before they answer, you can cancel withFriendManager.cancelInvitation(friendId:). - Once connected, hang up with
RemoteParticipant.disconnect(), not with these methods.
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"
}
}
}
}