Skip to content

Instantly share code, notes, and snippets.

@arthyn
Last active March 9, 2026 22:33
Show Gist options
  • Select an option

  • Save arthyn/d790700b0da75721dca7934fd2db494f to your computer and use it in GitHub Desktop.

Select an option

Save arthyn/d790700b0da75721dca7934fd2db494f to your computer and use it in GitHub Desktop.
Tlon Presence & Availability System PRD

Presence & Availability System PRD

Working directory: ~/Projects/homestead
Author: ~nocsyx-lassul
Date: 2026-01-24
Status: Draft


Overview

A two-part presence system for Tlon that provides both ephemeral status indicators (typing, viewing, in-call) and persistent availability states (online, away, offline). Designed to feel alive while respecting user privacy through opt-out controls.

Goals

  1. Show when someone is typing in a chat
  2. Flexibility to have other indicators in the future (calls, viewing, listening, etc)
  3. Show general online/away/offline availability
  4. Track last-seen timestamps
  5. Allow users to opt-out (go unseen) at any level of either mechanism

System Architecture

Two complementary systems with distinct purposes:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          PRESENCE SYSTEM                            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚      EPHEMERAL (seconds)        β”‚      AVAILABILITY (hours)         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  %presence agent (NEW)          β”‚  %contacts agent (EXISTING)       β”‚
β”‚  β€’ typing indicators            β”‚  β€’ online/away/offline            β”‚
β”‚  β€’ viewing status               β”‚  β€’ last-seen timestamp            β”‚
β”‚  β€’ call/listening               β”‚  β€’ custom status message          β”‚
β”‚  β€’ local storage only           β”‚  β€’ profile field sync             β”‚
β”‚  β€’ routed via %chat/%groups     β”‚  β€’ direct profile queries         β”‚
β”‚  β€’ auto-timeout (seconds)       β”‚  β€’ user-controlled updates        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Part 1: %presence Agent (Ephemeral Status)

Purpose

Handle short-lived, context-specific presence like typing indicators. Local-only storage with network routing delegated to %chat and %groups.

Key Design Principle

%presence only accepts self-pokes. It never talks to the network directly.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Network Flow: Typing Indicator                                      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                      β”‚
β”‚  [Their Ship]                      [My Ship]                         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                   β”‚
β”‚  β”‚  %chat      β”‚ ──── network ──→  β”‚  %chat      β”‚                   β”‚
β”‚  β”‚  (sends)    β”‚                   β”‚  (validates)β”‚                   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜                   β”‚
β”‚                                           β”‚                          β”‚
β”‚                                           β”‚ local poke               β”‚
β”‚                                           ↓                          β”‚
β”‚                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                   β”‚
β”‚                                    β”‚  %presence  β”‚                   β”‚
β”‚                                    β”‚  (stores)   β”‚                   β”‚
β”‚                                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜                   β”‚
β”‚                                           β”‚                          β”‚
β”‚                                           β”‚ subscription update      β”‚
β”‚                                           ↓                          β”‚
β”‚                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                   β”‚
β”‚                                    β”‚  UI/Client  β”‚                   β”‚
β”‚                                    β”‚  (displays) β”‚                   β”‚
β”‚                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                   β”‚
β”‚                                                                      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

State Structure

+$  topic     @tas                              :: typing, viewing, call, listening
+$  context   path                              :: where (e.g. /chat/~zod/general, /call/~zod/room-1)
+$  status    [=topic =context expires=@da]
+$  presence  (map ship (map topic status))     :: ship β†’ topic β†’ status

Actions

+$  action
  $%  [%set who=ship topic=@tas context=path timeout=(unit @dr)]  :: set status (resets timeout)
      [%clear who=ship topic=@tas context=path]            :: explicitly clear
      [%clear-all context=path]                   :: clear all for context
      [%heartbeat ~]                              :: process expired statuses
  ==

Example Timeouts

Topic Timeout Description
typing 10s Typing indicator
call ∞* In voice call (clear on end)
listening 30s Listening to radio/audio

Subscription Paths

:: for the %presence agent
/v1/all                    :: firehose

:: optional but maybe useful? probably don't include until we need
/v1/[topic]/[...context]   :: all ships for topic in context (e.g. /v1/typing/chat/~zod/general)
/v1/ship/[ship]            :: all statuses for a specific ship
/v1/context/[...context]   :: all topics for a context

Integration Points

%chat integration: For DMs, we'd like want to add either a standalone poke, or modify DM actions to include a "typing" action so it would become something like:

:: ...
+$  diff
  $:  [%typing parent-id=(unit id)]  :: parent-id included when in a thread
      diff:writs
  ==
+$  action  (pair ship diff)
::  ...

Upon receiving the new typing action, %chat would then poke %presence:

=/  =action:presence  [%set src.bowl %typing /dm/~sampel-palnet `~s10]
(emit [%pass /presence %agent [our.bowl %presence] %poke presence-action+!>(action)])

%channels integration: Similar to %chat except that the channel host would be responsible for fanning out the updates. We would add a new command and corresponding update:

:: ...
+$  c-channel
  $:  ::  ...existing actions
      [%typing parent-id=(unit id-post)] :: parent-id included when in a thread
  ==
:: ...
+$  u-channel
  $:  ::  ...existing actions
      [%typing who=ship parent-id=(unit id-post)]
  ==

%channels-server simply turns the command into an update, applying the src.bowl to the who then sends through the normal distribution channel.

Upon receiving the new typing update, %channels would poke %presence

?>  ?=(%typing -.u-channel)
=/  =action:presence  [%set who.u-channel %typing nest.u-channels `~s10]
(emit [%pass /presence %agent [our.bowl %presence] %poke presence-action+!>(action)])

Files to Create

desk/
β”œβ”€β”€ app/presence.hoon        :: agent implementation
β”œβ”€β”€ sur/presence.hoon        :: types and structures
β”œβ”€β”€ mar/presence/
β”‚   β”œβ”€β”€ action.hoon          :: mark for actions
β”‚   └── update.hoon          :: mark for subscription updates
└── lib/presence.hoon        :: helper functions (optional)

Part 2: %contacts Availability (Persistent Status)

Purpose

Longer-lived availability states that sync between ships via existing profile infrastructure.

Key Design Principle

Leverage existing %contacts profile sync. No new agent neededβ€”just new fields.

New Profile Fields

Add to existing contact profile structure:

::  in sur/contacts.hoon, extend +$  contact
+$  contact
  $:  nickname=@t
      bio=@t
      status=@t               :: existing: custom status text
      avatar=(unit @t)
      cover=(unit @t)
      color=@ux
      ::  NEW FIELDS
      availability=@t         :: "online" | "away" | "offline" | "dnd" | ""
      last-seen=(unit @da)    :: when last active (null if private)
      presence-mode=@t        :: "visible" | "last-seen-only" | "hidden"
  ==

Availability States

State Meaning Display
online Active now 🟒 Green dot
away Idle/stepped away 🟑 Yellow dot
offline Not connected ⚫ Gray dot
dnd Do not disturb πŸ”΄ Red dot
"" Not sharing (opted out) No indicator

Profile Sync Flow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Availability Sync Flow                                             β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                     β”‚
β”‚  [My Ship]                        [Their Ship]                      β”‚
β”‚                                                                     β”‚
β”‚  Client sets availability         Profile query returns             β”‚
β”‚         β”‚                         availability + last-seen          β”‚
β”‚         ↓                                ↑                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”                   β”‚
β”‚  β”‚  %contacts  β”‚ ── profile ───→ β”‚  %contacts  β”‚                   β”‚
β”‚  β”‚  (my ship)  β”‚    sync         β”‚  (their)    β”‚                   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                   β”‚
β”‚                                                                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Automatic Updates

Client-side logic (not in agent):

// Pseudocode for client availability management
const IDLE_THRESHOLD = 5 * 60 * 1000;  // 5 minutes
const OFFLINE_THRESHOLD = 30 * 60 * 1000;  // 30 minutes

let lastActivity = Date.now();

function updateAvailability() {
  const idle = Date.now() - lastActivity;
  
  if (idle < IDLE_THRESHOLD) {
    setAvailability('online');
  } else if (idle < OFFLINE_THRESHOLD) {
    setAvailability('away');
  } else {
    setAvailability('offline');
  }
  
  // Also update last-seen unless hidden
  if (presenceMode !== 'hidden') {
    setLastSeen(Date.now());
  }
}

Part 3: Privacy & Opt-Out Settings

Privacy Philosophy

Needs work, this suggests %settings-store but maybe we'd want to store each in their respective agents instead?

Users should have granular control over what they share. Default to visible, but make opting out easy and respected.

Privacy Modes

+$  presence-mode
  $?  %visible         :: show availability + last-seen + ephemeral
      %last-seen-only  :: show last-seen only (no live status)
      %hidden          :: completely invisible
  ==

Per-Feature Toggles

In addition to the global mode, users can toggle individual features:

Setting Default Description
share-availability true Show online/away/offline
share-last-seen true Show when last active
share-typing true Send typing indicators
share-viewing true Show when viewing a channel
show-others-availability true See others' availability (mutual)
show-others-typing true See others' typing indicators

Mutual Visibility (Optional)

If implemented, users who opt out of sharing don't see others' status either:

If I hide β†’ I don't see others
If I share β†’ I see others (who share)

Settings Storage

Store in %settings-store under presence namespace:

/apps/groups/presence/share-availability
/apps/groups/presence/share-last-seen
/apps/groups/presence/share-typing
/apps/groups/presence/share-viewing
/apps/groups/presence/mode

UI for Privacy Settings

Location: Settings β†’ Privacy β†’ Presence

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Privacy β†’ Presence                                                 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                     β”‚
β”‚  Presence Mode                                                      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚ β—‹ Visible      Show everything                              β”‚   β”‚
β”‚  β”‚ β—‹ Last-seen    Only show when I was last active             β”‚   β”‚
β”‚  β”‚ β—‹ Hidden       Don't share any presence info                β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                                     β”‚
β”‚  Individual Controls (when not Hidden)                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚ [βœ“] Share typing indicators                                 β”‚   β”‚
β”‚  β”‚ [βœ“] Share viewing status                                    β”‚   β”‚
β”‚  β”‚ [βœ“] Share online/away/offline                               β”‚   β”‚
β”‚  β”‚ [βœ“] Share last-seen time                                    β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                                     β”‚
β”‚  Note: If you hide your presence, you won't see others' either.    β”‚
β”‚                                                                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Part 4: UI Integration

4.1 Typing Indicators

Chat input area:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ~sampel-palnet is typing...                                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  [                                                    ] [Send]      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Multiple people:

~sampel-palnet and ~master-botnet are typing...
~sampel-palnet and 2 others are typing...

Animation: Three bouncing dots (...) or subtle pulse

4.2 Channel Viewer List

Sidebar or header:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  #general                      πŸ‘ 3 viewing  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Viewing now:                                β”‚
β”‚  β€’ ~sampel-palnet                            β”‚
β”‚  β€’ ~master-botnet                            β”‚
β”‚  β€’ ~random-person                            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

4.3 Availability Indicators

In member lists and DMs:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  DIRECT MESSAGES               β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  β”‚ 🟒 ~sampel-palnet          β”‚  ← online
β”‚  β”‚ 🟑 ~master-botnet          β”‚  ← away
β”‚  β”‚ ⚫ ~random-person          β”‚  ← offline
β”‚  β”‚    ~hidden-person          β”‚  ← opted out (no dot)
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Profile card / DM header:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ~sampel-palnet                                                     β”‚
β”‚  🟒 Online β€’ Last seen: just now                                    β”‚
β”‚  Status: "Building presence features"                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

When hidden:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ~hidden-person                                                     β”‚
β”‚  Last seen: β€”                                                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

4.4 Voice/Call Integration

In channel when call active:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  #voice-chat                                     πŸ”Š 3 in call       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  In voice:                                                          β”‚
β”‚  β€’ ~sampel-palnet (speaking)                                        β”‚
β”‚  β€’ ~master-botnet                                                   β”‚
β”‚  β€’ ~random-person (muted)                                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Part 5: Implementation Plan

Phase 1: Foundation (Week 1-2)

Backend:

  1. Create %presence agent (app/presence.hoon, sur/presence.hoon)
  2. Add new fields to %contacts profile structure
  3. Implement %chat integration for routing presence pokes
  4. Add %settings-store entries for privacy settings

Frontend:

  1. Subscribe to %presence updates in relevant views
  2. Add typing indicator component to chat input
  3. Add availability dot component

Phase 2: Core UI (Week 3-4)

Backend:

  1. Add %groups integration for viewing status
  2. Implement heartbeat timer for expiring statuses
  3. Add privacy mode enforcement

Frontend:

  1. Typing indicators in chat view
  2. Availability dots in DM list and member lists
  3. Profile card availability display
  4. "X is typing" text above input

Phase 3: Settings & Privacy (Week 5)

Backend:

  1. Wire up privacy settings to filter what's shared/shown
  2. Implement mutual visibility logic (optional)

Frontend:

  1. Privacy settings UI in Settings β†’ Privacy
  2. Respect hidden users in UI (no placeholder, just absent)
  3. "X viewers" in channel header

Phase 4: Polish (Week 6)

  1. Animations for typing indicator
  2. Performance optimization (batch updates)
  3. Edge cases (reconnection, stale data)
  4. Mobile parity

Open Questions

  1. Mutual visibility: Should hiding your status also hide others from you? (Suggested: yes, for simplicity and fairness)

  2. Per-channel/group settings: Should users be able to hide presence in specific channels only? (Suggested: defer to v2)

  3. Bot/agent presence: Should Clawdbot and similar agents show typing? (Suggested: yes, opt-in for agents)

  4. Read receipts: Out of scope for this PRD, but related. Consider separately.

  5. Presence in public channels: Should viewing be visible in large public channels? (Suggested: show count only, not names, above threshold)


Success Metrics

  • Typing indicators appear within 500ms of user starting to type
  • Availability updates propagate within 5s
  • <1% false positives on typing (showing when not typing)
  • Privacy settings respected 100% of the time
  • No presence data leaks for hidden users

Appendix: Hoon Sketches

sur/presence.hoon

|%
+$  topic     ?(%typing %viewing %call %listening)
+$  context   path                                   :: flexible context path
+$  status    [=topic =context expires=@da]
+$  presence  (map ship (map topic status))
::
+$  action
  $%  [%set =topic =context]
      [%clear =topic =context]
      [%clear-all =context]
      [%heartbeat ~]
  ==
::
+$  update
  $%  [%set =ship =status]
      [%clear =ship =topic =context]
      [%full =presence]
  ==
::
+$  mode      ?(%visible %last-seen-only %hidden)
--

app/presence.hoon (skeleton)

/+  default-agent, dbug
|%
+$  versioned-state  $%(state-0)
+$  state-0  [%0 =presence:sur mode=mode:sur timeouts=(map topic:sur @dr)]
+$  card  card:agent:gall
--
%-  agent:dbug
^-  agent:gall
|_  =bowl:gall
+*  this  .
    def   ~(. (default-agent this %.n) bowl)
::
++  on-init
  ^-  (quip card _this)
  =/  defaults  (malt ~[[%typing ~s10] [%viewing ~m5] [%call *@dr] [%listening ~s30]])
  [~ this(state [%0 ~ %visible defaults])]
::
++  on-poke
  |=  [=mark =vase]
  ^-  (quip card _this)
  ?>  =(src.bowl our.bowl)  :: only self-pokes
  ?+  mark  (on-poke:def mark vase)
      %presence-action
    =/  act  !<(action:sur vase)
    ?-  -.act
        %set
      ::  update state, kick subscribers
      =/  expires  (add now.bowl (~(got by timeouts.state) topic.act))
      =/  new-status  [topic.act context.act expires]
      ::  ... update presence map, send updates
      [~ this]
    ::
        %clear
      ::  remove from state, kick subscribers
      [~ this]
    ::
        %clear-all
      ::  clear all for context
      [~ this]
    ::
        %heartbeat
      ::  expire old entries
      [~ this]
    ==
  ==
::
++  on-watch
  |=  =path
  ^-  (quip card _this)
  ?+  path  (on-watch:def path)
      [%presence %topic @ *]
    ::  /presence/topic/[topic]/[...context]
    [~ this]
  ::
      [%presence %ship @ ~]
    ::  /presence/ship/[ship]
    [~ this]
  ::
      [%presence %context *]
    ::  /presence/context/[...context]
    [~ this]
  ==
::
++  on-agent  on-agent:def
++  on-arvo
  |=  [=wire =sign-arvo]
  ^-  (quip card _this)
  ?+  wire  (on-arvo:def wire sign-arvo)
      [%heartbeat ~]
    ::  process timer, set next
    [~ this]
  ==
++  on-peek   on-peek:def
++  on-leave  on-leave:def
++  on-fail   on-fail:def
--

Related Work

  • Telegram: Online status + last seen + typing
  • Discord: Online/idle/dnd + typing + viewing voice
  • Slack: Active/away + typing + huddle presence
  • Signal: Typing only (privacy-focused)
  • WhatsApp: Online/last-seen + typing + read receipts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment