← Back
In Depth

Adding Direct Messages to Our Slack Replacement

Adam Vartanian, Engineering Manager at CordAdam Vartanian
  • Engineering
A collection of colorful shapes

A while ago, we open-sourced our internal Slack replacement (which we call Clack). We’re happy to share that it now has direct messages (DMs) — a feature we built using the Cord SDK in about one engineer-day.

Let’s Be Direct

We’ve been all-in on Clack for general discussions for many months now, but we still used Slack for direct messaging because nobody had implemented it in Clack. It’s kind of annoying to have to switch between two different messaging apps depending on what kind of message you want to send, though, so I wanted to move that functionality into Clack.

Direct messages in a Slack-like product are simple to describe: it’s a series of messages between a group of users. You don’t have to give it a name or anything, you just choose which users you want to talk to and go. The most important thing is that the messages are private — nobody outside those users can see those messages.

I started coding up this feature on a Monday morning, and by lunchtime on Tuesday I had sent out the main PRs to implement it — and that wasn’t even the only thing I was working on. You can see the gory details in the two main PRs that implement the feature, or continue reading for the walkthrough.

Data Storage

The first step was to figure out how I was going to store which sets of users had an active conversation. Clack doesn’t have any data storage of its own — all the data is stored in Cord’s backend — so it needed to be stored in Cord’s data model. Fortunately, Cord has a perfect concept for keeping track of direct message conversations: groups.

Cord groups are how you control which users can interact with threads and other Cord objects. Since direct messages are always restricted to the participants, we would need to create a group for each direct message conversation. Because of that, we could create the groups on demand, and then use the existence of a group to say that a conversation exists. A user in Cord can be a member of any number of groups, so that’s no problem.

To make sure these groups are unique, even if two different users try to create a DM conversation with the same set of users, we use a deterministic ID scheme: take the user IDs, sort them alphabetically, join them with commas, and prefix it with dm:. So something like dm:flooey,josh,khadija. This means we can’t use commas in user IDs, but I know that Clack doesn’t do that; if it did, I could use a different separator.

Server Changes

Now that we know how we’re going to store these, let’s get going. We’ll start on the server, where there were two main changes needed. The first was in the handler that tells the client which channels the current user can use. In order to check which channels they can see, we already loaded the list of groups the user is in, so it was easy to scan those groups for any that were direct messaging groups and add those to the return value.

TypeScript
// We return the list of channels as { channelID: groupID_to_access }
groups
  .map((group) => group.toString())
  .filter((group) => group.startsWith('dm:'))
  .forEach((group) => {
    availableChannels[group] = group;
  });

The other change we needed to make was handling the creation of new direct messaging conversations. That’s also straightforward: just create the associated group in Cord and make sure the right users are in it.

This isn’t possible to do on the client. Cord’s client-side JS API doesn’t allow changing permissions, because then a user could grant themselves new permissions by using the browser’s developer tools. Only the administrative REST API can do it, so we use that here.

TypeScript
const result = await fetchCordRESTApi(
  `groups/${encodeURIComponent(channelName)}`,
  'PUT',
  JSON.stringify({
    name: channelName,
    members: extractUsersFromDirectMessageChannel(channelName),
  }),
);
res.send({ success: result.success });

And that’s all we needed to do on the server! On to the client…

Client Changes

The client changes were more involved, because we needed to present this information to the user. Most of the changes were bread-and-butter tasks that you’d do for any kind of new feature: adding the direct messaging conversations to the channel list, creating a dialog to choose the users for a new conversation, and that sort of thing. Here I want to highlight the Cord portions of the implementation.

The first thing we needed to do was render the list of channels and direct messages in the left sidebar. For normal channels, the display of a channel was its ID: our #music channel is at URL /channels/music and it shows up in the channel list as #music. However, as discussed above, a DM conversation has an ID of dm:user1,user2, but we don’t want the UI to display that as the name in the sidebar. What we want to display to the user is who is in that conversation, or more specifically, who else is in the conversation. Which means the name isn’t the same for everyone, but depends on which user is viewing it.

This required a little bit of refactoring, adding a separate name property to our Channel type. But it also required calling the Cord API to figure out the names of the users in the channel, since our channel ID only includes the user IDs.

TypeScript
const viewer = user.useViewerData();
const userData = user.useUserData(userIDsToQuery);

const channels: Channel[] = [];
for (const key in channelFetchResponse) {
  if (isDirectMessageChannel(key)) {
    const name = extractUsersFromDirectMessageChannel(key)
      .filter((userID) => userID !== viewer.id)
      .map((userID) => userData[userID].displayName)
      .join(', ');
    channels.push({ id: key, name, org: key });
  } else {
    channels.push({ id: key, name: key, org: channelFetchResponse[key] });
  }
}

Here we ask the JS API to load the viewer’s data and the data for every other user referenced in a DM conversation. Once all of those are loaded, we can transform the name of all the conversations into the list of other users in that conversation.

Finally, a little bit of an advanced feature. For chat channels, you wouldn’t want to be notified of every single message, because that would get spammy fast. But direct messages are usually much more important, and you want to see them right away, so we wanted users to be notified of every new message.

Luckily, this was also really easy to implement, especially in our new beta components. You can tell the Cord composer component to apply certain settings to newly created threads, including who should be subscribed to the thread. So all that’s necessary is to pass that to the Composer:

TypeScript
const createThreadOptions = useMemo(() => {
  return {
    // Other options to control name, location, URL, etc.
    ...(isDirectMessageChannel(channel.id) && {
      addSubscribers: extractUsersFromDirectMessageChannel(channel.id),
    }),
  };
}, [channel.id, channel.name, channel.org]);

...

<Composer createThread={createThreadOptions} />

And we’re done! We’ve finished adding private direct messaging to Clack.

Logging Off

This was a fun little side project, but direct messaging has already paid off by giving us another way to dogfood. And it was really straightforward to implement, since the Cord API handles a lot of the complexity for us. Check out the open source repo to see all the details on how Clack works or to give it a spin yourself!