Skip to content

Repository files navigation

Pulse

Pulse

Real-time chat, built like a desktop app should be.

A production-grade messaging client for Windows, macOS and Linux — threads, reactions, presence, search and a command palette, wrapped in an interface that gets out of the way.

CI License: MIT Electron React TypeScript Socket.IO Tailwind CSS


The Pulse workspace: channel list, conversation with code blocks and reactions, and the member panel

Every screenshot in this README is a real render of the real app. They are captured by npm run screenshots, which boots the actual renderer against an in-memory workspace and photographs it at 2×. No mockups, no Figma exports, no drift.


Contents


Why Pulse

Most chat demos stop at "messages appear in a list". Pulse is built as the thing you would actually ship:

  • A real desktop application. Frameless window with custom chrome, a system tray, native notifications, an unread badge, a global summon hotkey and a hardened preload bridge — not a web page in a wrapper.
  • A typed contract, not string soup. Every event that crosses the wire is declared once in @pulse/shared and consumed by both the client and the gateway, so a protocol change breaks the build instead of production.
  • A transport you can swap. The React tree talks to a Transport interface. One implementation speaks Socket.IO; the other is an in-memory workspace. That is how this README stays honest and how the UI can be developed with no backend running.
  • No native dependencies. npm install works on a clean machine on all three platforms with no build tools, no Python, no compiler.

Features

💬 Conversations

  • Public, private and direct channels
  • Threaded replies with participant avatars
  • Emoji reactions with live counts
  • Edit, delete and copy any message
  • Markdown with syntax-highlighted code
  • @mention autocomplete and highlighting
  • Drag, drop or paste attachments
  • Image lightbox and file chips

⚡ Realtime

  • Socket.IO fan-out over WebSocket
  • Typing indicators with server-side expiry
  • Presence: active, away, do-not-disturb
  • Per-channel unread and mention counts
  • Optimistic send with reconcile-on-ack
  • Retry affordance when delivery fails
  • Exponential reconnect backoff with jitter
  • Full-text search across every channel

🖥️ Desktop

  • Frameless window with custom controls
  • System tray with quick status switching
  • Native notifications that deep-link
  • Taskbar / dock unread badge
  • Ctrl+Shift+Space to summon
  • Remembered window bounds and session
  • Light and dark themes, eight accents
  • Command palette over everything

The interface

Threads keep the main channel readable

Replies live in a side panel with the root message pinned above them, so a deep discussion never buries the rest of the conversation.

A thread panel open beside the main conversation, showing the root message and three replies


One palette for everything

Ctrl/+K searches channels, people, every message in the workspace, and the commands themselves — with subsequence matching, so dsy finds design-system.

The command palette showing matching channels and full-text message results


A light theme that is actually finished

Every colour is a semantic token, so the light theme is a genuine second design rather than an inverted afterthought. Toggle it with Ctrl/+Shift+L.

The same workspace rendered in the light theme


Preferences worth opening

The preferences dialog on the appearance tab, with theme cards, accent swatches and a live message preview


Direct messages

A direct message conversation

Sign in

The sign-in screen

Architecture

flowchart LR
    subgraph Desktop["Electron client"]
        direction TB
        Main["Main process<br/><small>window · tray · menu · notifications</small>"]
        Preload["Preload bridge<br/><small>contextIsolation, no nodeIntegration</small>"]
        Renderer["React renderer<br/><small>zustand · Tailwind · framer-motion</small>"]
        Main <--> Preload
        Preload <--> Renderer
    end

    subgraph Shared["@pulse/shared"]
        Contract["Domain model<br/>Event contract<br/>Constants"]
    end

    subgraph Gateway["Realtime gateway"]
        direction TB
        HTTP["Express<br/><small>auth · uploads · health</small>"]
        Socket["Socket.IO<br/><small>rooms · acks · presence</small>"]
        Store["JSON store<br/><small>atomic, debounced writes</small>"]
        HTTP --> Store
        Socket --> Store
    end

    Renderer -- "Transport interface" --> Socket
    Renderer -- "REST" --> HTTP
    Shared -.-> Renderer
    Shared -.-> Socket
Loading

The renderer never imports socket.io-client directly. It depends on a Transport interface with two implementations:

Implementation Used by Backed by
SocketTransport The app in normal use A live Socket.IO gateway
DemoTransport Showcase mode and README screenshots An in-memory, scripted workspace

Because both satisfy the same contract, the entire UI can be exercised — including typing indicators, incoming messages and unread badges — with nothing running but the renderer.


Getting started

git clone https://github.com/msdev290/Pulse.git
cd Pulse

npm run bootstrap                       # install + build the shared contract
cp apps/server/.env.example apps/server/.env

npm run dev                             # gateway on :4000, Electron opens automatically

Requires Node 20.11+. Nothing compiles natively, so a clean Windows, macOS or Linux machine needs no additional tooling.

On first boot the gateway seeds a complete demo workspace — eight members, nine channels and a conversation with threads, reactions and attachments — and prints the credentials:

demo login  msdev290@pulse.chat / pulse1234

The sign-in screen offers that account as a one-click button whenever seeding is enabled.

Just want to look at the UI?

npm run showcase                        # http://localhost:5174

Showcase mode runs the real renderer in a browser against DemoTransport. No gateway, no database, no account — teammates even type and post on a loop.


Project layout

Pulse/
├── apps/
│   ├── desktop/                  Electron client
│   │   ├── src/main/             window, tray, menu, IPC, settings
│   │   ├── src/preload/          the only bridge into the renderer
│   │   └── src/renderer/         React app
│   │       ├── components/       UI, grouped by surface
│   │       ├── lib/transport/    SocketTransport · DemoTransport
│   │       └── store/            zustand: app state and UI state
│   └── server/                   Express + Socket.IO gateway
│       ├── src/http/             auth, uploads, health
│       ├── src/realtime/         the Socket.IO gateway
│       └── src/store.ts          JSON persistence
├── packages/shared/              domain model + event contract
├── scripts/                      icons, demo assets, screenshots, smoke test
└── docs/images/                  generated — never hand-edited

Scripts

Command What it does
npm run bootstrap Install dependencies and build @pulse/shared
npm run dev Gateway and Electron client together, both hot-reloading
npm run showcase The renderer in a browser, backed by the demo workspace
npm run typecheck Strict TypeScript across every workspace
npm run smoke End-to-end check against a running gateway
npm run assets Regenerate icons and the demo attachments
npm run screenshots Recapture every README image at 2×
npm run dist:win Build a Windows installer (dist:mac, dist:linux also available)

The realtime protocol

Every client → server event is acknowledged with a discriminated union, so failures are values rather than silent no-ops:

export type Ack<T> = { ok: true; data: T } | { ok: false; error: string };

The full surface lives in packages/shared/src/index.ts:

Direction Events
client → server message:send · message:edit · message:delete · message:react · message:history · channel:create · channel:join · channel:openDirect · channel:markRead · typing:start · typing:stop · presence:set · search:messages · profile:update
server → client session:bootstrap · message:new · message:updated · message:deleted · channel:created · channel:updated · channel:read · presence:updated · user:joined · user:updated · typing:updated · server:notice

Rooms. Every socket joins channel:<id> for each visible channel and user:<id> for its own account. Fan-out goes to the channel room; per-user state such as unread counts goes to the user room, so a member signed in on two machines stays consistent.

Delivery. Sending renders an optimistic bubble keyed by a client nonce. The server echoes the canonical message carrying the same nonce, and the client reconciles in place — no duplicate, no flicker. If the ack never arrives the bubble is marked undelivered with a retry button.

Reconnects. Backoff is exponential from 500 ms to a 30 s ceiling with 50 % jitter applied from the first attempt, so a gateway restart does not summon a thundering herd.


Design system

Colour never appears as a literal in a component. index.css declares semantic tokens that Tailwind consumes through @theme inline:

:root,
[data-theme='dark'] {
  --canvas: #07070c;
  --surface: #0c0c14;
  --ink: #ececf4;
  --ink-2: #a3a3bb;
  --border: rgb(255 255 255 / 0.07);
}

[data-theme='light'] {
  --canvas: #f4f4f9;
  --surface: #ffffff;
  --ink: #14141f;
  /* … */
}

Switching data-theme or data-accent on <html> re-skins the entire application, which is why the theme toggle is instant and why adding an accent is a five-line change.

The surface treatment is deliberately restrained: a slow-drifting aurora field behind everything, glass panels with real backdrop blur, one accent gradient used sparingly for emphasis, and motion that is always spring-based and always interruptible.


Security

The renderer is treated as untrusted:

  • contextIsolation: true, nodeIntegration: false, webviewTag: false
  • A narrow, explicitly enumerated preload API — no generic invoke(channel, …) escape hatch
  • A restrictive Content-Security-Policy meta tag on the renderer document
  • External links and window-open requests are intercepted and handed to the OS browser
  • Passwords are bcrypt-hashed; sessions are signed JWTs; the gateway refuses to boot in production with the development signing key
  • Auth endpoints are rate-limited per IP; uploads are size-capped and filename-sanitised

Keyboard shortcuts

Shortcut Action
Ctrl/ + K Command palette
Ctrl/ + F Search every message
Ctrl/ + Alt + / Previous / next conversation
Ctrl/ + Shift + N Create a channel
Ctrl/ + Shift + D Start a direct message
Ctrl/ + Shift + L Toggle light / dark theme
Ctrl/ + Shift + C Toggle compact density
Ctrl/ + , Preferences
Ctrl/ + / Shortcut sheet
Ctrl/ + Shift + Space Summon Pulse from anywhere
Enter / Shift+Enter Send / newline
Esc Close the topmost surface

Building installers

npm run dist:win      # NSIS installer + portable .exe
npm run dist:mac      # .dmg for arm64 and x64
npm run dist:linux    # AppImage + .deb

Artifacts land in apps/desktop/release/<version>/. The package job in .github/workflows/ci.yml builds all three from a manual workflow dispatch.


Roadmap

  • Voice notes and inline audio playback
  • Message pinning and per-channel bookmarks
  • Offline queue that drains on reconnect
  • Postgres adapter behind the existing store interface
  • End-to-end encryption for direct messages
  • Auto-update via electron-updater

Contributing

Bug reports and pull requests are welcome — see CONTRIBUTING.md for the development setup and the expectations for a change.


License

MIT © msdev290


⬆ back to top

Built with Electron, React, TypeScript, Socket.IO and Tailwind CSS.

About

⚡ Pulse - Real time chatting desktop application, A production-grade messaging client for Windows, macOS and Linux - threads, reactions, presence, search and a command palette, wrapped in an interface that gets out of the way.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages