OpenE2EE

Presence

The project policy, the account setting, the presence key, and the reads that tell a user when another user is online.

Presence tells a user of your app if another user is online now and when that user was last online. The OpenE2EE Signal Protocol Relay computes presence from the mailbox connections of each device. Your app sends no heartbeat, and the SDK reports no presence state. Presence is off in a new project.

For a project with presence on, Relay stores the setting of each account and a digest of its presence key. It also records when each device connects and closes, and the last time that it saw each device.

Online and last seen

A device is online while its mailbox socket is open. An account is online when one or more of its devices is online. The last seen of an account is the latest last-seen time of its devices.

Relay records the first open and the last close of each device at once. It sends each change to the presence directory of the project as it occurs. Relay does not delay or merge changes. A device that closes its socket and opens it again sends both changes. Relay records these changes only while the account is visible.

A device that loses its network sends no close. The SDK sends a ping every 30 seconds. Relay treats a socket as stale when its last answered ping is 75 seconds old, and closes it. The SDK gives up on a silent socket at 60 seconds and connects again, so the SDK acts first.

The directory keeps each device state in memory only. It uses an online state for 120 seconds after it last confirmed it. It uses an offline state for 15 minutes. After that time, a read asks the mailbox of the device again. When that request fails, the read uses the last state that the directory holds.

Thus a device that stops without a close can read as online for up to 120 seconds after the 75-second stale bound. A connect that Relay could not send to the directory is sent at the next wake of the mailbox. The 15-minute window limits how long such a device reads as offline.

Project policy

Each environment of a project has one presence policy with four fields. A project that never set a policy has presence off, the contacts audience, the exact last seen, and reciprocal on.

Presence sets who controls presence:

ChoiceWhat other users see
OffOther users never see when a user is online or when that user was last online.
On by defaultOther users see a user's presence until that user turns it off.
Off by defaultOther users see a user's presence only after that user turns it on.
Always onOther users always see a user's presence, and that user cannot turn it off.

Who sees it sets the audience:

ChoiceWhat other users see
ContactsOnly the contacts of a user see that user's presence.
Everyone in the projectAll other users in this project see a user's presence.

Last seen sets the precision of the last-seen time:

ChoiceWhat other users see
ExactOther users see the exact time a user was last online.
ApproximateOther users see only a range, such as within a week, for when a user was last online.
HiddenOther users see whether a user is online now but not when that user was last online.

Reciprocal sets what a hidden user sees:

ChoiceWhat other users see
OnA user who hides their presence also cannot see the presence of other users.
OffA user who hides their presence still sees the presence of other users.

The directory reads the policy again within 30 seconds of a change. A device learns the new mode at its next connect.

When the mode becomes Off, Relay deletes the setting of each account, each presence key digest, and each device row of the project. When presence is on again, each setting starts as unset, and each account must register its presence key again. Relay also deletes the presence rows of a deleted project and of a deleted account.

Console settings

Open the Relay project and select Settings. The page shows a Presence in Development card. When Production is active, it also shows a Presence in Production card. Each card sets the four fields of one environment. Only a member who can manage the project sees these cards.

Console shows no presence of a user. The device figures count the connections of devices, not the presence of accounts.

Account setting

Each account has one setting: unset, visible, or hidden. unset takes the default of the mode. The mode and the setting give the visibility that applies:

Modeunsetvisiblehidden
offhiddenhiddenhidden
default-onvisiblevisiblehidden
default-offhiddenvisiblehidden
forcedvisiblevisiblevisible

A mode change rewrites no setting. The same setting gives a new visibility under the new mode.

Under forced, a setting write fails with PRESENCE_SETTING_FORCED. Under off, a setting write fails with INVALID_TRANSITION.

Presence key

Under the contacts audience, a reader must present the presence key of each account that it reads. A contact is a user who holds the presence key of the account.

The SDK derives the presence key from the 32-byte profile key of the account. It uses HKDF-SHA-256 with an empty salt and the info open-e2ee-presence-key-v1, and it gives 16 bytes as 22 base64url characters. With the hosted.profileKeys option of createHostedSignalProtocolClient(), the SDK registers the key and exchanges it with each contact. Without the option, accessKey(profileKey) derives the key, registers it with Relay, and returns it. Send the key to each contact inside an end-to-end encrypted message. The contact stores it with grant(account, presenceKey).

The profile key never goes to Relay. Relay receives the presence key when the account registers it and when a contact reads. Relay stores only a digest of the key. A new profile key gives a new presence key. When the account registers it, each contact that holds only the old key reads null at once. Under off, a key registration fails with INVALID_TRANSITION.

Under the project audience, a read needs no key.

Reads

A read returns one result for each account, in the order given. A result is null, or { online, lastSeen }. The same null answers a hidden account, an unknown account, a missing or wrong key, and a project with presence off. A reader cannot tell these cases apart.

Under the contacts audience, an account that never registered a presence key reads null for all readers. When reciprocal is on, a hidden reader gets null for every account.

lastSeen follows the Last seen field:

  • Exact: milliseconds since the epoch, or null before the first connection of the account.
  • Approximate: recently within 3 days, within-week within 7 days, within-month within 30 days, and long-ago after that. An account with no last-seen time reads long-ago.
  • Hidden: always null.

Limits

One read request names 1 to 100 accounts. The SDK splits a longer list into requests of 100. Each device can send 30 read requests in each 60-second window. Relay refuses a request past the limit with QUOTA_EXCEEDED, and the error is retryable.

One watch request names 0 to 16 accounts. Each device can send 10 watch requests in each 60-second window, apart from its read requests. Relay refuses a watch past the limit with QUOTA_EXCEEDED, and the error is retryable. The SDK sends the watch again every 120 seconds, so most of the limit is free for changes of the watched accounts.

SDK

hostedRelayPresence(client) from /client returns the presence of a client from createHostedSignalProtocolClient(). Each request goes over the mailbox socket, so start the subscription first. A request without a connected socket fails with NOT_CONNECTED. The SDK does not send it over HTTP.

import { hostedRelayPresence } from '@open-e2ee/signal-protocol-sdk/client';

const presence = hostedRelayPresence(signal);
const policy = await presence.policy();
const account = await presence.setting();
const statuses = await presence.read([contactAccount]);
MemberWhat it does
policy()Returns the policy of the project: mode, audience, lastSeen, and reciprocal.
setting()Returns the setting of this account, its effective visibility, the mode, and keyRegistered.
setVisibility(visibility)Writes visible or hidden and returns the same fields as setting().
read(accounts)Reads the presence of each account.
watch(account, onChange)Calls onChange with the presence of the account, then again after each change. It returns a function that stops the watch.
accessKey(profileKey)Derives, registers, and returns the presence key of this account.
grant(account, presenceKey)Stores the presence key that a contact sent, on this device.

Name each account by the address that your app sends its messages to.

A failed request throws HostedRelayPresenceError with a code, a message, and retryable. The SDK adds four codes to the Relay codes:

CodeCause
NOT_CONNECTEDNo mailbox socket is live, or the socket closed before the answer.
TIMEOUTNo answer came in 10 seconds.
BUSYToo many requests wait for an answer.
FRAME_REJECTEDRelay closed the socket with code 1008 because it does not accept presence requests.

Watch

All watches of one client share one presence watch on the mailbox socket. watch() sends one presence-watch request for the first 16 watched accounts. It sends the request when the socket connects, at each change of these accounts, and again every 120 seconds. The answer gives the presence of each of these accounts. After the answer, Relay pushes a presence-update frame to the device at each change of a watched account. watch() calls the listener only when the presence changes.

The poll reads only the accounts past the first 16, every 30 seconds while the socket stays connected. Each poll sends one read request for each 100 of these accounts. A failed read keeps the last value, and the next poll reads again.

A watch fails when Relay refuses it or sends no answer in 10 seconds. After a failed watch, watch() reads every watched account every 30 seconds, and it sends the watch again after 120 seconds. A change of the first 16 accounts sends the watch at once. When a watch gets an answer, the poll again reads only the accounts past the first 16.

When grant() stores a new presence key for one of the first 16 accounts, watch() sends the watch again with the new key.

Relay pushes an update only to a device that may read the account, and it never pushes null. A device that can no longer read an account gets null at its next watch or read. Relay drops a watch that the device does not send again within 240 seconds. Relay does not guarantee the delivery or the order of pushed updates. The next update, watch, or read corrects the state.

A malformed presence-update frame makes the SDK connect again, and the new socket sends the watch again. A Relay that does not accept presence-watch closes the socket with code 1008. The SDK then reads after 30 seconds and sends the watch again after 120 seconds.

The watch goes only over the mailbox socket. The wake client and the HTTP carrier have no watch.

The watch and the poll stop while the socket is down. With the lifecycle binding, the socket closes in the background, so they stop there too. On the web, a hidden tab keeps its socket, and it continues to send its watch and to poll.

Wake client

A wake client has no mailbox socket. It runs in a push handler, a notification service extension, or a background task. Use hostedRelayWakePresence(client) beside pullHostedRelayAfterWake(). It has the same members without watch(), and it sends each request as one authenticated HTTP request. Neither carrier falls back to the other.

Move from app-managed presence

Many apps track presence on their own server. A typical hook marks the device online when its socket connects, sends a heartbeat every 10 seconds, and marks the device offline in the background. The server marks a device offline when its heartbeats stop, and a query returns the presence of a list of users. Relay replaces all of this with the socket edges. The steps below move such an app to Relay presence.

1. Split the connection hook

One hook often drives two different things: the connection of this device and the presence of other users. Give each its own source.

  • This device: use useRelayConnectionState({ signal }) from /hooks for the local connection indicator and the dot on your own avatar. Show the dot as online when state is connected. This state is local to this device. Do not use it for the dot of another user.
  • Other users: use presence.read(accounts) where the app showed the presence of a list of users. Use presence.watch(account, onChange) for the dot in an open chat.
import { useEffect, useState } from 'react';
import {
  hostedRelayPresence,
  type HostedRelayPresenceStatus,
} from '@open-e2ee/signal-protocol-sdk/client';
import { useRelayConnectionState } from '@open-e2ee/signal-protocol-sdk/hooks';

function OwnAvatarDot({ signal }) {
  const connection = useRelayConnectionState({ signal });
  return <Dot online={connection.state === 'connected'} />;
}

function ContactDot({ signal, contactAccount }) {
  const [status, setStatus] = useState<HostedRelayPresenceStatus | null>(null);
  useEffect(
    () => hostedRelayPresence(signal).watch(contactAccount, setStatus),
    [signal, contactAccount],
  );
  return <Dot online={status?.online === true} />;
}

2. Remove the heartbeat

Remove the heartbeat timer and every server write that marks a device online or offline. Remove the server timeout and the query that returned the presence of users. Relay records each device from its own socket, and a device that stops without a close goes stale after 75 seconds.

3. Bind the socket to the app state

Remove the app's own handler that stops the relay subscription in the background, and its AppState listener. Call useRelayLifecycle({ signal }) from /hooks one time in the component that owns the client. In the background, it closes the socket with code 1000, and Relay records the close and the last-seen time. When the app becomes active, it starts the subscription again. See bind the relay to the app state.

4. Exchange presence keys

Under the contacts audience, set the hosted.profileKeys option of createHostedSignalProtocolClient(). Give it getOwnProfileKey(), which returns the 32-byte profile key of the account, and a contacts store from /profile. Each device of an account must return the same profile key. Remove the app code that sent presence keys.

import { createHostedSignalProtocolClient } from '@open-e2ee/signal-protocol-sdk';
import { getOrCreateOwnProfileKey } from '@open-e2ee/signal-protocol-sdk/profile';

const signal = await createHostedSignalProtocolClient({
  adapters,
  hosted: {
    getIdentityAssertion,
    relayUrl: process.env.OPEN_E2EE_RELAY_URL!,
    profileKeys: {
      getOwnProfileKey: () => getOrCreateOwnProfileKey(),
      contacts, // your MutableContactProfileStateStore
    },
  },
});

With the option, the SDK does the exchange:

  • Each 1:1 message carries the profile key inside its end-to-end encrypted content. Before a 1:1 send to a contact that does not have the current key, the SDK sends one key-update message.
  • The receiving SDK keeps the profile key of the sender, derives the presence key, and calls grant(). No application hook gets the key update.
  • The SDK calls accessKey() when the socket connects and when the profile key changes. A new key revokes the old one, and the next message to each contact delivers the new key.
  • Group messages do not carry the key.

Only an app that does not set hosted.profileKeys exchanges the keys itself. Call accessKey() with the profile key of the account after sign-in. Send the key to each contact in an end-to-end encrypted message. When a key arrives from a contact, call grant(). Call accessKey() again when the profile key changes, and send the new key to each contact that must still see your presence. When setting() returns keyRegistered: false, register the key again.

import { getOrCreateOwnProfileKey } from '@open-e2ee/signal-protocol-sdk/profile';

const presenceKey = await presence.accessKey(await getOrCreateOwnProfileKey());
// Send presenceKey to each contact in an end-to-end encrypted message.

// When a contact's key arrives:
await presence.grant(contactAccount, contactPresenceKey);

5. Bind the activity status toggle

Bind the toggle for activity status to setVisibility(). Read policy() first:

  • off: hide the toggle. No user has presence.
  • forced: show the toggle as on and disable it. Tell the user that the app always shows presence.
  • default-on or default-off: enable the toggle. Show effective from setting() as its state.
const { mode } = await presence.policy();
const { effective } = await presence.setting();
showToggle({ hidden: mode === 'off', disabled: mode === 'forced', on: effective === 'visible' });

async function onToggle(on: boolean) {
  const next = await presence.setVisibility(on ? 'visible' : 'hidden');
  showToggle({ hidden: false, disabled: false, on: next.effective === 'visible' });
}

On this page