Full-featured cross-platform client for Mastodon, Bluesky, and Threads — crossposting, deck view, identity mapping, and more.
SocialTypeScript · SvelteGitHub @CrispStrobeOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Cross-platform Mastodon + Bluesky client with crossposting, identity mapping, and smart mentions. Web, desktop, and mobile.
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
Full-featured cross-platform client for Mastodon, Bluesky, and Threads with crossposting, identity mapping, and smart mentions.
Works as a web app (Vercel), desktop app (Windows/macOS/Linux via Tauri 2), and mobile app (iOS/Android via Tauri 2 mobile).
Live: https://crispdeck.vercel.app
Repo: https://github.com/CrispStrobe/CrispDeck
Changelog: CHANGELOG.md
Built with Tauri 2 + SvelteKit 2 + Svelte 5 + Rust + CrispASR (optional, for local translation/TTS/STT).Read the full README ↗Full-featured cross-platform client for Mastodon, Bluesky, and Threads with crossposting, identity mapping, and smart mentions.
Works as a web app (Vercel), desktop app (Windows/macOS/Linux via Tauri 2), and mobile app (iOS/Android via Tauri 2 mobile).
Live: https://crispdeck.vercel.app Repo: https://github.com/CrispStrobe/CrispDeck Changelog: CHANGELOG.md
Built with Tauri 2 + SvelteKit 2 + Svelte 5 + Rust + CrispASR (optional, for local translation/TTS/STT).
n keyboard shortcuta to add column, o to open, r to replyapp.bsky.bookmark.*) + Mastodon importapp.bsky.feed.postgateapp.bsky.actor.profile)did:web)The Threads API is significantly more limited than Bluesky/Mastodon. Here's what works and what doesn't:
| Feature | Status | Notes |
|---|---|---|
| OAuth login | Works | Server proxy, BYOK credentials, or manual token paste |
| View own posts | Works | Text, images, videos, carousels |
| Crosspost to Threads | Works | Text + media, 500-char limit, container-then-publish flow |
| Keyword search | Works | Requires threads_keyword_search permission |
| View reposts | Partial | Shows original author via permalink redirect; full content requires Advanced Access |
| View other users' posts | Limited | profile_posts endpoint requires Advanced Access (App Review) |
| Home timeline | Not available | Meta does not expose a feed/timeline API endpoint |
| DMs | Not available | No messaging API |
| Notifications | Not available | No notifications API |
| Like/reply/boost | Not available | No interaction API from third-party apps |
| Follow/unfollow | Not available | No social graph API |
| Streaming/real-time | Not available | No WebSocket or firehose |
Threads users' posts can also be read via Mastodon federation (@[email protected]) if the Threads user has opted into fediverse sharing.
/oauth/callback; in the app via a CrispDeck sign-in window or the system browser (no local port)prefers-reduced-motion across all animationscontent-visibility: auto on post cards, loading="lazy" + decoding="async" on images$derived computations properly memoized (no function-returning anti-patterns)$derived+-----------------------------------------------------------+
| SvelteKit Frontend (Svelte 5 runes, 29 pages) |
| +---------+---------+----------+---------+--------+ |
| | Feed | Compose | Identity | Deck | More...| |
| +---------+---------+----------+---------+--------+ |
| | | | | |
| +----v----------v--+ +---v----------v--------+ |
| | @atproto/api | | client-factory.ts | |
| | @atproto/oauth | | (OAuth + app-password) | |
| | masto (JS libs) | | Threads Graph API | |
| +------------------+ +---+---------------+---+ |
+----------------------------|---------------|---------------+
| |
+--------------v--+ +--------v-----------+
| Tauri (Desktop) | | Browser (Web/Vercel)|
| SQLite/rusqlite | | IndexedDB |
| AES-GCM+Argon2 | | Web Crypto AES-GCM |
| Rust strsim | | JS jaro-winkler |
| CrispASR (opt.) | | BYOK OpenAI / MyMem.|
| NMT/TTS/STT | | (translation only) |
+-----------------+ +--------------------+
| Layer | Technology |
|---|---|
| Desktop shell | Tauri 2.x |
| Frontend | SvelteKit 2 + Svelte 5 + Tailwind CSS 4 |
| Bluesky API | @atproto/api + @atproto/oauth-client-browser |
| Mastodon API | masto + direct REST |
| Threads API | Direct REST (official Graph API) |
| Database (desktop) | SQLite via rusqlite |
| Database (web) | IndexedDB |
| Encryption | AES-256-GCM (Argon2 on desktop, PBKDF2 on web) |
| Identity matching | strsim (Rust) / JS jaro-winkler (browser) |
| Translation (desktop) | CrispASR M2M-100 GGUF (optional --features crispasr) |
| Translation (web) | BYOK OpenAI-compatible / MyMemory free API |
| TTS (desktop) | CrispASR kokoro/vibevoice/qwen3-tts backends |
| STT (desktop) | CrispASR whisper/parakeet/qwen3-asr backends |
| Icons | @lucide/svelte |
| CI/CD | GitHub Actions (3 platforms) |
| Hosting | Vercel (static SPA) |
sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-devgit clone https://github.com/CrispStrobe/CrispDeck.git
cd CrispDeck
npm install
npm run dev
Opens at http://localhost:1420. Uses IndexedDB for storage — fully functional including Bluesky OAuth.
npm run tauri dev
# Web only (static SPA)
npm run build
# Desktop (platform-native installers)
npm run tauri build
The repo auto-deploys on push via GitHub integration. Manual deploy:
npx vercel deploy --prod
.github/workflows/pages.yml publishes a static mirror to
https://<owner>.github.io/<repo>/ on every push to main.
Vercel is the canonical deployment and the mirror is not equivalent, because
GitHub Pages serves static files only. Everything that talks straight to
Bluesky or Mastodon from the browser works — sign-in (including Bluesky OAuth),
timeline, feeds, posting. Everything backed by a function in api/ does not:
| Feature | Vercel | Pages |
|---|---|---|
| Bluesky + Mastodon sign-in, reading, posting | yes | yes |
Web push notifications (api/push) | not implemented | not implemented |
Publishing a custom feed (api/feed) | yes | yes, via Vercel |
Threads sign-in (api/threads token exchange) | yes | needs a registered callback |
Hosting a feed generator (api/xrpc) | yes | no |
The Pages build sets PUBLIC_API_ORIGIN so its /api/* calls go to the
Vercel deployment, and api/_lib/cors.ts allowlists the Pages origin so the
browser permits them. apiUrl() in src/lib/api-origin.ts is the single
place that decides; every other deployment leaves it empty and stays
same-origin.
Web push is deliberately not enabled. api/push/send.ts is a stub, and
the settings page correctly reports "VAPID keys not configured". Setting VAPID
keys would flip that to "Push notifications active" without any notification
ever being sent. Delivering a push while the app is closed requires a server
that can poll the user's networks, which requires their credentials leaving the
device — the opposite of what the privacy policy promises. Notifying while the
app is open already works and needs none of it.
Two things that still differ:
https://<owner>.github.io/<repo>/oauth/threads-callback registered as a
valid OAuth redirect URI in the Meta developer app. Meta rejects a callback
it has not been told about, and no amount of CORS changes that — it is a
console setting, not code.did:web origin and calls getFeedSkeleton there,
which requires a server. Feeds published from Pages are stored on Vercel and
served by Vercel, which is the working arrangement.A project site is served under /<repo>/ rather than at the origin root, so
the Pages build sets BASE_PATH and every internal link goes through base
from $app/paths (empty on Vercel, so nothing changes there). Comparisons run
the other way, through routePath() in src/lib/routes.ts, which strips the
mount point back off page.url.pathname.
Three static files hardcode an origin or a root path and are rewritten in the
CI checkout by scripts/mount-at-base.mjs — never committed, since the files
in the repository are the ones Vercel needs:
client-metadata.json — AT Protocol requires every redirect_uri to sit
under the same origin as client_id. Left pointing at Vercel, a sign-in
started on Pages would hand the session to the Vercel deployment instead.manifest.json — start_url, scope and id decide what the installed
PWA opens and which pages it owns.sw.js — the shell it precaches and its offline fallback.To serve the mirror from a custom domain at the root instead, set base to
"" and drop the mount-at-base step's --base argument.
npm test # frontend unit tests (incl. Mastodon sign-in against a stand-in instance)
npm run test:watch # watch mode
npm run test:e2e # Playwright E2E browser tests (requires build first)
# Ask the real sign-in servers whether they accept CrispDeck as deployed
node scripts/probe-oauth-servers.mjs # Bluesky PAR, no side effects
node scripts/probe-oauth-servers.mjs --mastodon fosstodon.org # also registers an app there
# Rust tests (requires CrispASR sibling checkout for --features crispasr)
cd src-tauri
cargo test # 5 unit tests (no CrispASR needed)
cargo test --features crispasr # 11 tests (registry, cache, config)
cargo test --features crispasr -- --ignored # 5 live translation tests (downloads m2m100 model)
Frontend unit tests hit real Bluesky/Mastodon APIs. E2E tests use Playwright with Chromium against the production build. Rust live tests download and run M2M-100 translation models via CrispASR.
src/
routes/ 29 pages: dashboard, feed, deck, compose, drafts, notifications,
messages, bookmarks, lists, feed-builder, starterpacks, identities,
search, gallery, calendar, reading-lists, trending, archive,
labelers, instance, moderation, analytics, settings, profile,
thread, catchup, about, oauth/callback, oauth/bsky-callback
lib/
api/ bluesky.ts, bluesky-oauth.ts, mastodon.ts, threads.ts, unified.ts, client-factory.ts
compose/ adapter.ts, thread.ts, mentions.ts, media.ts, ai.ts
components/ Post, CrosspostGroup, AdvancedFilters, AccountPicker,
MentionAutocomplete, EmojiPicker, GifPicker, KeyboardShortcuts, DeckColumn
utils/ export.ts (JSON, CSV, Markdown)
types.ts UnifiedPost, Account, Identity, Platform, Filters, etc.
db.ts Platform dispatcher (Tauri invoke or browser IndexedDB)
browser-db.ts IndexedDB implementation of all DB operations
platform.ts Tauri vs browser detection
streaming.ts StreamManager, Bluesky Jetstream + Mastodon WebSocket
keyword-monitor.ts Keyword matching + saved keyword sets for deck monitoring
byok-providers.ts 10 BYOK provider presets with /models discovery
universal-search.ts Cross-network search with merge/dedup
archive.ts Local post archive (IndexedDB)
bookmarks.ts Cross-platform bookmarks (IndexedDB)
templates.ts Post templates with variables (localStorage)
translate.ts Multi-provider translation (CrispASR / BYOK OpenAI / MyMemory)
hashtag-bank.ts Saved hashtag sets for compose
deck-layouts.ts Saved deck layouts / workspaces
i18n.svelte.ts TranslationService (Svelte 5 runes, 8 languages)
store.ts tauri-plugin-store settings wrapper
src-tauri/
src/
lib.rs AppState, plugin registration, 30+ command handlers
asr.rs CrispASR integration (translate, transcribe, synthesize)
db/ accounts, identities, crossposts, drafts, follows (SQLite CRUD)
auth/ credentials (AES-GCM), OS keychain secret store
commands/ db_commands, auth_commands, detect_commands (identity matching)
tests/ crispasr_integration.rs (registry + live translation tests)
migrations/ 001_initial.sql (7-table schema)
api/
feed/ Feed definition storage (publish.ts, unpublish.ts — Vercel Blob)
threads/ Threads OAuth proxy (auth-url.ts, token.ts)
xrpc/ Bluesky feed generator (getFeedSkeleton, describeFeedGenerator)
static/
.well-known/did.json Feed generator DID document (did:web:crispdeck.vercel.app)
client-metadata.json Bluesky OAuth client metadata
favicon.png App icon (192x192)
favicon.ico Legacy browser tab icon (16+32px)
icon-512.png PWA icon (512x512)
icon-1024.png Hi-res source icon (1024x1024)
apple-touch-icon.png iOS home screen icon (180x180)
icon-*-maskable.png Android adaptive icons (192+512)
manifest.json PWA manifest
sw.js Service worker (versioned cache)
scripts/
generate-icon.mjs Icon generator (Node.js canvas, source of truth)
generate-tauri-icons.mjs Resize to Tauri icon sizes
ci.yml): frontend tests, one shared production build and Playwright run,
release-script guards, and parallel Rust checks on Linux/macOS/Windows.mobile.yml): reusable, production-signed iOS + Android buildersrelease.yml): reusable Windows installers and Linux DEB.
macos-dmg.yml builds a signed, notarized universal DMG. web-release.yml
packages the static site; Vercel and Pages deploy from main.Coordinated releases build all six platforms from one source commit, retain native smoke evidence, verify signatures and hashes, and publish only after the complete package set passes. Run a build-only candidate with:
gh workflow run coordinated-release.yml --ref main -f publish=false
Tagging a new version starts publication mode. It requires Windows publisher signing, the retained Apple/Android identities and matching package/Tauri/Cargo versions. A published release cannot be replaced by this tool. Child builders no longer publish independently or trigger duplicate tag builds.
See Google Play setup for offline verification, store assets and the internal-track workflow.
See device validation for the live-account/physical-device matrix. TestFlight and App Store approval are separate from GitHub publication; a GitHub IPA is an App Store archive, not a sideload installer. Heavy compilation and emulators run in GitHub Actions.
Scheduling remains on-device and requires CrispDeck to stay open. No posting backend is enabled.
Mobile support uses Tauri 2's iOS/Android targets. The desktop-only plugins
(shell, process) sit behind the desktop feature, which is on by default.
# Generate the native project first (writes src-tauri/gen/, which is gitignored)
npx tauri ios init
npx tauri ios build
Note: tauri ios build / tauri android build take no --no-default-features
flag (checked against tauri-cli 2.11) — they accept only -f/--features. The
mobile build therefore still compiles the desktop feature. That builds and runs
fine, since tauri-plugin-shell and tauri-plugin-process both support mobile; to
genuinely drop them from a mobile binary, move those two crates into a
[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies]
table rather than relying on a CLI flag.
Sign-in returns to the app's own scheme, app.vercel.crispdeck: (the reverse of
crispdeck.vercel.app, which AT Protocol requires of a native client), via
tauri-plugin-deep-link; no local port is opened. On desktop the authorization
page runs in a CrispDeck sign-in window and Rust cancels the redirect before it
loads (auth_in_app_window in src-tauri/src/commands/auth_commands.rs);
Settings offers the system browser instead, which is the only option on mobile.
Bluesky's native client metadata is static/client-metadata-native.json, served
from crispdeck.vercel.app — its client_id is that URL, so it only works from
the production domain. Windows/Linux register the scheme at runtime; macOS,
iOS and Android from the bundle (the deep-link plugin's build script writes the
Android intent filter and iOS CFBundleURLTypes during mobile builds). macOS
only routes the scheme to a built .app, so test "System browser" with
npm run tauri build, not tauri dev.
AGPL-3.0 — free for commercial use, attribution required, derivatives must be open source.