From a technical perspective, Tact chats are nothing more than shared CloudKit records, with some extra ceremony that’s not relevant for this post. For each chat, there is a root Chat record, with a CKShare attached to it that determines all the permissions and participants of the chat. CloudKit is somewhat underdocumented and esoteric, and I have done a lot of trial-and-error experimentation to find out how it works. Here’s some of that written up for my future self.
All Tact chats, whether 1:1 on group, start out as private chats. We create a share for each chat.
let share = CKShare(rootRecord: chatRecord) share[CKShare.SystemFieldKey.title] = chat.name share.publicPermission = .none // Store the share and chat records back to iCloud
The chat owner can then add and remove participants to the chat. Here’s an outline how the owner adds a participant.
let lookupInfo = CKUserIdentity.LookupInfo(userRecordID: userRecordID) let fetchParticipantsOperation = CKFetchShareParticipantsOperation(userIdentityLookupInfos: [lookupInfo]) // Run the operation and get a shareParticipant result shareParticipant.permission = .readWrite share.addParticipant(shareParticipant) // Store the share back to iCloud
The above adds a participant to the share whose
pending. The pending participant shows up only for the owner. Other members of the share do not see the participant at this point.
To join the share, the added person needs to receive an invitation to the share, open the associated CKShare URL, and go through the joining process with system confirmation on their side. After they do that, their
acceptanceStatus changes to
accepted, and they show up for other members of the share as well. In other words, after a member joins a share, their info now appears in
CKShare.participants for all members of the share. The
role if regular members of a share is set to
privateUser, while owner has of course the
There are two ways for someone to leave a share/chat. Either the owner removes them with the
removeParticipant API, or they leave themselves by deleting the CKShare record. Note that it doesn’t make sense for the owner to leave a share (or chat in Tact): all shared CloudKit records must have an active owner. The only thing that the owner can do to “leave” the chat is to delete the whole record (chat).
Publicly joinable chats
First, a note about terms. We currently avoid the term “public chat” in Tact because even joinable chats are still owned by one owner who has ultimate authority over it, and the chat remains in the owner’s private database, counts against their iCloud quota, the content is strictly visible only to chat members, they are the ultimate authority about participation etc. We may choose to have another kind of “public chat” in Tact in the future, which lives in the public iCloud database with all the implications that come with it. So far, we haven’t done this. Hence, we talk about “joinable” chats in Tact.
You can make a chat joinable in Tact by “publishing a link” for it. Technically, this changes the CKShare permission.
share.publicPermission = .readWrite // Store the share back to iCloud
Anyone with an iCloud account who gets access to the CKShare URL can open it and join the chat. When they do that, they become a CKShare participant with
acceptanceStatus = accepted and
role = publicUser.
In Tact, I have chosen to put an level of indirection on top of CKShare URLs, so you never see or use those directly. You see “group links”, but those map back to a CKShare URL in the end, so this is just an implementation detail in Tact.
A fact that’s not obvious from CloudKit documentation: after you change a share’s
readWrite, the owner can no longer add participants with
addParticipant API. Doing so results in an internal consistency exception. The only way to add participants to a public share is if they themselves join the share using the URL.
Even though the owner cannot add new participants, they can still remove participants with the
removeParticipant API as expected. In a social network like Tact, this provides a level of control to the owners, as they can remove any unwanted members, even if they joined without the owner’s explicit approval.
What happens to the previous share participants with
role = privateUser when you make a CKShare publicly joinable (change its
.readWrite)? The documentation is not obvious on this, but my experiments show that the previous set of
privateUser participants is preserved when you make a share joinable. Thus, shares that were previously private, the owner added some members, and then made it joinable, will have a mix of
publicUser participants. The owner cannot add new
privateUser participants as noted above, but the previously existing members are preserved. This is good for Tact, as it means if you have a private group chat and decide to make it joinable, there is no disruption to current members.
What happens when you change a share’s
.readWrite back to
.none? The documentation is pretty clear on this:
CloudKit removes all participants if the new permission is
Indeed, this is what happens if you do it. All other participants are removed and only the owner remains as a participant on the share. You could say it’s somewhat counter-intuitive - shouldn’t the previous set of
privateUser participants be preserved? Perhaps, but this is just how the system works, and for now we must accept it as a fact.
This is the reason why you cannot go back from a joinable to private chat in Tact: all members would be removed, and it would be counter-intuitive. If you make a chat joinable, it will remain joinable.
We might implement different ways of controlling a joinable chat’s access: perhaps you could change the joining link to a different one and invalidate the previous one, or other solutions like this. Let us know if you ever run into this in Tact, and we can further develop this area.