Chats are the heart of Tact. You can have both one-on-one direct messages and group chats.

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.

Private chats

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 acceptanceStatus is 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 owner role.

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 publicPermission to 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 publicPermission to .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 privateUser and 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 publicPermission from .readWrite back to .none? The documentation is pretty clear on this:

CloudKit removes all participants if the new permission is none.

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.