Compare commits

47 Commits

Author SHA1 Message Date
b52926f7d8 Fix system notifications ignoring mention-only and default room settings.
Skip non-highlight events for Mentions/Default non-DM rooms so public chats stay quiet unless they should ping.
2026-07-24 00:30:25 +10:00
d338e1c35e Show update progress as a title-bar bar instead of a popout. 2026-07-23 15:18:24 +10:00
9589680a81 Use paarrot://desktop SSO redirect so consent identifies the app. 2026-07-23 15:13:53 +10:00
27f1357fc0 Remove editor beforeinput autocorrect workaround. 2026-07-23 14:57:57 +10:00
9509a9705e Add Shared Media drawer and harden same-room navigation.
Widen the media panel, support skinny full-page mode, keep jump-to-latest and return-to-previous reliable, and stop same-room event jumps from remounting the outlet or yanking the sidebar.
2026-07-23 14:52:01 +10:00
61d41900cc Add Stationery and Stationery Dark themes with notebook chrome.
Manila folders, ruled chat, post-it nav, and folder tabs; dark variant uses Catppuccin Mocha colors with muted stickies and soft glows.
2026-07-23 00:24:10 +10:00
e7777f42d8 Fix jumbo emoji detection broken by production minifier.
Replace surrogate-pair emoji regex with Unicode property escapes so Rolldown no longer corrupts matching in production builds.
2026-07-22 20:28:59 +10:00
ae70eae0fc Fix jumbo emoji sizing in production builds.
Set jumbo font-size on the message body and inherit Text size so T400 no longer keeps emoji-only messages small.
2026-07-22 20:14:09 +10:00
c286501be8 Improve timeline scroll, media layout, avatars, and emoji coverage.
Remember room scroll position across switches, reserve image/video heights from Matrix dimensions, cache authenticated media blobs for avatars, restore jumbo emoji-only messages, and extend emoji font unicode-range for Unicode 15 glyphs.
2026-07-22 20:00:14 +10:00
154f4dfdb0 fix: stop emoji fonts claiming spaces and Latin text
Use Fontsource's real family name ('Inter Variable') so text no longer
falls through to Noto Color Emoji / Twemoji. Scope emoji @font-face to
emoji unicode-range so space metrics stay normal on Linux and elsewhere.
2026-07-15 03:19:54 +10:00
c7067021ba Fix white timestamps when Twemoji is selected
InterVariable did not match Fontsource's Inter Variable name, so digits
fell through to Twemoji COLR keycaps. Scope Twemoji off ASCII digits.
2026-07-13 02:40:03 +10:00
687f139719 Fix Slate crash after inserting emoji from picker/autocomplete 2026-07-13 02:40:02 +10:00
b711646b58 Fix mic processing toggles after LiveKit voiceIsolation default 2026-07-13 02:25:04 +10:00
bd6d753f47 update 2026-07-12 18:15:03 +10:00
3de3058ca9 Allow git deps in npm for Node/npm compatibility. 2026-07-12 08:18:57 +10:00
02d96a9758 hidden shit, and online status bars, search settings 2026-07-12 07:49:17 +10:00
5374c10c61 room shit 2026-07-10 21:35:38 +10:00
9c7f71896c fix: rework compact nav and centralized back routing
Use master-detail layout on skinny windows so server and channel lists share one screen, with back returning to the space list instead of redirect loops. Add useBackRoute for title-bar and page-header back buttons with view transitions.
2026-07-09 04:22:07 +10:00
b4258278d8 fix: restore rooms and DMs after React Router 7 upgrade
Stop double-encoding Matrix IDs in pathUtils, decode route params on read, fix millify CJS import, and read space children from currentState so channels and joins work again.
2026-07-09 02:35:04 +10:00
e058165830 feat: upgrade toolchain and soften sync recovery after network blips
Bump Vite 8, matrix-js-sdk, and related deps; fix immer/vanilla-extract imports for new majors. Replace blocking sync error dialog with background recovery on wake/offline, and disable Vite auto-open in dev. Add feature handoff documentation index.
2026-07-09 01:55:09 +10:00
f67f08f1db feat: Enhance space hierarchy management by implementing asynchronous room fetching and improving room handling logic 2026-07-06 16:06:50 +10:00
3ee95a295d feat: Implement forum feed layout and refactor space navigation logic to support new routing structure 2026-07-06 15:35:21 +10:00
09db721b7e refactor: Consolidate icon imports across components and streamline import statements for improved readability 2026-07-06 03:29:18 +10:00
1f71fd7a51 feat: Update plugin manager integration with lucide-react icons and improve RoomInput component logic 2026-07-06 01:30:25 +10:00
c57797ffe4 feat: Integrate forum layout support across various components and enhance room handling logic 2026-07-06 01:06:20 +10:00
d1626e3585 feat: Enhance protocol status handling and repair functionality in About and SSOLogin components 2026-05-25 01:01:05 +10:00
a4a0fa6758 feat: Add protocol status handling and repair functionality in About component 2026-05-25 00:34:13 +10:00
8de528da9a fix: Improve drag threshold handling in useInertialHorizontalScroll hook 2026-05-17 17:07:49 +10:00
ba31d65568 feat: Refactor Android share handling to dynamically load Capacitor core and improve plugin registration 2026-05-17 16:53:40 +10:00
c2fc71ddca feat: Refactor background sync to dynamically load Capacitor core and register plugin 2026-05-17 16:51:39 +10:00
40dc552c8d fix: Update import statement for Capacitor local notifications module ID 2026-05-17 16:48:34 +10:00
62876edd77 feat: Add carousel functionality with scrolling and image handling in RoomTimeline 2026-05-17 16:46:31 +10:00
9b81530012 feat: Lazy-load Capacitor local notifications to optimize runtime loading 2026-05-17 16:42:46 +10:00
af02b215f4 feat: Implement inertial horizontal scrolling for carousel components and enhance text selection handling 2026-05-17 16:42:46 +10:00
b9b0dce40b fix: Remove unnecessary size prop from Modal in ShareRoomPicker component 2026-05-13 11:12:14 +10:00
b2fd65c8cb feat: Enhance editor functionality with autocorrect handling and text replacement
feat: Implement confirmation dialog for room sharing in ShareRoomPicker

fix: Improve server input synchronization and blur handling in ServerPicker

feat: Integrate UnifiedPush for background sync in Android, including pusher management

refactor: Streamline Android share handling and file upload processes

fix: Update notification handling to use Capacitor's local notifications API
2026-05-13 10:40:30 +10:00
7ef9939d8a fix: Change Code component display style from inline-block to inline 2026-05-13 08:00:22 +10:00
43bd6320b8 Refactor code structure for improved readability and maintainability 2026-05-13 05:47:11 +10:00
aaf8089ba6 feat: Trim whitespace from message bodies and enhance emoji usage integration 2026-05-13 05:17:23 +10:00
38a43a3a99 feat: Add emoji usage tracking hook and integrate with EmojiBoard for usage statistics 2026-05-13 04:59:13 +10:00
4fdedb87c8 feat: Implement microphone monitoring feature; enhance text overflow handling and line height across components 2026-05-13 03:37:32 +10:00
5d4d518af8 feat: Add detailed logging for user profile components and image metadata extraction; enhance color handling utilities 2026-04-30 20:23:44 +10:00
cad4852878 feat: Enhance scrolling behavior and styles across components; add centralized scroll utilities
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:51:50 +10:00
e6af56a417 Merge branch 'main' of http://synbox.ruv.wtf:8418/litruv/cinny 2026-04-28 15:47:31 +10:00
ffec244113 feat: Refactor Android share and background sync utilities; streamline notification handling with dynamic imports 2026-04-28 15:46:19 +10:00
52abffb4c9 feat: integrate scroll to latest behavior setting into unread info calculation 2026-04-26 19:10:06 +10:00
ef0aa2a847 feat: Implement Android share functionality and background sync
- Add ShareRoomPicker component for selecting rooms to share Android intents.
- Introduce AndroidShare utility for handling shared files and payloads from Android.
- Implement background synchronization with UnifiedPush for Android notifications.
- Enhance notification handling in Tauri and Capacitor environments.
- Update TypeScript configuration for better module resolution.
- Create Capacitor configuration file for Android integration.
- Add SVG icon for notifications.
- Refactor media URL authentication checks in matrix utility.
2026-04-26 14:32:16 +10:00
425 changed files with 29474 additions and 9300 deletions

1
.npmrc
View File

@@ -1,2 +1,3 @@
legacy-peer-deps=true
save-exact=true
allow-git=all

BIN
build_output.txt Normal file

Binary file not shown.

12
capacitor.config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"appId": "com.paarrot.app",
"appName": "Paarrot",
"webDir": "dist",
"bundledWebRuntime": false,
"plugins": {
"LocalNotifications": {
"smallIcon": "ic_stat_paarrot",
"iconColor": "#FF8A00"
}
}
}

View File

@@ -1,8 +1,6 @@
{
"defaultHomeserver": 0,
"defaultHomeserver": 2,
"homeserverList": [
"ruv.wtf",
"exau.dev",
"converser.eu",
"envs.net",
"matrix.org",
@@ -11,26 +9,13 @@
"xmr.se"
],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:envs.net",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
"servers": []
},
"hashRouter": {

View File

@@ -105,6 +105,7 @@ interface PluginContext {
pluginId: string;
matrixClient: MatrixClient;
React: typeof import('react');
lucide: typeof import('lucide-react');
commands: CommandsAPI;
messages: MessagesAPI;
@@ -416,6 +417,8 @@ interface UIButtonDefinition {
location: UILocation;
label: string;
icon?: string;
/** Lucide icon name, e.g. `ImagePlay`. Also supported via `icon: 'lucide:ImagePlay'`. */
lucideIcon?: string;
position?: UIButtonPosition;
onClick?: () => void | Promise<void>;
}

112
docs/README.md Normal file
View File

@@ -0,0 +1,112 @@
# Paarrot documentation
Documentation for the Paarrot Matrix client (Cinny fork). Use this index to find feature handoffs, API references, and developer guides.
## Handoff docs (start here)
Per-feature handoff folders live under [`handoff/features/`](./handoff/features/). Each folder has a `HANDOFF.md` with architecture, key files, data model, and maintenance notes.
New feature? Copy [`handoff/TEMPLATE.md`](./handoff/TEMPLATE.md) into `handoff/features/<feature-name>/HANDOFF.md` and add a row below.
### Platform & shell
| Feature | Handoff |
|---------|---------|
| Electron desktop shell | [electron-shell](./handoff/features/electron-shell/HANDOFF.md) |
| Android (Capacitor/Tauri) | [android](./handoff/features/android/HANDOFF.md) |
| Client chrome (title bar, splash, welcome) | [client-chrome](./handoff/features/client-chrome/HANDOFF.md) |
| Local HTTP API | [local-api](./handoff/features/local-api/HANDOFF.md) |
| Stream Deck integration | [streamdeck](./handoff/features/streamdeck/HANDOFF.md) |
| Background notifications | [background-notifications](./handoff/features/background-notifications/HANDOFF.md) |
### Core client
| Feature | Handoff |
|---------|---------|
| Authentication | [authentication](./handoff/features/authentication/HANDOFF.md) |
| Matrix client (init, sync, providers) | [matrix-client](./handoff/features/matrix-client/HANDOFF.md) |
| State management (Jotai) | [state-management](./handoff/features/state-management/HANDOFF.md) |
| Routing & navigation | [routing-and-navigation](./handoff/features/routing-and-navigation/HANDOFF.md) |
| E2E encryption & devices | [e2e-and-devices](./handoff/features/e2e-and-devices/HANDOFF.md) |
### Navigation & panels
| Feature | Handoff |
|---------|---------|
| Home & sidebar | [home-and-sidebar](./handoff/features/home-and-sidebar/HANDOFF.md) |
| Room nav (channel rows) | [room-nav](./handoff/features/room-nav/HANDOFF.md) |
| Direct messages | [direct-messages](./handoff/features/direct-messages/HANDOFF.md) |
| Inbox & invites | [inbox-and-invites](./handoff/features/inbox-and-invites/HANDOFF.md) |
| Explore (public rooms) | [explore](./handoff/features/explore/HANDOFF.md) |
| Join before navigate | [join-before-navigate](./handoff/features/join-before-navigate/HANDOFF.md) |
### Messaging
| Feature | Handoff |
|---------|---------|
| Room timeline | [room-timeline](./handoff/features/room-timeline/HANDOFF.md) |
| Editor & composer | [editor-and-composer](./handoff/features/editor-and-composer/HANDOFF.md) |
| Message rendering (markdown, HTML) | [message-rendering](./handoff/features/message-rendering/HANDOFF.md) |
| Media & URL preview | [media-and-url-preview](./handoff/features/media-and-url-preview/HANDOFF.md) |
| Message search | [message-search](./handoff/features/message-search/HANDOFF.md) |
| Global search (quick nav) | [global-search](./handoff/features/global-search/HANDOFF.md) |
### Rooms, spaces & creation
| Feature | Handoff |
|---------|---------|
| Create flows (room/space/DM) | [create-flows](./handoff/features/create-flows/HANDOFF.md) |
| Room settings | [room-settings](./handoff/features/room-settings/HANDOFF.md) |
| Space settings | [space-settings](./handoff/features/space-settings/HANDOFF.md) |
| Common settings (shared pages) | [common-settings](./handoff/features/common-settings/HANDOFF.md) |
| Sub-rooms | [sub-rooms](./handoff/features/sub-rooms/HANDOFF.md) |
| Lobby (space card view) | [lobby-forums](./handoff/features/lobby-forums/HANDOFF.md) |
| Forum spaces (post feed UI) | [forum](./handoff/features/forum/HANDOFF.md) |
### Settings & customization
| Feature | Handoff |
|---------|---------|
| User settings | [user-settings](./handoff/features/user-settings/HANDOFF.md) |
| Themes & appearance | [themes-and-appearance](./handoff/features/themes-and-appearance/HANDOFF.md) |
| Emojis & stickers | [emoji-stickers](./handoff/features/emoji-stickers/HANDOFF.md) |
| Custom emoji packs | [custom-emoji-packs](./handoff/features/custom-emoji-packs/HANDOFF.md) |
| Avatar metadata | [avatar-metadata](./handoff/features/avatar-metadata/HANDOFF.md) |
| Embed filters | [embed-filters](./handoff/features/embed-filters/HANDOFF.md) |
| Developer tools | [developer-tools](./handoff/features/developer-tools/HANDOFF.md) |
### Real-time & extensions
| Feature | Handoff |
|---------|---------|
| Voice & video calls | [voice-calls](./handoff/features/voice-calls/HANDOFF.md) |
| Plugin system | [plugins](./handoff/features/plugins/HANDOFF.md) |
## API & plugin references
| Doc | Description |
|-----|-------------|
| [API.md](./API.md) | Full local HTTP API reference (port 33384) |
| [API-QUICKSTART.md](./API-QUICKSTART.md) | Quick start for the local API |
| [PLUGINS.md](./PLUGINS.md) | Plugin system user guide |
| [PLUGIN_API.md](./PLUGIN_API.md) | Full plugin API reference |
| [PLUGIN_BUTTON_API.md](./PLUGIN_BUTTON_API.md) | UI button injection API |
| [PLUGIN_SYSTEM_IMPLEMENTATION.md](./PLUGIN_SYSTEM_IMPLEMENTATION.md) | Internal plugin architecture |
Plugin development guide (in-app): [`src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md`](../src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md)
## Repo layout (docs-related)
```
cinny/docs/
README.md ← you are here
handoff/
README.md ← how to write handoff docs
TEMPLATE.md ← copy for new features
features/
<feature-name>/
HANDOFF.md
API.md ← reference docs (kept at root for links)
PLUGINS.md
...
```

53
docs/handoff/README.md Normal file
View File

@@ -0,0 +1,53 @@
# Handoff documentation
Handoff docs capture what a new maintainer needs to own a feature: behavior, architecture, data model, sharp edges, and how to verify changes.
## When to write one
Create or update a handoff when:
- A feature is Paarrot-specific (not vanilla Cinny upstream)
- The feature spans multiple layers (UI + Matrix + Electron/Android)
- You are about to hand work to another person or pause development
Inherited Cinny features (basic room timeline, auth flows, etc.) do not need handoffs unless Paarrot has materially changed them.
## Folder convention
```
handoff/features/<kebab-case-name>/
HANDOFF.md
```
Use short, product-facing names: `voice-calls`, `sub-rooms`, `local-api`.
## How to fill in a handoff
1. Copy [`TEMPLATE.md`](./TEMPLATE.md) to `features/<name>/HANDOFF.md`
2. Fill every section — delete "Add your extra things here" placeholders
3. Link to existing reference docs instead of duplicating them
4. Add the feature to the table in [`../README.md`](../README.md)
5. List real file paths (grep the codebase; do not guess)
## Sections explained
| Section | Purpose |
|---------|---------|
| **Summary** | One paragraph: what it does and why it exists |
| **User-facing behavior** | What users see and configure |
| **Architecture** | Layers, main modules, request/event flow |
| **Key files** | Paths a maintainer will touch first |
| **Data model** | Matrix state events, account data, local storage |
| **Dependencies** | External services, npm packages, OS APIs |
| **Integration points** | Other features, IPC, plugins, API |
| **Testing** | Manual steps and any automated tests |
| **Known issues** | Bugs, limitations, tech debt |
| **Related docs** | Links to API refs, READMEs, upstream specs |
## Keeping handoffs current
Update the handoff in the same PR when you:
- Add a new Matrix event type or account data key
- Change IPC or HTTP API contracts
- Move or rename core modules

67
docs/handoff/TEMPLATE.md Normal file
View File

@@ -0,0 +1,67 @@
# <Feature name> — handoff
> Copy this file to `handoff/features/<feature-name>/HANDOFF.md` and replace all placeholders.
## Summary
<!-- One paragraph: what this feature does and why Paarrot has it. -->
## User-facing behavior
<!-- What users see, where in the UI, and what settings exist. -->
## Architecture
<!-- High-level flow: UI → hooks/services → Matrix/Electron/OS. -->
```
<!-- Optional ASCII or mermaid diagram -->
```
## Key files
| Path | Role |
|------|------|
| `path/to/file` | <!-- what it does --> |
## Data model
<!-- Matrix state events, account data types, local storage keys. -->
| Key / event | Scope | Shape |
|-------------|-------|-------|
| | | |
## Dependencies
<!-- npm packages, external APIs, OS permissions. -->
## Integration points
<!-- Other features, local API, plugins, IPC channels. -->
## Testing
### Manual
1. <!-- step -->
### Automated
<!-- test files or "none yet" -->
## Known issues & gotchas
<!-- Sharp edges, race conditions, platform differences. -->
## Future work
<!-- Planned improvements or open questions. -->
## Related docs
- <!-- links -->
## Add your extra things here
<!-- Feature-specific notes: rollout flags, server requirements, MSC numbers, etc. -->

View File

@@ -0,0 +1,88 @@
# Android (Capacitor / Tauri) — handoff
## Summary
Paarrot ships an Android build via Capacitor and/or Tauri (`src-tauri/`, `cinny/capacitor.config.json`). Mobile-specific code handles native notifications, UnifiedPush background sync, share intents, and platform detection branching away from Electron APIs.
## User-facing behavior
- Native Android app with Paarrot UI (WebView)
- Share into Paarrot from other apps (images/files → room upload draft)
- Background notifications via UnifiedPush (not FCM-only)
- No local HTTP API on mobile (Electron-only)
## Architecture
```
Capacitor/Tauri shell
→ WebView loads cinny bundle
ClientNonUIFeatures.tsx
→ isCapacitorNative / isTauri branches
backgroundSync.ts → UnifiedPush + Matrix pusher
androidShare.ts → share intent → room draft
tauri.ts → native notification APIs
```
## Key files
| Path | Role |
|------|------|
| `cinny/capacitor.config.json` | Capacitor app id, web dir |
| `src-tauri/src/main.rs` | Tauri Android entry |
| `cinny/src/app/utils/tauri.ts` | Platform detection, notifications |
| `cinny/src/app/utils/backgroundSync.ts` | UnifiedPush registration |
| `cinny/src/app/utils/androidShare.ts` | Share target handling |
| `cinny/src/app/pages/client/ClientNonUIFeatures.tsx` | Share listener wiring |
## Data model
Same as background notifications (`paarrot.unifiedpush.*` localStorage).
Share payloads: ephemeral in-memory until user picks room.
## Dependencies
- Capacitor plugins / Tauri mobile toolchain
- UnifiedPush distributor (ntfy, etc.)
- Android notification icon resources
## Integration points
- **background-notifications**: primary overlap
- **Room input drafts**: `roomIdToUploadItemsAtomFamily` for shared files
- **NOT Electron**: `initPaarrotAPI` skips on mobile webview
## Testing
### Manual
1. Build Android APK/AAB per project scripts.
2. Share image from Gallery → Paarrot → pick room → verify upload draft.
3. Background app; trigger mention; verify UP notification.
4. Tap notification → opens correct room.
### Automated
- None on device CI in repo (check Gitea workflow)
## Known issues & gotchas
- `ANDROID_NOTIFICATION_SMALL_ICON = 'ic_stat_paarrot'` must exist in Android res
- UnifiedPush without distributor shows user-facing error string
- Feature parity gap vs desktop (plugins, Stream Deck, local API)
- Test `isTauri` vs `isCapacitorNative` — both may be true in some builds
## Future work
- iOS Capacitor target
- Play Store release pipeline docs
- Mobile plugin sandbox policy
## Related docs
- [background-notifications HANDOFF](../background-notifications/HANDOFF.md)
## Add your extra things here
- `listenForAndroidShares`, `getPendingAndroidShare`, `materializeSharedFile` in `androidShare.ts`
- Capacitor app name in `capacitor.config.json` — verify `appId` matches push config

View File

@@ -0,0 +1,97 @@
# Authentication — handoff
## Summary
Auth covers login, registration, password reset, SSO, and server selection before the Matrix client starts. Sessions are persisted locally and restored on app launch via `ClientRoot`.
## User-facing behavior
- Pick homeserver (ServerPicker) or use default from config
- Login: password, token, SSO redirect
- Register: password-based with optional email flows
- Reset password flow
- Auth layout shows Paarrot branding
- Device display name defaults to `Paarrot`
## Architecture
```
Unauthenticated routes (AuthLayout)
→ Login / Register / ResetPassword / SSOLogin
→ session written to localStorage (sessions.ts)
ClientRoot (authenticated)
→ initClient(session) → startClient(mx)
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/pages/auth/login/Login.tsx` | Login hub |
| `cinny/src/app/pages/auth/login/PasswordLoginForm.tsx` | Password login |
| `cinny/src/app/pages/auth/login/TokenLogin.tsx` | Access token login |
| `cinny/src/app/pages/auth/SSOLogin.tsx` | SSO redirect |
| `cinny/src/app/pages/auth/register/Register.tsx` | Registration |
| `cinny/src/app/pages/auth/register/PasswordRegisterForm.tsx` | Password register |
| `cinny/src/app/pages/auth/reset-password/ResetPassword.tsx` | Password reset |
| `cinny/src/app/pages/auth/ServerPicker.tsx` | Homeserver selection |
| `cinny/src/app/pages/auth/AuthLayout.tsx` | Auth chrome + logo |
| `cinny/src/app/pages/auth/login/loginUtil.ts` | Login helpers |
| `cinny/src/app/pages/auth/register/registerUtil.ts` | Register helpers |
| `cinny/src/app/state/sessions.ts` | Session storage |
| `cinny/src/client/initMatrix.ts` | Client init from session |
| `cinny/src/app/pages/afterLoginRedirectPath.ts` | Post-login redirect |
## Data model
| Key | Storage | Content |
|-----|---------|---------|
| Session store | localStorage | access token, user id, device id, hs url |
| `setAfterLoginRedirectPath` | sessionStorage/memory | deep link target after login |
## Dependencies
- `matrix-js-sdk` login/register APIs
- Homeserver `.well-known` / login flows (`useParsedLoginFlows`, `useAuthFlows`)
- OIDC/SSO via redirect URLs
## Integration points
- **Router**: unauthenticated vs `ClientRoot` split
- **matrix-client**: `initClient` consumes session
- **Device verification**: post-login verification UI in router
## Testing
### Manual
1. Login with password on test homeserver.
2. Token login with valid access token.
3. SSO flow if server supports it.
4. Register new account; verify email if required.
5. Logout clears session and returns to login.
### Automated
- None
## Known issues & gotchas
- `initial_device_display_name: 'Paarrot'` hardcoded in login/register forms
- Session logout from server triggers full localStorage clear + reload
- Server picker URL must match actual homeserver capabilities
## Future work
- Multi-account without full reload (see account-switcher)
- Passkey / WebAuthn if upstream adds support
## Related docs
- [matrix-client HANDOFF](../matrix-client/HANDOFF.md)
- [e2e-and-devices HANDOFF](../e2e-and-devices/HANDOFF.md)
## Add your extra things here
- Auth routes: `LOGIN_PATH`, `REGISTER_PATH`, `RESET_PASSWORD_PATH` in `paths.ts`
- `UnAuthRouteThemeManager` vs `AuthRouteThemeManager` in ThemeManager.tsx

View File

@@ -0,0 +1,92 @@
# Avatar metadata — handoff
## Summary
Paarrot embeds profile accent metadata **inside image files** (PNG, JPEG, WebP, GIF) so avatar colors, banners, gradients, and border colors travel with the MXC upload. Other Paarrot clients read this metadata to theme profile UI without extra Matrix account data fields.
## User-facing behavior
- **Settings → Account → Profile**: pick accent color (and related styling) applied to avatar image on save
- Profile viewers extract color/banner from avatar HTTP URL for themed headers
- Works across image formats users commonly upload
## Architecture
```
Profile settings UI
→ embedMetadata / embedColor (imageMetadata.ts)
→ format-specific: pngMetadata, jpegMetadata, webpMetadata, gifMetadata
→ upload to Matrix
Other clients / Profile.tsx
→ extractMetadataFromImage(fetch avatar)
→ apply CSS variables / banner URL
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/utils/imageMetadata.ts` | Unified embed/extract API |
| `cinny/src/app/utils/pngMetadata.ts` | PNG tEXt chunks |
| `cinny/src/app/utils/jpegMetadata.ts` | JPEG XMP APP1 |
| `cinny/src/app/utils/webpMetadata.ts` | WebP XMP |
| `cinny/src/app/utils/gifMetadata.ts` | GIF application extension |
| `cinny/src/app/features/settings/account/Profile.tsx` | Settings UI + upload pipeline |
## Data model
Metadata keys (embedded in binary image, not Matrix):
| Key | Format | Notes |
|-----|--------|-------|
| `paarrot:color` | All | Accent color hex |
| `paarrot:banner` | PNG (+ unified API) | Banner image URL |
| `paarrot:borderColor` | PNG | Border accent |
| `paarrot:gradient` | PNG | Gradient spec |
XMP namespace: `http://paarrot.app/ns/1.0/`
## Dependencies
- Browser `ArrayBuffer` / `Uint8Array` parsing
- No server-side processing — client-only before upload
## Integration points
- **Profile display**: user profile cards read metadata from avatar URL
- **Image upload flow**: must re-encode image with metadata before `uploadContent`
## Testing
### Manual
1. Set profile color in settings; save avatar.
2. Download avatar MXC; verify metadata with hex dump or Paarrot on second account.
3. Repeat for PNG, JPEG, WebP, GIF uploads.
4. Remove color; verify strip functions work.
### Automated
- None — good candidate for unit tests on chunk parsers
## Known issues & gotchas
- Metadata stripped if user uploads through non-Paarrot client
- Large images: re-encoding memory cost on mobile
- Console logging in `pngMetadata.ts` (`extractColorFromPNG`) — noisy in prod
- Federation: only Paarrot-aware clients interpret metadata
## Future work
- Unit tests per format parser
- Fallback to account data if metadata missing
- Document binary spec in standalone spec doc
## Related docs
- None
## Add your extra things here
- Entry points: `extractPaarrotMetadata`, `embedPaarrotMetadata`, `embedPaarrotColor` in `imageMetadata.ts`
- Profile comment: "Stored in avatar image metadata, visible to other Paarrot users"

View File

@@ -0,0 +1,95 @@
# Background notifications — handoff
## Summary
Paarrot keeps users notified of Matrix activity when the app is in the background: desktop tray/favicon badges, system notifications (Electron/Tauri/Android), and Android **UnifiedPush** for reliable mobile push without Google FCM dependency.
## User-facing behavior
- Unread counts update favicon (Paarrot / unread / highlight variants)
- System notifications for mentions/DMs per notification settings
- Android: requires UnifiedPush distributor (e.g. ntfy); user prompted if missing
- Foreground/background state affects sync strategy
## Architecture
```
ClientNonUIFeatures.tsx
→ FaviconUpdater (roomToUnread atom)
→ notification listeners (tauri.ts sendNotification)
→ initPaarrotAPI + background sync lifecycle
backgroundSync.ts
→ UnifiedPush registration, pusher setup
→ setAppForegroundState / startBackgroundSync / stopBackgroundSync
tauri.ts / electron main
→ OS notification APIs, Android small icon ic_stat_paarrot
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/pages/client/ClientNonUIFeatures.tsx` | Favicon, notifications, API init, background sync hooks |
| `cinny/src/app/utils/backgroundSync.ts` | Pushers, UnifiedPush, foreground state |
| `cinny/src/app/utils/tauri.ts` | Tauri/Capacitor notification helpers |
| `cinny/src/app/features/settings/notifications/SystemNotification.tsx` | Permission UX |
| `electron/main.js` | Tray, notification IPC (if applicable) |
| `src-tauri/src/main.rs` | Tauri Android shell |
## Data model
| Key | Storage | Purpose |
|-----|---------|---------|
| `paarrot.unifiedpush.*` | localStorage prefix | UP endpoint state |
| `com.paarrot.app.android` | Pusher app id base | Android pushers |
| Matrix pusher | Homeserver | HTTP push gateway |
## Dependencies
- UnifiedPush distributor app on Android
- Notification permission (browser/OS)
- Homeserver pusher support
- `matrix-js-sdk` sync
## Integration points
- **Settings → Notifications**: rules + system permission
- **DirectTab / sidebar**: custom Paarrot SVG favicon states
- **Deep links**: notification tap → `setupNotificationTapListener`
## Testing
### Manual
1. Desktop: background app; send mention; verify system notification.
2. Verify favicon switches unread/highlight with counts.
3. Android: install ntfy; verify push when app killed.
4. Toggle notification settings; verify respect mute rules.
### Automated
- None
## Known issues & gotchas
- UnifiedPush user education string in `backgroundSync.ts` if no distributor
- Multiple runtimes: `isTauri`, `isElectron`, `isCapacitorNative` branch logic
- Pusher registration races on fast login/logout
- Android small icon: `ic_stat_paarrot` must exist in Android resources
- Background wake (`MatrixSyncService`) must filter by push rules / unread_notifications; a push for one room used to notify for every new message in the sync batch
## Future work
- iOS push strategy
- Notification action buttons (reply, mark read)
- Per-room notification channels on Android
## Related docs
- Matrix push gateway docs (upstream)
## Add your extra things here
- Constants: `PUSHER_APP_ID_BASE`, `PUSHER_STORAGE_PREFIX` in `backgroundSync.ts`
- Logo assets: `paarrot.svg`, `paarrot-unread.svg`, `paarrot-highlight.svg` in `public/res/svg/`
- Android notify filter: mute / mentions / default-room behavior lives in `MatrixSyncService.resolveRoomNotifyMode`

View File

@@ -0,0 +1,95 @@
# Client chrome — handoff
## Summary
App shell UI outside core chat: custom title bar (Electron), splash screen, welcome page, account switcher, update notifications, sync status, spec version warnings, and feature capability checks.
## User-facing behavior
- **Title bar**: drag region, window controls, room title, account menu (desktop)
- **Splash**: loading spinner during client init
- **Welcome page**: first-run / empty state hints
- **Account switcher**: switch between logged-in accounts (if multi-session)
- **Update notification**: new version available (electron-updater)
- **Feature check**: block unsupported browsers
- **Spec versions**: warn on old homeserver
## Architecture
```
ClientRoot → TitleBar + AccountSwitcher + children
SplashScreen during matrix init
WelcomePage on empty states
UpdateNotification component
FeatureCheck wraps App
SpecVersions in ClientRoot
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/components/title-bar/TitleBar.tsx` | Desktop title bar |
| `cinny/src/app/components/splash-screen/SplashScreen.tsx` | Splash |
| `cinny/src/app/pages/client/WelcomePage.tsx` | Welcome / download links |
| `cinny/src/app/components/account-switcher/AccountSwitcher.tsx` | Multi-account |
| `cinny/src/app/components/update-notification/UpdateNotification.tsx` | Auto-update UI |
| `cinny/src/app/pages/FeatureCheck.tsx` | Browser capability gate |
| `cinny/src/app/pages/client/SpecVersions.tsx` | HS version warning |
| `cinny/src/app/pages/client/SyncStatus.tsx` | Sync indicator |
| `cinny/src/app/pages/ConfigConfig.tsx` | Config load error UI |
| `cinny/src/app/components/ClientConfigLoader.tsx` | Load config.json |
## Data model
| Key | Storage |
|-----|---------|
| Sessions | multi-account in sessions.ts |
| electron-store | window bounds (main process) |
## Dependencies
- Electron IPC for window controls
- `electron-updater` for updates
- `clientConfig` JSON (homeserver default, hash router, etc.)
## Integration points
- **electron-shell**: title bar IPC
- **matrix-client**: splash until sync ready
- **authentication**: account switcher sessions
## Testing
### Manual
1. Desktop: minimize/maximize/close via title bar.
2. Slow network: splash shows until ready.
3. Trigger update check in dev (if configured).
4. Unsupported browser: FeatureCheck message.
### Automated
- None
## Known issues & gotchas
- Title bar only on Electron — web uses browser chrome
- Welcome page links point to GitHub releases — verify URLs match your distribution (Gitea vs GitHub)
- `FeatureCheck` may block older WebViews on Android
## Future work
- Unified status bar (sync + update + call status)
- macOS traffic lights styling
## Related docs
- [electron-shell HANDOFF](../electron-shell/HANDOFF.md)
- [matrix-client HANDOFF](../matrix-client/HANDOFF.md)
- [authentication HANDOFF](../authentication/HANDOFF.md)
## Add your extra things here
- Title bar shows `Paarrot` prefix + dynamic room/space title suffix
- `ClientConfigLoader` reads public config — document config.json schema in client config handoff if split later

View File

@@ -0,0 +1,82 @@
# Common settings — handoff
## Summary
Shared setting pages used by both room and space settings overlays: general (profile, join rules, encryption, embed filters), members, permissions editors, emojis/stickers, and developer tools (raw state events).
## User-facing behavior
- Same UI whether editing a room or space context
- Members list with kick/ban/invite
- Power level editor
- Room emoji packs
- Developer tools: send custom state events
## Architecture
```
common-settings/
general/ — RoomProfile, RoomJoinRules, RoomEncryption, EmbedFilters
members/ — Members.tsx
permissions/ — PowersEditor, PermissionGroups
emojis-stickers/
developer-tools/ — StateEventEditor, SendRoomEvent
Imported by room-settings and space-settings shells
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/common-settings/general/RoomProfile.tsx` | Name/topic/avatar |
| `cinny/src/app/features/common-settings/general/RoomJoinRules.tsx` | Join rules |
| `cinny/src/app/features/common-settings/general/RoomEncryption.tsx` | E2E toggle |
| `cinny/src/app/features/common-settings/general/EmbedFilters.tsx` | Room-wide embed filters |
| `cinny/src/app/features/common-settings/members/Members.tsx` | Member management |
| `cinny/src/app/features/common-settings/permissions/PowersEditor.tsx` | Power levels |
| `cinny/src/app/features/common-settings/developer-tools/` | Dev event tools |
## Data model
Standard Matrix room/space state events. Embed filters: `im.paarrot.room.embed_filters`.
## Dependencies
- Permission hooks, member list hooks
## Integration points
- **room-settings** / **space-settings**: parent shells
- **embed-filters**: room-wide pattern UI
- **emoji-stickers**: room pack management
## Testing
### Manual
1. Open members from room settings; invite user.
2. Set embed filter pattern as admin.
3. Developer tools: send test state event (staging only).
### Automated
- None
## Known issues & gotchas
- `DevelopTools` naming typo inherited from upstream
- Encryption enable is irreversible — UI must warn
## Future work
- Extract embed filters to own settings section
## Related docs
- [room-settings HANDOFF](../room-settings/HANDOFF.md)
- [space-settings HANDOFF](../space-settings/HANDOFF.md)
- [embed-filters HANDOFF](../embed-filters/HANDOFF.md)
## Add your extra things here
- `PermissionGroups.tsx` — grouped permission UI

View File

@@ -0,0 +1,88 @@
# Create flows — handoff
## Summary
UI for creating rooms, spaces, direct chats, and adding existing rooms to spaces. Includes modal renderers mounted at client root and join/create page routes.
## User-facing behavior
- Create room modal (optionally as sub-room of parent)
- Create space modal
- Create DM / group DM
- Add existing room to space
- Create tab in sidebar for quick actions
## Architecture
```
CreateRoomModalRenderer → CreateRoomModal.tsx
CreateSpaceModalRenderer → CreateSpaceModal.tsx
create-chat/ — DM creation
add-existing/ — pick room to add to space
pages/client/create/Create.tsx — create hub
pages/client/direct/DirectCreate.tsx
State: createRoomModal.ts, createSpaceModal.ts
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/create-room/CreateRoomModal.tsx` | Room create + sub-room parent update |
| `cinny/src/app/features/create-space/CreateSpaceModal.tsx` | Space create |
| `cinny/src/app/features/create-chat/` | DM flows |
| `cinny/src/app/features/add-existing/AddExisting.tsx` | Add to space |
| `cinny/src/app/features/create-room/CreateRoom.tsx` | Standalone create page |
| `cinny/src/app/features/create-space/CreateSpace.tsx` | Standalone space create |
| `cinny/src/app/state/createRoomModal.ts` | Modal state + parent room id |
| `cinny/src/app/state/createSpaceModal.ts` | Space modal state |
| `cinny/src/app/pages/client/create/Create.tsx` | Create hub route |
| `cinny/src/app/pages/client/home/CreateRoom.tsx` | Home create entry |
## Data model
Creates standard Matrix rooms/spaces. Sub-room creation also updates `im.paarrot.sub_rooms` on parent.
## Dependencies
- matrix-js-sdk `createRoom` APIs
- Space parent/child events
## Integration points
- **sub-rooms**: parent id in create room modal
- **room-nav**: opens create sub-room modal
- **routing-and-navigation**: Create routes, modal renderers
## Testing
### Manual
1. Create public room; verify join.
2. Create sub-room under parent; appears in home tree.
3. Create space; add existing room.
4. Start DM from DirectCreate.
### Automated
- None
## Known issues & gotchas
- Sub-room failure after room created leaves orphan — see sub-rooms handoff
- `CreateRoomKindSelector` for room type variants
- Forum room type may need specific create options
## Future work
- Create forum space template
- Batch add rooms to space
## Related docs
- [sub-rooms HANDOFF](../sub-rooms/HANDOFF.md)
- [routing-and-navigation HANDOFF](../routing-and-navigation/HANDOFF.md)
## Add your extra things here
- `useOpenCreateSubRoomModal` hook opens modal with `parentRoomId`

View File

@@ -0,0 +1,87 @@
# Custom emoji packs — handoff
## Summary
Built-in (non-plugin) custom emoji system: read image packs from room state, resolve shortcodes to images in messages and autocomplete. Distinct from user-installed JS plugins but shares pack format concepts.
## User-facing behavior
- Room/space emoji packs in settings
- `:shortcode:` in composer resolves to pack image
- Pack viewer for browsing stickers/emojis
- Integrates with `im.ponies.room_emotes` and pack MXC structure
## Architecture
```
plugins/custom-emoji/
PackMetaReader, PackImageReader, PackImagesReader
ImagePack.ts, PackAddress.ts
emoji plugin (plugins/emoji.ts) — render shortcodes
settings emojis-stickers + common-settings emojis
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/plugins/custom-emoji/PackMetaReader.ts` | Read pack metadata |
| `cinny/src/app/plugins/custom-emoji/PackImageReader.ts` | Single image |
| `cinny/src/app/plugins/custom-emoji/PackImagesReader.ts` | Bulk images |
| `cinny/src/app/plugins/custom-emoji/ImagePack.ts` | Pack model |
| `cinny/src/app/plugins/custom-emoji/types.ts` | Types |
| `cinny/src/app/plugins/emoji.ts` | Shortcode rendering |
| `cinny/src/app/plugins/recent-emoji.ts` | Recent emoji helper |
| `cinny/src/app/components/image-pack-view/ImagePackView.tsx` | Pack browser UI |
| `cinny/src/app/hooks/useImagePackRooms.ts` | Rooms with packs |
## Data model
| Event | Purpose |
|-------|---------|
| `im.ponies.room_emotes` | Room emoji pack definition |
| Image pack account data | User/global packs (see emoji-stickers) |
## Dependencies
- MXC media URLs
- `useMediaAuthentication` for protected media
## Integration points
- **emoji-stickers**: settings UI for packs + Telegram import
- **message-rendering**: emoji.ts in parse pipeline
- **editor-and-composer**: EmoticonAutocomplete
## Testing
### Manual
1. Add emoji pack to room.
2. Type `:shortcode:` in composer; image inserts.
3. View pack in ImagePackView.
4. Encrypted room pack visibility.
### Automated
- None
## Known issues & gotchas
- Pack address parsing in `PackAddress.ts` — MXC format sensitive
- Large packs slow autocomplete — consider lazy load
- Telegram import creates separate pack ids (`telegram_*`)
## Future work
- Pack CDN cache
- Animated emoji support audit
## Related docs
- [emoji-stickers HANDOFF](../emoji-stickers/HANDOFF.md)
- [message-rendering HANDOFF](../message-rendering/HANDOFF.md)
## Add your extra things here
- `StateEvent.PoniesRoomEmotes = 'im.ponies.room_emotes'` in room.ts

View File

@@ -0,0 +1,75 @@
# Developer tools — handoff
## Summary
Power-user tools for debugging Matrix state: view/edit account data, send custom room events, and inspect raw JSON. Available in user settings and room/space common-settings.
## User-facing behavior
- **Settings → Developer tools**: account data viewer
- **Room settings → Developer tools**: send state events, room account data
- Raw JSON editors with validation warnings
## Architecture
```
settings/developer-tools/
DevelopTools.tsx, AccountData.tsx
common-settings/developer-tools/
DevelopTools.tsx, StateEventEditor.tsx, SendRoomEvent.tsx
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/settings/developer-tools/DevelopTools.tsx` | User-level dev tools |
| `cinny/src/app/features/settings/developer-tools/AccountData.tsx` | Account data viewer |
| `cinny/src/app/features/common-settings/developer-tools/DevelopTools.tsx` | Room dev tools entry |
| `cinny/src/app/features/common-settings/developer-tools/StateEventEditor.tsx` | Edit state events |
| `cinny/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx` | Send events |
## Data model
Direct read/write to Matrix account data and state events — **production danger**.
## Dependencies
- matrix-js-sdk low-level APIs
## Integration points
- **user-settings**: developer tab
- **common-settings**: per-room tools
## Testing
### Manual
1. View account data keys on staging account.
2. Send harmless custom state event in test room.
3. Verify permission denied without power level.
### Automated
- None
## Known issues & gotchas
- No guard rails — can break rooms with bad state events
- Should be hidden or disabled in production builds for normal users (currently settings-gated only)
- Typo `DevelopTools` vs `DeveloperTools` in filenames
## Future work
- Feature flag via client config
- Event diff viewer
## Related docs
- [user-settings HANDOFF](../user-settings/HANDOFF.md)
- [common-settings HANDOFF](../common-settings/HANDOFF.md)
## Add your extra things here
- Only use on test homeservers — document internal MSC event types here when experimenting

View File

@@ -0,0 +1,83 @@
# Direct messages — handoff
## Summary
The DMs panel lists direct chats and provides flows to start new conversations. Uses `m.direct` account data to identify DM rooms.
## User-facing behavior
- Sidebar Direct tab shows DM rooms sorted
- Create new DM / group chat
- DM room opens in direct route layout
- Paarrot logo badge states on tab (shared with home tab styling)
## Architecture
```
client/direct/
Direct.tsx — DM room list page
DirectCreate.tsx — start new chat
useDirectRooms.ts — room list from m.direct
RoomProvider.tsx — route-level room context
mDirectAtom — direct room index
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/pages/client/direct/Direct.tsx` | DM list |
| `cinny/src/app/pages/client/direct/DirectCreate.tsx` | Create DM |
| `cinny/src/app/pages/client/direct/useDirectRooms.ts` | List logic |
| `cinny/src/app/pages/client/sidebar/DirectTab.tsx` | Sidebar tab |
| `cinny/src/app/state/mDirectList.ts` | `mDirectAtom` |
| `cinny/src/app/hooks/useDirectUsers.ts` | DM user helpers |
## Data model
| Key | Scope |
|-----|-------|
| `m.direct` | User account data — map userId → roomIds[] |
## Dependencies
- matrix-js-sdk DM APIs
- `is_direct` on member events
## Integration points
- **home-and-sidebar**: DirectTab
- **routing-and-navigation**: `DIRECT_PATH`
- **create-flows**: create-chat feature
- **room-timeline**: same Room component for DM rooms
## Testing
### Manual
1. Start DM with user; appears in direct list.
2. Group DM with multiple users.
3. Unread badge on Direct tab.
### Automated
- None
## Known issues & gotchas
- `m.direct` can be stale — client may repair on sync
- DM vs private room distinction relies on account data
## Future work
- Pin favorite DMs
- DM search filter
## Related docs
- [home-and-sidebar HANDOFF](../home-and-sidebar/HANDOFF.md)
- [create-flows HANDOFF](../create-flows/HANDOFF.md)
## Add your extra things here
- `getDirectRoomPath` in pathUtils for DM room URLs

View File

@@ -0,0 +1,92 @@
# E2E encryption & devices — handoff
## Summary
End-to-end encryption support via matrix-js-sdk crypto store: device verification, cross-signing, secret storage, key backup/export, and session management. Critical for secure DM and encrypted rooms.
## User-facing behavior
- Verify new logins / other sessions
- Export room keys backup (`paarrot-keys.txt`)
- Restore backup after verification
- View and rename devices; logout remote sessions
- Unverified sessions tab in sidebar
## Architecture
```
secretStorageKeys.ts + cryptoCallbacks
Devices.tsx, Verification.tsx, LocalBackup.tsx
DeviceVerification.tsx — incoming verification
BackupRestore.tsx — auto restore on verify
useSecretStorage, useKeyBackup, useVerificationRequest hooks
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/client/secretStorageKeys.ts` | SSSS key handling |
| `cinny/src/app/features/settings/devices/Devices.tsx` | Device list |
| `cinny/src/app/features/settings/devices/Verification.tsx` | Verification UI |
| `cinny/src/app/features/settings/devices/LocalBackup.tsx` | Key export |
| `cinny/src/app/features/settings/devices/OtherDevices.tsx` | Remote sessions |
| `cinny/src/app/components/DeviceVerification.tsx` | Incoming verify modal |
| `cinny/src/app/components/BackupRestore.tsx` | Auto restore |
| `cinny/src/app/components/SecretStorage.tsx` | Passphrase setup |
| `cinny/src/app/hooks/useSecretStorage.ts` | Secret storage API |
| `cinny/src/app/hooks/useKeyBackup.ts` | Backup API |
| `cinny/src/app/pages/client/sidebar/UnverifiedTab.tsx` | Unverified badge |
## Data model
| Store | Purpose |
|-------|---------|
| IndexedDB crypto | Olm account, Megolm sessions |
| SSSS | Cross-signing keys, backup key |
| Matrix account data | `m.cross_signing.*`, `m.secret_storage.*` |
## Dependencies
- matrix-js-sdk crypto module
- Secure random / Web Crypto
## Integration points
- **matrix-client**: crypto store init in initMatrix
- **authentication**: first login bootstrap
- **user-settings**: devices tab
## Testing
### Manual
1. Login second device; verify via emoji/SAS from first.
2. Export keys backup; file downloads.
3. Encrypted room: send message; decrypt on other device.
4. Logout remote session from devices list.
### Automated
- None
## Known issues & gotchas
- `handlingKeyConflict` in initMatrix on duplicate device keys
- Logout clears crypto store — must re-verify
- Backup passphrase loss = message history loss
## Future work
- Dehydrated devices support
- Better recovery key UX
## Related docs
- [matrix-client HANDOFF](../matrix-client/HANDOFF.md)
- [user-settings HANDOFF](../user-settings/HANDOFF.md)
## Add your extra things here
- Local backup filename: `paarrot-keys.txt` in LocalBackup.tsx
- `ReceiveSelfDeviceVerification` route component in Router.tsx

View File

@@ -0,0 +1,92 @@
# Editor & composer — handoff
## Summary
The message composer is a **Slate**-based rich text editor with autocomplete for users, rooms, and emoticons. Converts between editor state and Matrix HTML/plaintext. Supports toolbar formatting, intents, and file paste/drop integration.
## User-facing behavior
- Type messages with bold/italic/code etc. (toolbar)
- `@user` and `#room` mentions with autocomplete popup
- `:emoji` emoticon autocomplete
- Paste images/files into composer
- Draft persistence per room
## Architecture
```
Editor.tsx (Slate)
→ Elements.tsx, Toolbar.tsx
→ autocomplete/ (User, Room, Emoticon)
→ input.ts / output.ts — Matrix format conversion
RoomInput.tsx wires Editor to send pipeline + uploads
text-area plugin (legacy/plain areas in some UI)
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/components/editor/Editor.tsx` | Main Slate editor |
| `cinny/src/app/components/editor/Toolbar.tsx` | Formatting toolbar |
| `cinny/src/app/components/editor/input.ts` | Parse into editor |
| `cinny/src/app/components/editor/output.ts` | Serialize to Matrix |
| `cinny/src/app/components/editor/autocomplete/` | Mention/emoji menus |
| `cinny/src/app/features/room/RoomInput.tsx` | Room composer wrapper |
| `cinny/src/app/plugins/text-area/` | Plain text area utilities |
| `cinny/src/app/hooks/useFilePasteHandler.ts` | Paste files |
| `cinny/src/app/hooks/useFileDrop.ts` | Drag-drop files |
| `cinny/src/app/state/room/roomInputDrafts.ts` | Draft persistence |
## Data model
| Key | Storage |
|-----|---------|
| `roomIdToMsgDraftAtomFamily` | localStorage per room |
| `roomIdToUploadItemsAtomFamily` | pending uploads |
## Dependencies
- `slate`, `slate-react`
- `folds` UI primitives
## Integration points
- **room-timeline**: send on submit
- **emoji-stickers**: emoticon autocomplete + custom packs
- **plugins**: `text-composer-toolbar`, `composer-actions` slots
- **media-and-url-preview**: upload before send
## Testing
### Manual
1. Mention user and room; verify pill rendering in sent message.
2. Format bold/italic; verify HTML in `formatted_body`.
3. Reload mid-compose; draft restores.
4. Paste image; upload card appears.
### Automated
- `Editor.preview.tsx` exists for dev preview
## Known issues & gotchas
- `useCompositionEndTracking` in App.tsx for IME composition
- Slate types in `slate.d.ts` — keep custom elements typed
- Plain text vs HTML fallback for clients without HTML support
## Future work
- WYSIWYG markdown mode
- Draft sync across devices via account data
## Related docs
- [room-timeline HANDOFF](../room-timeline/HANDOFF.md)
- [emoji-stickers HANDOFF](../emoji-stickers/HANDOFF.md)
## Add your extra things here
- `plainToEditorInput`, `toPlainText` helpers exported from editor index
- Forum has separate composers: `ForumReplyComposer`, `ForumChatComposer`

View File

@@ -0,0 +1,96 @@
# Electron desktop shell — handoff
## Summary
The desktop app wraps the Cinny/Paarrot web UI in Electron: custom title bar, system tray, deep links (`paarrot://`), auto-updates, plugin filesystem access, screen capture for calls, and the local HTTP API server.
## User-facing behavior
- Frameless/custom **title bar** with window controls (Windows/Linux)
- Minimize to **tray**; close may minimize instead of quit (platform config)
- `paarrot://` URL protocol for room links
- Auto-update checks (electron-updater)
- Plugins read/write under `%APPDATA%\paarrot\`
## Architecture
```
electron/main.js (main process)
→ BrowserWindow → Vite dev server or built cinny dist
→ IPC: plugins, screen capture, clipboard, updates
→ PaarrotAPIServer
→ Tray + protocol handler paarrot://
preload / contextBridge → window.electron in renderer
```
## Key files
| Path | Role |
|------|------|
| `electron/main.js` | Main process entry |
| `electron/api-server.js` | Local HTTP API |
| `electron/preload.js` (if exists) | IPC bridge |
| `electron-builder.json5` | Packaging config |
| `package.json` (root) | `npm run dev`, build scripts |
| `dev-app-update.yml` | Update channel config |
| `cinny/src/app/components/title-bar/TitleBar.tsx` | Custom title bar UI |
| `build-resources/` | Linux after-install, icons |
## Data model
| Store | Tool | Contents |
|-------|------|----------|
| `electron-store` | main.js | Window bounds, user prefs |
| UserData | Electron | plugins, caches |
Protocol: `PROTOCOL_SCHEME = 'paarrot'` in main.js
## Dependencies
- `electron`, `electron-updater`, `electron-store`
- `electron-builder` for CI releases
- Vite dev server port **38347**; static server **44548** in dev
## Integration points
- **local-api**: started with main window
- **Plugins**: filesystem paths under `paarrot` app name
- **Voice calls**: `desktopCapturer` for screen share
- **Deep links**: pending deep link queue until client ready
## Testing
### Manual
1. `npm run dev` — window loads, title bar draggable.
2. Register `paarrot://` link; opens correct room.
3. Tray icon present; restore from tray.
4. Build installer; auto-update check (staging).
5. Install plugin to AppData path; loader finds it.
### Automated
- CI: `.gitea/workflows/build.yml`
## Known issues & gotchas
- `isDev` detection: `NODE_ENV` or `!app.isPackaged`
- Protocol registration differs Windows vs Linux (`after-install.sh`)
- Single instance lock for deep links — see `pendingDeepLink` in main.js
- Title bar hit-testing CSS sensitive on Windows 11
## Future work
- macOS menu bar parity
- Code signing documentation for Windows
- Consolidate magic ports into shared config
## Related docs
- [local-api HANDOFF](../local-api/HANDOFF.md)
- Root [README.md](../../../../README.md) — build instructions
## Add your extra things here
- Dev URLs: `VITE_DEV_SERVER = http://localhost:38347`, `PORT = 44548`
- App userData folder name: `paarrot` (matches plugin paths in README)

View File

@@ -0,0 +1,84 @@
# Embed filters — handoff
## Summary
Embed filters let users and room admins suppress URL preview/embeds for matching links. **Personal** filters live in per-room account data; **room-wide** filters are a room state event admins control. Patterns are regex strings tested against URLs before rendering embeds in the timeline and threads.
## User-facing behavior
- Per-user: configure patterns for a room (settings or thread UI — see consumers)
- Room admins: room-wide disabled patterns via `im.paarrot.room.embed_filters`
- Matching URLs skip embed cards (Giphy, Open Graph, etc.)
## Architecture
```
useRoomEmbedFilters (personal, account data)
useRoomWideEmbedFilters (room state)
→ combineEmbedFilters()
→ ThreadView / RoomTimeline embed rendering
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/hooks/useRoomEmbedFilters.ts` | Hooks, types, combine logic |
| `cinny/src/types/matrix/room.ts` | `StateEvent.RoomEmbedFilters` |
| `cinny/src/app/features/room/ThreadView.tsx` | Consumer |
| `cinny/src/app/features/room/RoomTimeline.tsx` | Consumer |
| `cinny/src/app/features/common-settings/general/EmbedFilters.tsx` | Room-wide settings UI |
| `cinny/src/app/components/RenderMessageContent.tsx` | Embed render gating |
| `cinny/src/app/features/forum/useForumMessageRenderOptions.ts` | Forum post embed options |
## Data model
| Key | Scope | Content |
|-----|-------|---------|
| `im.paarrot.embed_filters` | Room account data (per user) | `{ disabledPatterns: string[] }` |
| `im.paarrot.room.embed_filters` | Room state | `{ disabledPatterns: string[] }` |
Patterns are JavaScript `RegExp` strings — invalid regex can throw; handle at input time.
## Dependencies
- `matrix-js-sdk` account data + state events
## Integration points
- **URL preview components**: `url-preview/` — embed gating happens before render
- **Thread view**: combines personal + room-wide filters
## Testing
### Manual
1. Add personal pattern blocking `example.com` in a room.
2. Post link; verify no embed.
3. As admin set room-wide pattern; verify all members affected.
4. Test thread view uses combined filters.
### Automated
- None
## Known issues & gotchas
- Two storage locations — document clearly for users (personal vs room-wide)
- Regex validation on save recommended — bad patterns break matching try/catch sites
- Account data type constant: `EMBED_FILTERS_ACCOUNT_DATA_TYPE` in hook file
## Future work
- UI in room settings for room-wide filters (if not complete)
- Import/export pattern lists
- Non-regex simple domain blocklist mode
## Related docs
- None dedicated
## Add your extra things here
- `combineEmbedFilters` merges personal + room-wide with dedup logic — read implementation before changing precedence
- `EmbedFiltersContent` interface in `useRoomEmbedFilters.ts`

View File

@@ -0,0 +1,92 @@
# Emojis & stickers — handoff
## Summary
Paarrot extends Cinny's emoji/sticker system with **usage tracking** (frequently used emojis), **Telegram sticker pack import**, custom emoji pack plugins, and configurable **emoji font** (System / Apple / Twemoji). Pack management lives under Settings and room/space settings.
## User-facing behavior
- **Settings → Emojis & stickers**: global packs, user packs, Telegram import
- Frequently-used emojis sorted by usage stats
- **Settings → General → Emoji style**: System, Apple, Twemoji fonts
- Telegram: bot token stored locally, import packs by name
- Room/space emoji packs via common-settings (inherited Cinny patterns)
## Architecture
```
useEmojiUsage → account data paarrot.favemojis
EmojiStyleFeature (ClientNonUIFeatures) → CSS --font-emoji
TelegramImport.tsx → telegramStickerImport.ts → Matrix image pack upload
custom-emoji plugin (plugins/custom-emoji/) → PackMetaReader
Settings / common-settings EmojisStickers pages
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/hooks/useEmojiUsage.ts` | Usage stats in account data |
| `cinny/src/app/utils/telegramStickerImport.ts` | Telegram Bot API import |
| `cinny/src/app/features/settings/emojis-stickers/TelegramImport.tsx` | Import UI |
| `cinny/src/app/features/settings/emojis-stickers/EmojisStickers.tsx` | Settings hub |
| `cinny/src/app/features/settings/emojis-stickers/UserPack.tsx` | User pack editor |
| `cinny/src/app/features/settings/emojis-stickers/GlobalPacks.tsx` | Global packs |
| `cinny/src/app/plugins/custom-emoji/` | Custom emoji pack reader |
| `cinny/src/app/pages/client/ClientNonUIFeatures.tsx` | `EmojiStyleFeature` |
| `cinny/src/app/state/settings.ts` | `EmojiStyle` enum |
## Data model
| Key | Scope | Content |
|-----|-------|---------|
| `paarrot.favemojis` | User account data | Emoji usage counts |
| `cinny_telegram_bot_token` | localStorage | Telegram bot token (TelegramImport) |
| `im.ponies.room_emotes` | Room state | Standard Matrix emoji packs |
## Dependencies
- Telegram Bot API (`api.telegram.org`)
- Twemoji / Apple Color Emoji fonts (bundled or system)
- `matrix-js-sdk` for image pack events
## Integration points
- **Emoji board / composer autocomplete**: reads usage ordering
- **Image pack viewer**: preview imported packs
- **Plugins**: custom-emoji pack format
## Testing
### Manual
1. Send messages with varied emoji; open picker — frequent ones rise.
2. Import Telegram pack with valid bot token; verify stickers in room.
3. Switch emoji style General setting; verify font change in messages.
4. Add room emoji pack in space settings.
### Automated
- None
## Known issues & gotchas
- Telegram token in **localStorage** — not encrypted; user-provided bot
- Telegram import uses network to Telegram servers — rate limits apply
- `PAARROT_EMOJI_USAGE_EVENT = 'paarrot.favemojis'` — must stay stable or migrate
- Pack id prefix `telegram_${packName}` for imported packs
## Future work
- Encrypt or secure-store Telegram token
- Usage stats sync across devices (account data already does)
- Sticker pack marketplace
## Related docs
- Cinny upstream emoji pack docs (if any)
## Add your extra things here
- Emoji style values: `EmojiStyle.System | Apple | Twemoji` in settings atom
- `useEmojiGroupLabels` for picker section headers

View File

@@ -0,0 +1,79 @@
# Explore — handoff
## Summary
Discover public rooms and featured communities on Matrix homeservers. Browse by server, search public room directory, and preview/join rooms.
## User-facing behavior
- Explore tab in sidebar
- Pick server / homeserver directory
- Featured rooms list
- Search public rooms on server
- Join room from preview
## Architecture
```
client/explore/
Explore.tsx — main explore page
Server.tsx — server-specific directory
Featured.tsx — featured rooms
PublicRooms via matrix directory API
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/pages/client/explore/Explore.tsx` | Explore hub |
| `cinny/src/app/pages/client/explore/Server.tsx` | Server directory |
| `cinny/src/app/pages/client/explore/Featured.tsx` | Featured list |
| `cinny/src/app/pages/client/sidebar/ExploreTab.tsx` | Sidebar entry |
| `cinny/src/app/hooks/useRoomDirectoryVisibility.ts` | Directory visibility |
## Data model
Public room directory from homeserver HTTP API (ephemeral).
## Dependencies
- Homeserver `/publicRooms` or sliding sync directory
- `matrix-js-sdk` room directory helpers
## Integration points
- **routing-and-navigation**: `EXPLORE_PATH`, `_SERVER_PATH`, `_FEATURED_PATH`
- **join-before-navigate**: may route here before join
## Testing
### Manual
1. Open explore; list loads for default server.
2. Switch server; directory refreshes.
3. Join public room from explore.
4. Server without public directory — error handling.
### Automated
- None
## Known issues & gotchas
- Public directory disabled on many private homeservers
- Featured rooms may be config-dependent (`clientConfig`)
## Future work
- Federated universal search
- Room preview with topic/avatar before join
## Related docs
- [routing-and-navigation HANDOFF](../routing-and-navigation/HANDOFF.md)
- [join-before-navigate HANDOFF](../join-before-navigate/HANDOFF.md)
## Add your extra things here
- `getExploreFeaturedPath` in pathUtils

View File

@@ -0,0 +1,105 @@
# Forum spaces — handoff
## Summary
Forum spaces provide a board-style UI for `m.forum` rooms and `forum_space` room kinds: topic sections, post feeds, thread detail, rich replies, filters, and dedicated composers. This is a large Paarrot-specific feature area separate from the card-based **Lobby** view.
## User-facing behavior
- Forum spaces open to a **feed** of posts (not the lobby card grid)
- Sections group topics (child rooms / subspaces)
- Create posts with title + rich body; threaded replies
- Board detail view, thread detail, topic search/filter bar
- Distinct composers: new post, reply, chat-style
## Architecture
```
ForumAwareSpaceLayout / ForumSpaceLayout
→ ForumFeedPage (feed sidebar + post list)
→ ForumBoardDetail / ForumThreadDetail
→ forumFeed.ts (aggregate posts from topic rooms)
→ forumTopicHelpers, forumRichText, topicSearch
Hooks: useForumBoard, useForumRoomLiveUpdates, useForumMessageReactions
```
Routing: `getSpaceDefaultPath` + `shouldShowForumLobby` in path utils / room utils.
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/forum/ForumFeedPage.tsx` | Main feed page |
| `cinny/src/app/features/forum/ForumSpaceLayout.tsx` | Layout shell |
| `cinny/src/app/features/forum/ForumAwareSpaceLayout.tsx` | Forum vs non-forum switch |
| `cinny/src/app/features/forum/forumFeed.ts` | Post aggregation, root detection |
| `cinny/src/app/features/forum/types.ts` | ForumTopic, ForumPost, etc. |
| `cinny/src/app/features/forum/ForumBoardView.tsx` | Board listing |
| `cinny/src/app/features/forum/ForumThreadDetail.tsx` | Thread view |
| `cinny/src/app/features/forum/ForumNewPostModal.tsx` | Create post |
| `cinny/src/app/features/forum/ForumReplyComposer.tsx` | Replies |
| `cinny/src/app/features/forum/useForumBoard.ts` | Board data hook |
| `cinny/src/app/features/forum/useForumRoomLiveUpdates.ts` | Live timeline sync |
| `cinny/src/app/pages/pathUtils.ts` | Forum routing |
## Data model
| Signal | Purpose |
|--------|---------|
| `m.room.create` `type: m.forum` | Matrix forum room type |
| `im.paarrot.room.kind: forum_space` | Paarrot forum classification |
| `com.matrixsso.title` on messages | Forum post title (legacy/custom) |
| `formatted_body` with `<h1>` | Root post detection |
| `m.relates_to` threads | Replies and edits |
| `im.paarrot.sub_rooms` | Topic room hierarchy (see sub-rooms) |
## Dependencies
- `matrix-js-sdk` relations, threads, space hierarchy
- Rich text / Slate editor integration for composers
- Embed filters via `useForumMessageRenderOptions.ts`
## Integration points
- **lobby-forums**: routing chooses forum feed vs lobby
- **sub-rooms**: topic rooms may be sub-rooms
- **embed-filters**: forum message render options
- **local-api**: `isSpaceLikeRoom` treats forum as space-like
## Testing
### Manual
1. Open a `m.forum` space; verify feed UI loads.
2. Create post with title; verify appears in feed and topic room.
3. Reply in thread; verify counts and detail view.
4. Filter/search topics; verify results.
5. Compare behavior with `forum_space` room kind.
### Automated
- None
## Known issues & gotchas
- Root post detection uses **two** heuristics: `com.matrixsso.title` and `<h1>` in formatted_body
- Large `forumFeed.ts` — main complexity hotspot
- Forum vs lobby split scattered across path utils and layout components
- Edit/replace relations need careful handling (`isAnnotationRoomMessage`)
## Future work
- Standardize on one title field (MSC alignment)
- Unit tests for `isForumRootContent` and feed aggregation
- Performance for large spaces (pagination)
## Related docs
- [lobby-forums HANDOFF](../lobby-forums/HANDOFF.md) — lobby card UI vs forum feed routing
- [sub-rooms HANDOFF](../sub-rooms/HANDOFF.md)
- [embed-filters HANDOFF](../embed-filters/HANDOFF.md)
## Add your extra things here
- ~40 files under `features/forum/` — start with `forumFeed.ts` and `ForumFeedPage.tsx` when onboarding
- `annotatePostsWithRoomScope` in `forumTopicHelpers.ts` for cross-topic feed display

View File

@@ -0,0 +1,75 @@
# Global search — handoff
## Summary
Quick global search modal for jumping to rooms, users, and recent conversations. Distinct from full message search — optimized for navigation, not event content search.
## User-facing behavior
- Keyboard shortcut / sidebar tab opens search modal
- Type to filter rooms and users
- Select result to navigate
## Architecture
```
SearchModalRenderer (in Router.tsx)
→ features/search/Search.tsx
→ searchModal.ts atom for open state
useAsyncSearch or similar for filtering
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/search/Search.tsx` | Modal UI |
| `cinny/src/app/state/searchModal.ts` | Open/close state |
| `cinny/src/app/pages/client/sidebar/SearchTab.tsx` | Sidebar entry |
## Data model
| Key | Storage |
|-----|---------|
| `searchModal` atom | memory |
## Dependencies
- Local room list + user index from sync
## Integration points
- **routing-and-navigation**: `SearchModalRenderer` in router
- **home-and-sidebar**: SearchTab
- **message-search**: different feature — don't merge UIs
## Testing
### Manual
1. Open search modal from sidebar.
2. Filter rooms by name.
3. Select room; navigates and modal closes.
### Automated
- None
## Known issues & gotchas
- Two "search" features confuse users — message search vs global nav search
- Modal focus trap via folds Overlay patterns
## Future work
- Unify search entry points with tabs (rooms / messages / users)
- Recent searches persistence
## Related docs
- [message-search HANDOFF](../message-search/HANDOFF.md)
- [routing-and-navigation HANDOFF](../routing-and-navigation/HANDOFF.md)
## Add your extra things here
- `SearchModalRenderer` mounted at client root level in Router.tsx

View File

@@ -0,0 +1,100 @@
# Home & sidebar — handoff
## Summary
The **Home** panel lists joined rooms (with sub-room nesting), and the **sidebar** switches between Home, DMs, Explore, Inbox, Search, Create, and Settings tabs. Room ordering, categories, and unread badges are rendered here.
## User-facing behavior
- Home: hierarchical room list with categories, drag reorder, unread/highlight states
- Sidebar tabs with Paarrot logo states (normal/unread/highlight) on Home
- Space tabs when inside a space context
- Threads category on home (`HomeThreadsCategory`)
- Sync status in client chrome
## Architecture
```
SidebarNav → tab routes (HomeTab, DirectTab, ExploreTab, ...)
Home.tsx
→ useHomeRooms.ts (room list + sub-room flattening)
→ RoomNavItem / RoomNavCategoryButton / UnjoinedSubRoomItem
DirectTab.tsx → useDirectRooms.ts
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/pages/client/home/Home.tsx` | Home room list UI |
| `cinny/src/app/pages/client/home/useHomeRooms.ts` | Room list logic + Paarrot room kind |
| `cinny/src/app/pages/client/SidebarNav.tsx` | Sidebar tab bar |
| `cinny/src/app/pages/client/sidebar/HomeTab.tsx` | Home tab |
| `cinny/src/app/pages/client/sidebar/DirectTab.tsx` | DMs tab + favicon logos |
| `cinny/src/app/pages/client/sidebar/InboxTab.tsx` | Inbox tab |
| `cinny/src/app/pages/client/sidebar/ExploreTab.tsx` | Explore tab |
| `cinny/src/app/pages/client/sidebar/SearchTab.tsx` | Search tab |
| `cinny/src/app/pages/client/sidebar/SettingsTab.tsx` | Settings tab |
| `cinny/src/app/pages/client/sidebar/SpaceTabs.tsx` | Space context tabs |
| `cinny/src/app/pages/client/home/HomeThreadsCategory.tsx` | Threads grouping |
| `cinny/src/app/hooks/useRoomListReorder.ts` | DnD reorder |
| `cinny/src/app/hooks/useSidebarItems.ts` | Sidebar item model |
## Data model
| Source | Data |
|--------|------|
| `allRoomsAtom` | Joined rooms |
| `roomToUnreadAtom` | Badge state |
| `mDirectAtom` | DM room mapping |
| `im.paarrot.sub_rooms` | Nested children (see sub-rooms) |
| `im.paarrot.room.kind` | Room classification |
| localStorage | Category collapse, room order |
## Dependencies
- Jotai room list atoms
- `matrix-js-sdk` room summaries
## Integration points
- **sub-rooms**: tree flattening in Home.tsx
- **room-nav**: nav item components
- **routing-and-navigation**: tab routes
- **background-notifications**: unread → favicon in DirectTab
## Testing
### Manual
1. Verify room order drag persists.
2. Sub-room nesting indent and unjoined state.
3. Unread badge on sidebar tab.
4. Category collapse/expand.
5. Switch tabs; active state correct.
### Automated
- None
## Known issues & gotchas
- `useHomeRooms` filters top-level vs sub-room IDs — easy to break tree
- DirectTab uses custom Paarrot SVGs for badge states
- Mobile hides sidebar in favor of bottom/full-page nav
## Future work
- Unified sidebar component between home and space contexts
- Virtualize long room lists
## Related docs
- [sub-rooms HANDOFF](../sub-rooms/HANDOFF.md)
- [room-nav HANDOFF](../room-nav/HANDOFF.md)
- [routing-and-navigation HANDOFF](../routing-and-navigation/HANDOFF.md)
## Add your extra things here
- `RoomNavCategoryButton` for collapsible sections
- `useClosedNavCategoriesAtom` for collapsed state

View File

@@ -0,0 +1,83 @@
# Inbox & invites — handoff
## Summary
The inbox area covers invites, notification feed, and notification preferences. Sidebar Inbox tab routes to invites list and notifications history.
## User-facing behavior
- View and accept/decline room invites
- Browse notification events (mentions, etc.)
- Per-room notification preference overrides
## Architecture
```
client/inbox/
Inbox.tsx — layout
Invites.tsx — invite list (allInvitesAtom)
Notifications.tsx — notification feed
ClientRoomsNotificationPreferences.tsx — bulk prefs
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/pages/client/inbox/Inbox.tsx` | Inbox layout |
| `cinny/src/app/pages/client/inbox/Invites.tsx` | Invites UI |
| `cinny/src/app/pages/client/inbox/Notifications.tsx` | Notifications list |
| `cinny/src/app/state/room-list/inviteList.ts` | `allInvitesAtom` |
| `cinny/src/app/pages/client/ClientRoomsNotificationPreferences.tsx` | Room notif prefs |
| `cinny/src/app/pages/client/sidebar/InboxTab.tsx` | Sidebar entry |
| `cinny/src/app/hooks/useRoomsNotificationPreferences.ts` | Prefs hook |
## Data model
| Source | Data |
|--------|------|
| Sync | `m.room.member` invite membership |
| Account data | push rules, per-room notification prefs |
## Dependencies
- matrix-js-sdk sync invite events
- Push rule APIs
## Integration points
- **background-notifications**: same push rules drive system notifications
- **user-settings**: notification settings tab
- **routing-and-navigation**: `INBOX_PATH`, `_INVITES_PATH`, `_NOTIFICATIONS_PATH`
## Testing
### Manual
1. Receive invite; appears in inbox.
2. Accept invite; room joins home list.
3. Decline invite; removed from list.
4. Notifications page shows recent highlights.
### Automated
- None
## Known issues & gotchas
- Invite list can desync briefly during sync gap
- Notification feed scope depends on push rules setup
## Future work
- Mark all invites read
- Notification grouping by room
## Related docs
- [background-notifications HANDOFF](../background-notifications/HANDOFF.md)
- [user-settings HANDOFF](../user-settings/HANDOFF.md)
## Add your extra things here
- `getInboxInvitesPath`, `getInboxNotificationsPath` in pathUtils

View File

@@ -0,0 +1,78 @@
# Join before navigate — handoff
## Summary
When a user opens a room link for a room they have not joined, this preview page shows room info (name, topic, member count) and offers join before entering the timeline.
## User-facing behavior
- matrix.to / internal join links land here if not member
- Room card preview with topic
- Join button → membership then navigate to room/space
- Back navigation on mobile
## Architecture
```
JoinBeforeNavigate.tsx
→ RoomSummaryLoader for preview data
→ useRoomNavigate after join
Route: _JOIN_PATH in Router.tsx
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx` | Preview + join UI |
| `cinny/src/app/components/RoomSummaryLoader.tsx` | Fetch room preview |
| `cinny/src/app/hooks/useRoomNavigate.ts` | Post-join navigation |
| `cinny/src/app/state/joiningProgress.ts` | Join in progress UX |
## Data model
Preview from `/summary` API or peek room state. No local persistence.
## Dependencies
- matrix-js-sdk `joinRoom`
- `via` servers param for federated joins
## Integration points
- **routing-and-navigation**: join route with `roomIdOrAlias`, `viaServers`
- **explore**: similar preview patterns
- **electron-shell**: `paarrot://` deep links may target join flow
## Testing
### Manual
1. Open link to unjoined public room.
2. Preview shows name/topic.
3. Join succeeds; lands in timeline.
4. Space link navigates to space not room.
### Automated
- None
## Known issues & gotchas
- Private rooms without peek show limited preview
- `viaServers` required for some federated aliases
- `isSpace` check routes to space navigation after join
## Future work
- Knock rooms support
- Invite-only room messaging
## Related docs
- [routing-and-navigation HANDOFF](../routing-and-navigation/HANDOFF.md)
- [electron-shell HANDOFF](../electron-shell/HANDOFF.md)
## Add your extra things here
- Props: `roomIdOrAlias`, optional `eventId`, `viaServers[]`

View File

@@ -0,0 +1,95 @@
# Lobby & forums — handoff
## Summary
Spaces in Paarrot can show a **Lobby** view: a card-based hierarchy of child rooms with drag-and-drop ordering, hero header, and category collapse. **Forum** spaces (`m.forum` room type or `im.paarrot.room.kind` = `forum_space`) use a post-feed style navigation instead of the standard lobby when opened.
## User-facing behavior
- Opening a space lands on lobby (or forum feed for forum spaces)
- Lobby: hero, categories, room cards, DnD reorder (with permissions)
- Forum spaces: routed to post feed per `getSpaceDefaultPath` / `shouldShowForumLobby`
- Category open/closed state persisted per space
## Architecture
```
Space route → Lobby.tsx (SpaceCardLobby)
→ LobbyHeader, LobbyHero, SpaceHierarchy
→ DnD reorder → m.space.child order updates
→ closedLobbyCategories atom (per-space category collapse)
→ shouldShowForumLobby(space) gates forum vs lobby behavior
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/lobby/Lobby.tsx` | Main lobby view |
| `cinny/src/app/features/lobby/LobbyHeader.tsx` | Space header |
| `cinny/src/app/features/lobby/LobbyHero.tsx` | Hero banner area |
| `cinny/src/app/features/lobby/SpaceHierarchy.tsx` | Room tree/cards |
| `cinny/src/app/features/lobby/DnD.tsx` | Drag and drop |
| `cinny/src/app/features/lobby/RoomItem.tsx` | Room card |
| `cinny/src/app/state/closedLobbyCategories.ts` | Collapsed categories |
| `cinny/src/app/state/hooks/closedLobbyCategories.ts` | Jotai hook |
| `cinny/src/app/pages/pathUtils.ts` | `getSpaceLobbyPath`, forum routing |
| `cinny/src/app/utils/room.ts` | `shouldShowForumLobby`, `getStateEvent` |
| `cinny/src/types/matrix/room.ts` | `RoomType.Forum`, `PaarrotRoomKind` |
## Data model
| Event / field | Purpose |
|---------------|---------|
| `m.space.child` | Child room membership + `order` |
| `im.paarrot.sub_rooms` | Paarrot nested children (see sub-rooms handoff) |
| `im.paarrot.room.kind` | e.g. `forum_space` for forum behavior |
| `m.room.create` `type` | `m.forum` for forum rooms |
| Local: `closedLobbyCategories` | UI collapse state (not Matrix) |
## Dependencies
- Matrix space hierarchy events
- DnD library (see Lobby imports)
- Room power levels for reorder permission (`useCanDropLobbyItem`)
## Integration points
- **Sub-rooms**: `SpaceHierarchy` merges `im.paarrot.sub_rooms` children
- **Navigation**: `getSpaceDefaultPath` chooses lobby vs forum path
- **local-api**: `isSpaceLikeRoom` treats forum kind as space-like for channel API
## Testing
### Manual
1. Open a normal space → lobby with child rooms.
2. Collapse/expand category; reload — state should persist.
3. Drag reorder child (as admin); verify `m.space.child` order.
4. Open forum-type space; verify feed navigation not lobby cards.
5. Test with sub-rooms under a child space card.
### Automated
- None
## Known issues & gotchas
- Forum detection spans **two** signals: `m.forum` create type and `im.paarrot.room.kind`
- DnD permission logic is custom per `useCanDropLobbyItem` — read before changing
- `makeLobbyCategoryId(spaceId, childId)` keys collapse state
## Future work
- Unified hierarchy model (space.child + sub_rooms)
- Lobby theming from space avatar metadata
## Related docs
- [forum HANDOFF](../forum/HANDOFF.md) — post feed UI for forum spaces
- [sub-rooms HANDOFF](../sub-rooms/HANDOFF.md)
## Add your extra things here
- Route constant: `SPACE_LOBBY_PATH` in path utils
- `SpaceCardLobby` is the default export wrapper in `Lobby.tsx`

View File

@@ -0,0 +1,102 @@
# Local HTTP API — handoff
## Summary
Paarrot exposes a localhost HTTP API (port **33384**) so external tools — Stream Deck, scripts, macros — can control the running desktop app: mute/deafen, switch rooms, send messages, and read status. The Electron main process runs an Express server; actions are forwarded to the renderer via IPC and executed against the live Matrix client and call service.
## User-facing behavior
- API starts automatically when the Electron app launches (no user setting).
- Only binds to `127.0.0.1` — not reachable from the network.
- No authentication today; security relies on localhost-only binding.
## Architecture
```
External client (curl, Stream Deck, script)
→ GET/POST http://127.0.0.1:33384/...
→ electron/api-server.js (Express)
→ IPC to renderer (executeAction)
→ cinny/src/app/paarrot-api.ts (initPaarrotAPI)
→ Matrix client / CallService / router navigate
```
Navigation for room changes uses `setPaarrotNavigate()` registered from the React router in `ClientNonUIFeatures.tsx`.
## Key files
| Path | Role |
|------|------|
| `electron/api-server.js` | Express routes, IPC bridge to renderer |
| `electron/main.js` | Starts `PaarrotAPIServer` with the main window |
| `cinny/src/app/paarrot-api.ts` | Action handlers (mute, deafen, channel, message, status) |
| `cinny/src/app/pages/client/ClientNonUIFeatures.tsx` | Calls `initPaarrotAPI()` and `setPaarrotNavigate()` |
| `test-api.js` / `test-api.sh` | Smoke tests for endpoints |
| `paarrot-api.postman_collection.json` | Postman collection (generated) |
## Data model
No persistent storage. Actions read/write ephemeral app state (current room, call mute/deafen).
### IPC actions (renderer)
| Action | Params | Returns |
|--------|--------|---------|
| `get-status` | — | mute, deafen, currentRoom, connected, userId |
| `toggle-mute` / `set-mute` | `{ muted? }` | `{ muted }` |
| `toggle-deafen` / `set-deafen` | `{ deafened? }` | `{ deafened }` |
| `change-channel` | `{ roomId }` | navigation result |
| `get-channels` | — | room list |
| `send-message` | `{ roomId, message }` | send result |
| `send-message-current` | `{ message }` | send to active room |
| `get-current-room` | — | current room id |
## Dependencies
- `express`, `cors`, `body-parser` (Electron)
- `matrix-js-sdk` (renderer)
- Call feature: `getCallService()` from `features/call/useCall.ts`
## Integration points
- **Voice calls**: mute/deafen actions delegate to `CallService`
- **Stream Deck**: `streamdeck-plugin/` consumes this API
- **Room navigation**: uses `getHomeRoomPath`, `getDirectRoomPath`, `getSpaceRoomPath`
- **Space detection**: `isSpaceLikeRoom()` checks `im.paarrot.room.kind` and `m.forum` type
## Testing
### Manual
1. Start Paarrot desktop (`npm run dev` or built app).
2. `curl http://127.0.0.1:33384/health``{ "status": "ok" }`
3. `curl http://127.0.0.1:33384/status` while logged in.
4. Join a voice call; `POST /mute/toggle` and verify UI state.
5. `POST /channel` with a room id; verify navigation.
### Automated
- `node test-api.js` from repo root
## Known issues & gotchas
- Mute/deafen only work when `CallService` has an active call; otherwise handlers return a message, not an error.
- API is Electron-only; `initPaarrotAPI` no-ops when `window.electron.api` is missing (web/Tauri builds).
- Adding endpoints requires changes in **both** `api-server.js` and `paarrot-api.ts`.
## Future work
- Optional auth token for local API
- WebSocket or SSE for status push (Stream Deck polling today)
- Document all endpoints in Postman collection generator (`scripts/generate-postman-collection.js`)
## Related docs
- [API.md](../../API.md) — full endpoint reference
- [API-QUICKSTART.md](../../API-QUICKSTART.md)
- [streamdeck HANDOFF](../streamdeck/HANDOFF.md)
## Add your extra things here
- Default port: **33384** (`PaarrotAPIServer` constructor in `api-server.js`)
- `isSpaceLikeRoom` treats `forum_space` room kind and `m.forum` create type as spaces for channel listing

View File

@@ -0,0 +1,93 @@
# Matrix client — handoff
## Summary
The Matrix client layer wraps `matrix-js-sdk`: client creation, IndexedDB crypto/sync stores, start/stop sync, logout, and React context providers. `ClientRoot` is the authenticated app bootstrapper.
## User-facing behavior
- Splash screen ("Heating up") while client initializes
- Sync status reflected in UI (`SyncStatus`, splash)
- Logout from settings or forced server logout
- Mobile: visibility-based sync restart
## Architecture
```
ClientRoot.tsx
→ getCurrentSession() → initClient(session)
→ startClient(mx)
→ Providers: MatrixClient, Capabilities, MediaConfig, Call, PluginLoader
initMatrix.ts
→ createClient + IndexedDBStore + IndexedDBCryptoStore
→ secretStorageKeys / cryptoCallbacks
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/client/initMatrix.ts` | createClient, start/stop, logout, cache clear |
| `cinny/src/client/secretStorageKeys.ts` | SSSS / secret storage callbacks |
| `cinny/src/app/pages/client/ClientRoot.tsx` | Bootstrap, providers, title bar |
| `cinny/src/app/hooks/useMatrixClient.ts` | React context for `MatrixClient` |
| `cinny/src/app/hooks/useSyncState.ts` | Sync state subscription |
| `cinny/src/app/pages/client/SyncStatus.tsx` | Sync indicator UI |
| `cinny/src/app/components/splash-screen/SplashScreen.tsx` | Loading shell |
## Data model
| Store | Backend | Purpose |
|-------|---------|---------|
| IndexedDB sync store | matrix-js-sdk | Room timeline cache |
| IndexedDB crypto store | matrix-js-sdk | Olm/Megolm keys |
| Session | localStorage | Auth credentials |
## Dependencies
- `matrix-js-sdk`
- IndexedDB (browser/Electron)
## Integration points
- **authentication**: session → initClient
- **state-management**: sync events feed atoms
- **background-notifications**: pushers registered after sync
- **e2e-and-devices**: crypto store, verification
- **plugins**: Matrix client exposed to plugin API
## Testing
### Manual
1. Fresh login; verify sync completes (timeline loads).
2. Reload app; session restores without re-login.
3. Logout; IndexedDB cleared appropriately.
4. Airplane mode → sync error → recovery on reconnect.
### Automated
- None
## Known issues & gotchas
- `handlingKeyConflict` flag in initMatrix for crypto key conflicts
- `deleteAllMatrixDatabases` on certain logout/clear paths — destructive
- Mobile `useVisibilitySync` restarts sync on app resume (Tauri)
- `clearCacheAndReload` utility for support scenarios
## Future work
- Lazy sync / sliding sync if migrating MSC
- Better sync error UX with retry backoff
## Related docs
- [authentication HANDOFF](../authentication/HANDOFF.md)
- [state-management HANDOFF](../state-management/HANDOFF.md)
- [e2e-and-devices HANDOFF](../e2e-and-devices/HANDOFF.md)
## Add your extra things here
- `logoutClient`, `clearLoginData`, `clearCacheAndReload` exported from initMatrix.ts
- `HttpApiEvent.SessionLoggedOut` listener in ClientRoot clears everything

View File

@@ -0,0 +1,90 @@
# Media & URL preview — handoff
## Summary
Handles file uploads, encrypted media, image/video viewers, thumbnails, and URL preview cards (Open Graph, Giphy, etc.). Upload progress shown via upload cards in the composer.
## User-facing behavior
- Attach files via `+` button, paste, or drag-drop
- Image/video lightbox viewers
- Link URLs expand to preview cards in timeline
- Encrypted attachments decrypted client-side
- Upload progress and cancel
## Architecture
```
UploadCardRenderer / upload state atoms
→ encryptFile (utils/matrix) for encrypted rooms
→ matrix uploadContent
url-preview/ components
→ fetch OG metadata, Giphy embed
→ gated by embed-filters
image-viewer/, video-viewer/ — fullscreen media
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/components/upload-card/UploadCardRenderer.tsx` | Upload UI |
| `cinny/src/app/state/upload.ts` | Upload state |
| `cinny/src/app/state/room/roomInputDrafts.ts` | `TUploadItem` |
| `cinny/src/app/utils/matrix.ts` | `encryptFile`, upload helpers |
| `cinny/src/app/components/url-preview/` | Link embed cards |
| `cinny/src/app/components/image-viewer/` | Image lightbox |
| `cinny/src/app/components/video-viewer/` | Video player |
| `cinny/src/app/hooks/useMediaAuthentication.ts` | Authenticated media URLs |
| `cinny/src/app/hooks/useAuthenticatedMediaUrl.ts` | MXC → HTTP with auth |
| `cinny/src/app/components/message/content/` | Image/video/thumbnail content |
## Data model
Upload queue in Jotai atom families (per room). Media itself on homeserver as MXC.
## Dependencies
- Homeserver media repository config (`useMediaConfig`)
- `matrix-js-sdk` encryption APIs
## Integration points
- **room-timeline**: attachment messages + URL embeds
- **embed-filters**: suppress previews
- **editor-and-composer**: upload from composer
- **avatar-metadata**: separate pipeline for profile images
## Testing
### Manual
1. Upload image in encrypted room; verify decrypt on receive.
2. Post URL; preview card appears.
3. Block URL pattern via embed filter; no card.
4. Open image in viewer; zoom/navigate.
### Automated
- None
## Known issues & gotchas
- Media authentication required on some homeservers (`useMediaAuthentication`)
- Large file memory on mobile
- Giphy embed has separate CSS module
## Future work
- Resumable uploads
- Blurhash placeholders
## Related docs
- [embed-filters HANDOFF](../embed-filters/HANDOFF.md)
- [room-timeline HANDOFF](../room-timeline/HANDOFF.md)
## Add your extra things here
- `ThumbnailContent.tsx`, `VideoContent.tsx` in message/content/
- Android share materializes files into upload draft (see android handoff)

View File

@@ -0,0 +1,89 @@
# Message rendering — handoff
## Summary
Built-in plugins parse and render message bodies: custom markdown (block + inline), HTML sanitization, syntax highlighting, matrix.to links, emoji shortcodes, bad-word filter hooks, and PDF preview support.
## User-facing behavior
- Messages render formatted text, code blocks with Prism highlighting
- Clickable matrix.to / room links
- Custom emoji from packs inline
- Spoiler/highlight/color extensions where enabled
## Architecture
```
RenderMessageContent.tsx
→ react-custom-html-parser.tsx
→ plugins/markdown/ (block + inline parsers)
→ plugins/react-prism/ReactPrism.tsx
→ plugins/emoji.ts, matrix-to.ts, color.ts
msgContent.ts — content type detection
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/components/RenderMessageContent.tsx` | Main render entry |
| `cinny/src/app/plugins/react-custom-html-parser.tsx` | HTML → React |
| `cinny/src/app/plugins/markdown/` | Markdown rules + parsers |
| `cinny/src/app/plugins/react-prism/ReactPrism.tsx` | Code highlighting |
| `cinny/src/app/plugins/emoji.ts` | Emoji shortcodes |
| `cinny/src/app/plugins/matrix-to.ts` | matrix.to link handling |
| `cinny/src/app/plugins/bad-words.ts` | Content filter hook |
| `cinny/src/app/plugins/color.ts` | Text color |
| `cinny/src/app/utils/sanitize.ts` | HTML sanitization |
| `cinny/src/app/features/room/msgContent.ts` | Message content helpers |
## Data model
None — pure functions over message content JSON.
## Dependencies
- `prismjs` (via react-prism)
- `pdfjs-dist` wrapper for PDF thumbnails
- `millify` for number formatting in embeds
## Integration points
- **room-timeline**: all message bodies
- **forum**: `forumRichText.ts`, `ForumMessageBody.tsx`
- **custom-emoji-packs**: emoji plugin reads pack data
- **embed-filters**: separate from body render — URL preview gating
## Testing
### Manual
1. Send markdown: bold, code fence, link.
2. Verify matrix.to link navigates in-app.
3. Custom emoji shortcode renders image.
4. XSS attempt in message — should sanitize.
### Automated
- None
## Known issues & gotchas
- Markdown rules split block vs inline — order matters in parser
- `bad-words.ts` may be no-op depending on config
- HTML from other clients may not match Paarrot markdown subset
## Future work
- CommonMark test suite
- Lazy-load Prism languages
## Related docs
- [room-timeline HANDOFF](../room-timeline/HANDOFF.md)
- [custom-emoji-packs HANDOFF](../custom-emoji-packs/HANDOFF.md)
## Add your extra things here
- `plugins/markdown/block/rules.ts` and `inline/rules.ts` — start here for new syntax
- `via-servers.ts` for link join hints

View File

@@ -0,0 +1,79 @@
# Message search — handoff
## Summary
In-room and cross-room message search UI: query input, filters, result grouping, and navigation to found events. Uses Matrix `/search` or client-side APIs via `useMessageSearch`.
## User-facing behavior
- Search modal or page with text query
- Filters: room, sender, date (see SearchFilters)
- Results grouped by room/period
- Click result → jump to event in timeline
## Architecture
```
MessageSearch.tsx
→ useMessageSearch.ts
→ SearchInput, SearchFilters, SearchResultGroup
Router may mount search under client routes
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/message-search/MessageSearch.tsx` | Main UI |
| `cinny/src/app/features/message-search/useMessageSearch.ts` | Search logic |
| `cinny/src/app/features/message-search/SearchInput.tsx` | Query field |
| `cinny/src/app/features/message-search/SearchFilters.tsx` | Filters |
| `cinny/src/app/features/message-search/SearchResultGroup.tsx` | Result list |
## Data model
Ephemeral search state in component hooks. No persistence.
## Dependencies
- `matrix-js-sdk` search API
- Homeserver must support message search (server-dependent)
## Integration points
- **routing-and-navigation**: search routes in home/space
- **room-timeline**: navigation to event id
- **global-search**: separate modal feature — see that handoff
## Testing
### Manual
1. Search known keyword; results appear.
2. Apply room filter; results scoped.
3. Click result; timeline scrolls to event.
4. Test on homeserver without search — expect graceful error.
### Automated
- None
## Known issues & gotchas
- Server-side search availability varies by deployment
- Encrypted room search limited by server/indexing policy
- Pagination in `useMessageSearch` — check for duplicate results
## Future work
- Client-side fallback search for small rooms
- Highlight match in timeline
## Related docs
- [global-search HANDOFF](../global-search/HANDOFF.md)
- [room-timeline HANDOFF](../room-timeline/HANDOFF.md)
## Add your extra things here
- Exported from `features/message-search/index.ts`

View File

@@ -0,0 +1,98 @@
# Plugin system — handoff
## Summary
Paarrot extends the Matrix client with JavaScript plugins loaded from disk or the marketplace. Plugins can register slash commands, inject UI buttons in 11 locations, hook Matrix events, register themes, and run background tasks. The runtime is `@paarrot/plugin-manager`; the app wires slots in settings, nav, composer, and room UI.
## User-facing behavior
- **Settings → Plugins**: Installed, Marketplace, enable/disable, uninstall
- Plugins live in OS-specific dirs (`%APPDATA%\paarrot\plugins\`, `~/.config/Paarrot/plugins/`, etc.)
- Marketplace pulls from [Plugin Directory](https://github.com/Paarrot/Plugin-Directory) JSON index
## Architecture
```
Plugin folder (index.js + plugin-metadata.json)
→ PluginLoader (reads, validates, enables)
→ @paarrot/plugin-manager (lifecycle, API surface)
→ PluginAPI.ts (app-specific bindings: Matrix client, navigate, notifications)
→ UI slots: PluginNavSlot, PluginButtonSlot, PluginSidebarSlot, ...
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/settings/plugins/PluginLoader.tsx` | Load/enable/disable plugins |
| `cinny/src/app/features/settings/plugins/PluginAPI.ts` | Re-exports plugin-manager + app hooks |
| `cinny/src/app/features/settings/plugins/Plugins.tsx` | Settings UI (installed + marketplace) |
| `cinny/src/app/features/settings/plugins/PluginButtonSlot.tsx` | Icon button injection |
| `cinny/src/app/features/settings/plugins/PluginNavSlot.tsx` | Nav row injection |
| `cinny/src/app/features/settings/plugins/PluginSidebarSlot.tsx` | Sidebar icon injection |
| `cinny/src/app/features/settings/plugins/types.ts` | Local types |
| `cinny/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md` | Author-facing dev guide |
| `example-plugin/` | Reference plugin at repo root |
| `cinny/docs/PLUGIN_*.md` | API reference docs |
## Data model
- Plugin metadata: `plugin-metadata.json` per plugin (id, version, permissions, `paarrot.utils` deps)
- Enabled state: persisted via plugin-manager (Electron userData)
- No Matrix events for plugins themselves
### UI locations (`UILocation`)
`channel-list`, `home-section`, `direct-messages`, `sidebar-actions`, `text-composer-toolbar`, `composer-actions`, `room-header`, `room-menu`, `message-actions`, `user-menu`, `search-notification-section`
## Dependencies
- `@paarrot/plugin-manager` (npm)
- Electron `fs` / IPC for plugin install paths
- GitHub raw URLs for marketplace index (`PluginAPI.ts`)
## Integration points
- **Matrix client**: exposed to plugins for send/receive
- **Local API**: separate from plugins; plugins do not use HTTP API by default
- **Settings**: plugin tabs via `PluginTab`
- **All major UI surfaces**: search codebase for `PluginButtonSlot` / `PluginNavSlot` usages
## Testing
### Manual
1. Copy `example-plugin/` to plugins directory.
2. Enable in Settings → Plugins.
3. Verify slash command and injected button appear.
4. Disable plugin; verify UI cleans up.
### Automated
- None dedicated; validate via example-plugin manually
## Known issues & gotchas
- Plugin API version must match `@paarrot/plugin-manager` expectations
- `ctx.require("other-plugin-id")` for inter-plugin deps
- Marketplace cache-bust query param on index fetch
- Plugin dev guide lives inside `src/` — consider linking from handoff only
## Future work
- Sandboxing / permission prompts per capability
- Hot reload without app restart
- Signed plugin packages
## Related docs
- [PLUGINS.md](../../PLUGINS.md)
- [PLUGIN_API.md](../../PLUGIN_API.md)
- [PLUGIN_BUTTON_API.md](../../PLUGIN_BUTTON_API.md)
- [PLUGIN_SYSTEM_IMPLEMENTATION.md](../../PLUGIN_SYSTEM_IMPLEMENTATION.md)
- [PLUGIN_DEVELOPMENT.md](../../../../src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md)
## Add your extra things here
- Marketplace base: `https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/`
- 11 UI injection points — see README plugin table in repo root

View File

@@ -0,0 +1,82 @@
# Room nav — handoff
## Summary
Room nav components render individual channel rows in sidebars and space channel lists: room items, category buttons, call participant indicators, sub-room actions, and unjoined sub-room join prompts.
## User-facing behavior
- Room row: name, avatar, unread badge, active highlight
- Context menu: add sub-room, room actions
- Call participants indicator when call active in room
- Unjoined sub-room row with join button
- Category headers with collapse
## Architecture
```
features/room-nav/
RoomNavItem → per-room row + menus
RoomNavCategoryButton → section headers
UnjoinedSubRoomItem → join flow
CallParticipantsIndicator → active call badge
Used by Home.tsx, space sidebars, forum sidebars
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/room-nav/RoomNavItem.tsx` | Main room row |
| `cinny/src/app/features/room-nav/RoomNavCategoryButton.tsx` | Category header |
| `cinny/src/app/features/room-nav/UnjoinedSubRoomItem.tsx` | Unjoined sub-room |
| `cinny/src/app/features/room-nav/CallParticipantsIndicator.tsx` | In-call indicator |
| `cinny/src/app/features/room-nav/styles.css.ts` | Nav styles |
## Data model
Uses room state, unread atoms, `im.paarrot.sub_rooms` permissions — no own persistence.
## Dependencies
- `usePermission` for sub-room create
- `useOpenCreateSubRoomModal` from state hooks
## Integration points
- **sub-rooms**: Add Sub-Room menu action
- **voice-calls**: participant indicator
- **create-flows**: opens create room modal with parent id
- **home-and-sidebar**: primary consumer
## Testing
### Manual
1. Right-click/context menu on room row.
2. Add sub-room when permitted / disabled when not.
3. Active call shows participant indicator.
4. Unjoined sub-room join navigates correctly.
### Automated
- None
## Known issues & gotchas
- Depth indentation for sub-rooms passed as prop — styling in CSS
- `canAddSubRoom` checks `StateEvent.PaarrotSubRooms` power level
## Future work
- Consolidate with forum sidebar items if duplicated
## Related docs
- [sub-rooms HANDOFF](../sub-rooms/HANDOFF.md)
- [home-and-sidebar HANDOFF](../home-and-sidebar/HANDOFF.md)
- [voice-calls HANDOFF](../voice-calls/HANDOFF.md)
## Add your extra things here
- Export barrel: `features/room-nav/index.ts`

View File

@@ -0,0 +1,81 @@
# Room settings — handoff
## Summary
Per-room settings overlay: general info, permissions, members (via common-settings), sub-rooms (Paarrot), and developer tools. Opened from room header/menu without leaving the room.
## User-facing behavior
- Edit room name, topic, avatar, address, join rules, encryption
- Permissions and power levels
- Sub-rooms management (Paarrot)
- Member list and moderation actions
## Architecture
```
RoomSettingsRenderer (Router overlay)
→ RoomSettings.tsx — page nav
→ room-settings/general/, permissions/, sub-rooms/
→ delegates to common-settings/ for shared pages
roomSettings.ts atom — which page open
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/room-settings/RoomSettings.tsx` | Settings shell |
| `cinny/src/app/features/room-settings/general/` | General tab |
| `cinny/src/app/features/room-settings/permissions/` | Permissions |
| `cinny/src/app/features/room-settings/sub-rooms/SubRooms.tsx` | Sub-rooms (Paarrot) |
| `cinny/src/app/state/roomSettings.ts` | Modal/page state |
| `cinny/src/app/state/hooks/roomSettings.ts` | Open/close hooks |
| `cinny/src/app/features/common-settings/` | Shared setting pages |
## Data model
Standard Matrix room state events (`m.room.name`, `m.room.power_levels`, etc.) plus `im.paarrot.sub_rooms`.
## Dependencies
- `useRoomPermissions`, `usePowerLevels`
- common-settings components
## Integration points
- **sub-rooms**: Paarrot-specific tab
- **common-settings**: members, emojis, developer tools reuse
- **embed-filters**: `EmbedFilters.tsx` in common-settings general
## Testing
### Manual
1. Open room settings from ⋮ menu.
2. Change topic; verify state event.
3. Sub-rooms tab: reorder children.
4. Permissions: change power level.
### Automated
- None
## Known issues & gotchas
- Overlay vs full page on mobile — check ScreenSize branches
- `RoomSettingsRenderer` must stay mounted in Router for deep links
## Future work
- Room settings deep link URL
## Related docs
- [sub-rooms HANDOFF](../sub-rooms/HANDOFF.md)
- [common-settings HANDOFF](../common-settings/HANDOFF.md)
- [embed-filters HANDOFF](../embed-filters/HANDOFF.md)
## Add your extra things here
- `SubRoomsPage` enum in `roomSettings.ts`

View File

@@ -0,0 +1,110 @@
# Room timeline — handoff
## Summary
The room view is the core messaging experience: virtualized timeline, message rendering, composer input, threads, reactions, pins, members drawer, and room header. Includes Paarrot-specific embed filter integration and command autocomplete.
## User-facing behavior
- Scrollable timeline with read markers, edits, redactions, reactions
- Rich composer with mentions, emoji, attachments, slash commands
- Thread side panel (`ThreadView`)
- Members drawer, room header actions, typing indicators
- Jump to time, pinned messages menu, reaction viewer
- Following mode / tombstone for upgraded rooms
## Architecture
```
Room.tsx / RoomView.tsx
→ RoomTimeline.tsx (virtualized events)
→ RoomInput.tsx + Editor
→ Message.tsx / RenderMessageContent.tsx
→ ThreadView.tsx
→ RoomViewHeader.tsx, MembersDrawer
Hooks: useVirtualPaginator, useRoomLatestRenderedEvent, useMarkAsRead
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/room/Room.tsx` | Room entry |
| `cinny/src/app/features/room/RoomView.tsx` | Main layout |
| `cinny/src/app/features/room/RoomTimeline.tsx` | Timeline |
| `cinny/src/app/features/room/RoomInput.tsx` | Composer |
| `cinny/src/app/features/room/ThreadView.tsx` | Thread panel |
| `cinny/src/app/features/room/RoomViewHeader.tsx` | Header bar |
| `cinny/src/app/features/room/message/Message.tsx` | Single message |
| `cinny/src/app/features/room/message/Reactions.tsx` | Reaction row |
| `cinny/src/app/features/room/message/MessageEditor.tsx` | Edit message |
| `cinny/src/app/features/room/CommandAutocomplete.tsx` | Slash commands |
| `cinny/src/app/features/room/reaction-viewer/ReactionViewer.tsx` | Who reacted |
| `cinny/src/app/features/room/jump-to-time/JumpToTime.tsx` | Time jump UI |
| `cinny/src/app/features/room/room-pin-menu/RoomPinMenu.tsx` | Pinned messages |
| `cinny/src/app/components/RenderMessageContent.tsx` | Content render + embeds |
| `cinny/src/app/hooks/useVirtualPaginator.ts` | Timeline pagination |
## Data model
| Source | Data |
|--------|------|
| Matrix sync | `m.room.message`, edits, reactions, pins |
| Room account data | drafts (`roomInputDrafts`) |
| `im.paarrot.embed_filters` | Personal embed blocks |
| `im.paarrot.room.embed_filters` | Room-wide embed blocks |
## Dependencies
- `matrix-js-sdk` timeline APIs
- Slate editor (composer)
- Embed filter hooks
- Plugin button slots in header/composer/message actions
## Integration points
- **editor-and-composer**: RoomInput uses Editor
- **embed-filters**: timeline + thread embed gating
- **message-rendering**: markdown/HTML parse pipeline
- **media-and-url-preview**: attachments and link cards
- **plugins**: message-actions, composer-actions slots
- **voice-calls**: header call controls
## Testing
### Manual
1. Send text, image, reply, edit, delete.
2. Open thread; reply in thread.
3. Add reaction; open reaction viewer.
4. Pin message; open pin menu.
5. Block embed domain; verify no preview.
6. Slash command from plugin.
### Automated
- None
## Known issues & gotchas
- Virtual paginator complexity — scroll position bugs common regression area
- Encrypted rooms: render path differs for `m.room.encrypted`
- `RoomViewFollowing` auto-scroll behavior vs manual scroll
- Large threads performance
## Future work
- Timeline unit tests for edit/redaction grouping
- Improved read receipt aggregation
## Related docs
- [editor-and-composer HANDOFF](../editor-and-composer/HANDOFF.md)
- [embed-filters HANDOFF](../embed-filters/HANDOFF.md)
- [message-rendering HANDOFF](../message-rendering/HANDOFF.md)
- [media-and-url-preview HANDOFF](../media-and-url-preview/HANDOFF.md)
## Add your extra things here
- `msgContent.ts` — message content helpers
- `useRoomNavigate` for permalink jumps

View File

@@ -0,0 +1,96 @@
# Routing & navigation — handoff
## Summary
React Router drives all app navigation: auth routes, client shell, home/direct/space/inbox/explore paths, room views, lobby/forum feeds, and modal overlays (settings, search, create). Supports hash router mode via client config.
## User-facing behavior
- URL reflects current room, space, inbox tab, explore server
- Mobile vs desktop route layouts (`MobileFriendly` wrappers)
- Deep links and `matrix.to` style joins via join route
- After-login redirect preserves intended destination
- View transitions on supported platforms
## Architecture
```
App.tsx → createRouter(clientConfig, screenSize)
→ Auth routes | ClientLayout + SidebarNav + Outlet
paths.ts — route constants
pathUtils.ts — path builders (getHomeRoomPath, getSpaceDefaultPath, ...)
Router.tsx — route tree + loaders/guards
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/pages/Router.tsx` | Full route tree |
| `cinny/src/app/pages/paths.ts` | Path constants |
| `cinny/src/app/pages/pathUtils.ts` | Path builder utilities |
| `cinny/src/app/pages/App.tsx` | RouterProvider setup |
| `cinny/src/app/pages/client/ClientLayout.tsx` | Authenticated layout shell |
| `cinny/src/app/pages/client/SidebarNav.tsx` | Left sidebar tabs |
| `cinny/src/app/pages/MobileFriendly.tsx` | Mobile nav adapters |
| `cinny/src/app/hooks/useRoomNavigate.ts` | Imperative room navigation |
| `cinny/src/app/hooks/useNavToActivePathMapper.ts` | Restore last path |
| `cinny/src/app/pages/afterLoginRedirectPath.ts` | Login redirect |
| `cinny/src/app/components/AnimatedOutlet.tsx` | Route transitions |
## Data model
| Key | Purpose |
|-----|---------|
| `navToActivePath` atom | Per-context last URL |
| URL path params | roomId, spaceId, eventId |
## Dependencies
- `react-router-dom` v6
- `clientConfig.hashRouter` for Electron/file protocol
## Integration points
- **forum**: `ForumAwareSpaceLayout`, `ForumFeedPage` routes
- **lobby-forums**: `_LOBBY_PATH`, `Lobby` component
- **join-before-navigate**: `_JOIN_PATH` route
- **local-api**: `setPaarrotNavigate` uses same path utils
- **settings/room-settings**: overlay renderers on client routes
## Testing
### Manual
1. Navigate home → room → back; URL updates correctly.
2. Open space; forum vs lobby path per room type.
3. Hash router mode (if enabled in config).
4. Mobile breakpoints: sidebar vs full-page nav.
5. Refresh on deep room URL — lands in same room.
### Automated
- None
## Known issues & gotchas
- `getSpaceDefaultPath` branches forum vs lobby — must stay in sync with forum handoff
- Hash router needed for some Electron `file://` deployments
- Many path helpers — prefer using `pathUtils` over string concat
## Future work
- Route-level code splitting audit
- Unified join/link handler
## Related docs
- [forum HANDOFF](../forum/HANDOFF.md)
- [lobby-forums HANDOFF](../lobby-forums/HANDOFF.md)
- [join-before-navigate HANDOFF](../join-before-navigate/HANDOFF.md)
- [home-and-sidebar HANDOFF](../home-and-sidebar/HANDOFF.md)
## Add your extra things here
- Key paths: `HOME_PATH`, `DIRECT_PATH`, `SPACE_PATH`, `INBOX_PATH`, `EXPLORE_PATH`, `_FEED_PATH`, `_LOBBY_PATH`
- `getAppPathFromHref` for parsing external links

View File

@@ -0,0 +1,73 @@
# Space settings — handoff
## Summary
Per-space settings overlay analogous to room settings: general space profile, permissions, and shared common-settings pages. Used for Matrix spaces (and forum spaces).
## User-facing behavior
- Edit space name, topic, avatar, visibility
- Space permissions and power levels
- Child space management via common flows
## Architecture
```
SpaceSettingsRenderer (Router overlay)
→ space-settings/SpaceSettings.tsx
→ general/, permissions/
spaceSettings.ts atom
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/space-settings/SpaceSettings.tsx` | Settings shell |
| `cinny/src/app/features/space-settings/general/` | General tab |
| `cinny/src/app/features/space-settings/permissions/` | Permissions |
| `cinny/src/app/state/spaceSettings.ts` | Open state |
| `cinny/src/app/state/hooks/spaceSettings.ts` | Hooks |
## Data model
Matrix space state: `m.space.child`, `m.room.name`, power levels, etc.
## Dependencies
- Same permission patterns as room settings
## Integration points
- **lobby-forums**: space metadata shown in lobby hero
- **create-flows**: add existing rooms to space
- **common-settings**: shared pages
## Testing
### Manual
1. Open space settings.
2. Edit space name and avatar.
3. Change join rules / visibility.
### Automated
- None
## Known issues & gotchas
- Forum spaces may expose different settings entry points — verify `ForumSpaceLayout`
## Future work
- Space-level embed filter defaults
## Related docs
- [common-settings HANDOFF](../common-settings/HANDOFF.md)
- [lobby-forums HANDOFF](../lobby-forums/HANDOFF.md)
## Add your extra things here
- `usePermissionItems` in space-settings/permissions/

View File

@@ -0,0 +1,99 @@
# State management — handoff
## Summary
Client UI state uses **Jotai** atoms: room lists, unread counts, settings, modals, drafts, navigation memory, and sidebar collapse. Many atoms sync to localStorage via `atomWithLocalStorage`.
## User-facing behavior
- Room list order, collapsed categories, sidebar folders persist across sessions
- Message drafts and upload queues per room persist locally
- Settings (theme, emoji style, zoom) persist locally
- Unread badges drive favicon and sidebar indicators
## Architecture
```
JotaiProvider (App.tsx)
→ atoms in state/
→ hooks in state/hooks/ wrap atoms for components
ClientBindAtoms.tsx binds Matrix events → atom updates
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/state/settings.ts` | User settings atom |
| `cinny/src/app/state/sessions.ts` | Auth sessions |
| `cinny/src/app/state/room-list/roomList.ts` | `allRoomsAtom`, ordering |
| `cinny/src/app/state/room/roomToUnread.ts` | Unread counts |
| `cinny/src/app/state/room/roomInputDrafts.ts` | Composer drafts + uploads |
| `cinny/src/app/state/room/roomToParents.ts` | Space parent mapping |
| `cinny/src/app/state/mDirectList.ts` | Direct message index |
| `cinny/src/app/state/navToActivePath.ts` | Last active path per context |
| `cinny/src/app/state/closedNavCategories.ts` | Collapsed nav sections |
| `cinny/src/app/state/closedLobbyCategories.ts` | Collapsed lobby categories |
| `cinny/src/app/state/createRoomModal.ts` | Create room modal state |
| `cinny/src/app/state/createSpaceModal.ts` | Create space modal state |
| `cinny/src/app/state/searchModal.ts` | Global search modal |
| `cinny/src/app/state/utils/atomWithLocalStorage.ts` | Persistence helper |
| `cinny/src/app/pages/client/ClientBindAtoms.ts` | Matrix → atom bindings |
## Data model
Most state is **local only** (not Matrix). Exceptions: settings that also write account data (notification prefs via separate hooks).
| Atom area | Persists? |
|-----------|-----------|
| settings | localStorage |
| room list order | localStorage |
| drafts | localStorage |
| unread | memory (derived from sync) |
| modals | memory |
## Dependencies
- `jotai`
- `@tanstack/react-query` (separate from Jotai, used in App.tsx)
## Integration points
- **matrix-client**: sync updates room/unread atoms via ClientBindAtoms
- **home-and-sidebar**: room list atoms
- **room-timeline**: draft atoms
- **routing-and-navigation**: navToActivePath
## Testing
### Manual
1. Type draft, reload — draft should restore.
2. Collapse category, reload — stays collapsed.
3. Reorder rooms; verify order persists.
4. Logout — verify which atoms clear vs persist.
### Automated
- None
## Known issues & gotchas
- Logout clears **all** localStorage in session-logout handler — wipes settings too
- Atom families (`roomIdToMsgDraftAtomFamily`) — memory growth in many rooms
- `ClientBindAtoms` must mount early or UI shows stale unreads
## Future work
- Selective localStorage clear on logout (keep non-sensitive prefs)
- Document atom dependency graph
## Related docs
- [matrix-client HANDOFF](../matrix-client/HANDOFF.md)
- [home-and-sidebar HANDOFF](../home-and-sidebar/HANDOFF.md)
## Add your extra things here
- `useSetting` hook in `state/hooks/settings.ts` — primary settings accessor
- `joiningProgress.ts` — room join UX state

View File

@@ -0,0 +1,82 @@
# Stream Deck integration — handoff
## Summary
The Elgato Stream Deck plugin (`streamdeck-plugin/`) controls Paarrot via the local HTTP API: toggle mute/deafen, switch channels, send messages, and show status. It is a separate package from the main app but ships in the same monorepo.
## User-facing behavior
- User installs `.streamDeckPlugin` or copies `com.paarrot.streamdeck.sdPlugin` into Stream Deck plugins folder
- Requires Paarrot desktop running with API on `http://127.0.0.1:33384`
- Actions: Toggle Mute, Toggle Deafen, Change Channel, Send Message, Get Status
## Architecture
```
Stream Deck button press
→ com.paarrot.streamdeck.sdPlugin/plugin/index.js
→ fetch http://127.0.0.1:33384/...
→ Paarrot Electron API (see local-api handoff)
```
## Key files
| Path | Role |
|------|------|
| `streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/manifest.json` | Plugin manifest |
| `streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.js` | Action implementations |
| `streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.html` | Property inspector UI |
| `streamdeck-plugin/build.js` | Build `.streamDeckPlugin` bundle |
| `streamdeck-plugin/validate.js` | Manifest validation |
| `streamdeck-plugin/README.md` | User install guide |
| `streamdeck-plugin/QUICKSTART.md` | Dev quickstart |
## Data model
None — stateless HTTP client. Channel list and status come from Paarrot API responses.
## Dependencies
- Elgato Stream Deck software 6.0+
- Paarrot local API (port 33384)
- Node build tooling in `streamdeck-plugin/package.json`
## Integration points
- **local-api**: sole backend; breaking API changes require Stream Deck plugin update
- Icons: `streamdeck-plugin/.../images/paarrot.svg`, `status.svg`
## Testing
### Manual
1. Run Paarrot; confirm `/health` responds.
2. Load plugin in Stream Deck developer mode.
3. Map Toggle Mute to a key; join a call; press key.
4. Map Change Channel; verify room switch in Paarrot.
### Automated
- `node streamdeck-plugin/validate.js`
## Known issues & gotchas
- Plugin repo README still references placeholder GitHub URLs in places — update on publish
- No auth on API; same machine trust model
- Deafen/mute only meaningful during active calls
## Future work
- Auto-discover rooms without manual room id entry in property inspector
- Connection status indicator on Stream Deck key
## Related docs
- [local-api HANDOFF](../local-api/HANDOFF.md)
- [API.md](../../API.md)
- [streamdeck-plugin/README.md](../../../../streamdeck-plugin/README.md)
## Add your extra things here
- Bundle id: `com.paarrot.streamdeck`
- Build output: `.streamDeckPlugin` via `npm run build` in `streamdeck-plugin/`

View File

@@ -0,0 +1,93 @@
# Sub-rooms — handoff
## Summary
Sub-rooms let a parent room own an ordered list of child rooms, displayed nested in the home channel list and manageable in room settings. Hierarchy is stored in Matrix room state (`im.paarrot.sub_rooms`), not only `m.space.child`, so channels can nest under a room without full space semantics.
## User-facing behavior
- Nested channels under a room in **Home** sidebar (indented tree)
- **Add Sub-Room** from room nav context menu (requires power level on `im.paarrot.sub_rooms`)
- **Room settings → Sub-rooms** to reorder/remove children
- Creating a sub-room from parent updates parent's `children` array
- Unjoined sub-rooms show as `UnjoinedSubRoomItem` with join affordance
## Architecture
```
Room state: im.paarrot.sub_rooms { children: string[] }
→ useRoomSubRooms hook
→ Home.tsx flatten tree (addRoomAndSubRooms)
→ RoomNavItem "Add Sub-Room" → CreateRoomModal
→ SubRooms.tsx settings editor
→ SpaceHierarchy.tsx (lobby) reads same state
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/hooks/useRoomSubRooms.ts` | Hook + `PaarrotSubRoomsContent` type |
| `cinny/src/types/matrix/room.ts` | `StateEvent.PaarrotSubRooms` |
| `cinny/src/app/pages/client/home/Home.tsx` | Renders nested list |
| `cinny/src/app/features/room-nav/RoomNavItem.tsx` | Add sub-room action |
| `cinny/src/app/features/room-nav/UnjoinedSubRoomItem.tsx` | Unjoined child UI |
| `cinny/src/app/features/room-settings/sub-rooms/SubRooms.tsx` | Settings page |
| `cinny/src/app/features/create-room/CreateRoomModal.tsx` | Appends new room to parent state |
| `cinny/src/app/features/lobby/SpaceHierarchy.tsx` | Lobby hierarchy integration |
## Data model
| Event | State key | Content |
|-------|-----------|---------|
| `im.paarrot.sub_rooms` | `""` (empty) | `{ children: string[] }` — room IDs in display order |
Power levels: treat like other state events; editors need `state_default` or explicit level for `im.paarrot.sub_rooms`.
## Dependencies
- `matrix-js-sdk` state events
- Parent room must exist and user must have permission to send state
## Integration points
- **Home navigation**: primary consumer of tree flattening
- **Lobby/forums**: `SpaceHierarchy` reads sub-rooms for display
- **Create room flow**: auto-updates parent on sub-room create
- Distinct from **m.space.child** space hierarchy (may coexist)
## Testing
### Manual
1. In a room where you have admin, add sub-room from nav menu.
2. Verify child appears indented under parent in Home.
3. Reorder in Room settings → Sub-rooms.
4. Leave child room; verify unjoined state UI.
5. Check lobby view shows hierarchy when applicable.
### Automated
- None
## Known issues & gotchas
- Only **joined** sub-rooms appear in home tree; unjoined listed separately
- Top-level filter: rooms that appear in any parent's `children` are excluded from root list
- Creating sub-room sends state event on **parent** — failure leaves orphan room
- `StateEvent.PaarrotSubRooms as any` casts in sendStateEvent — typing debt
## Future work
- Sync with `m.space.child` for federation-friendly hierarchy
- Drag-and-drop reorder in nav (DnD exists in lobby, not necessarily sub-rooms settings)
- Depth limits / circular reference guards
## Related docs
- [lobby-forums HANDOFF](../lobby-forums/HANDOFF.md)
## Add your extra things here
- Interface: `PaarrotSubRoomsContent { children: string[] }` in `useRoomSubRooms.ts`
- Permission check pattern: `permissions.stateEvent(StateEvent.PaarrotSubRooms, userId)`

View File

@@ -0,0 +1,89 @@
# Themes & appearance — handoff
## Summary
Theming covers light/dark/system themes, custom CSS variables, font choices, page zoom, message spacing, and view transitions. Themes can be extended by plugins.
## User-facing behavior
- Theme toggle in settings (light / dark / system)
- Page zoom slider (General settings)
- Message spacing density
- View transitions on navigation (where supported)
- Plugin-registered custom themes
## Architecture
```
ThemeManager.tsx — auth vs client theme application
settings.ts — theme, zoom, spacing in settingsAtom
useTheme.ts — read/apply theme
ClientNonUIFeatures — PageZoomFeature, EmojiStyleFeature
vanilla-extract / folds tokens in colors.css.ts
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/pages/ThemeManager.tsx` | Route-level theme |
| `cinny/src/app/state/settings.ts` | Settings including theme |
| `cinny/src/app/hooks/useTheme.ts` | Theme hook |
| `cinny/src/colors.css.ts` | Color tokens |
| `cinny/src/app/pages/client/ClientNonUIFeatures.tsx` | Page zoom CSS |
| `cinny/src/app/hooks/useViewTransitions.ts` | View transitions |
| `cinny/src/app/hooks/useMessageSpacing.ts` | Message density |
| `cinny/public/themes/` | Bundled theme files (if present) |
## Data model
| Key | Storage |
|-----|---------|
| `settingsAtom` | theme, pageZoom, messageSpacing, emojiStyle |
## Dependencies
- `folds` design system
- `@vanilla-extract/css` for component styles
- CSS `prefers-color-scheme` for system theme
## Integration points
- **plugins**: `registerTheme` API
- **user-settings**: General tab controls
- **forum**: `forumTheme.css.ts` forum-specific styling
## Testing
### Manual
1. Switch dark/light; all pages consistent.
2. System theme follows OS.
3. Page zoom 80150%; layout usable.
4. Plugin theme applies without breaking contrast.
### Automated
- None
## Known issues & gotchas
- Separate `AuthRouteThemeManager` and client theme — auth pages may differ
- Zoom uses `font-size` on `documentElement` — may affect rem layout globally
- Forum theme partially separate from global tokens
## Future work
- High contrast accessibility theme
- Per-room wallpaper from avatar metadata banner
## Related docs
- [user-settings HANDOFF](../user-settings/HANDOFF.md)
- [plugins HANDOFF](../plugins/HANDOFF.md)
- [avatar-metadata HANDOFF](../avatar-metadata/HANDOFF.md)
## Add your extra things here
- `EmojiStyle` enum affects `--font-emoji` CSS variable
- `useViewTransitions` tied to browser View Transitions API support

View File

@@ -0,0 +1,94 @@
# User settings — handoff
## Summary
Global user settings modal: general preferences, account profile, notifications, audio devices, sessions/devices, emojis, plugins, developer tools, and about. Accessed from sidebar Settings tab.
## User-facing behavior
- **General**: theme, language, page zoom, emoji style, message spacing
- **Account**: display name, avatar (with Paarrot metadata), matrix id
- **Notifications**: push rules, keywords, system permission
- **Audio**: input/output device selection for calls
- **Devices**: verify sessions, key backup, cross-signing
- **Plugins**: see plugins handoff
- **About**: version info
## Architecture
```
Settings.tsx — tabbed modal
→ general/, account/, notifications/, audio/, devices/, emojis-stickers/, plugins/, developer-tools/, about/
SettingsPages enum
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/settings/Settings.tsx` | Settings modal shell |
| `cinny/src/app/features/settings/general/General.tsx` | General prefs |
| `cinny/src/app/features/settings/account/Profile.tsx` | Profile + avatar metadata |
| `cinny/src/app/features/settings/notifications/` | Notification rules |
| `cinny/src/app/features/settings/audio/Audio.tsx` | Call audio devices |
| `cinny/src/app/features/settings/devices/` | Sessions & verification |
| `cinny/src/app/features/settings/about/About.tsx` | About page |
| `cinny/src/app/state/settings.ts` | Settings atom |
| `cinny/src/app/pages/client/sidebar/SettingsTab.tsx` | Entry point |
## Data model
| Key | Storage |
|-----|---------|
| `settingsAtom` | localStorage (theme, zoom, emoji style, etc.) |
| Profile | Matrix account data + avatar MXC |
| Push rules | Homeserver account data |
## Dependencies
- `folds` modal/page components
- Notification API, MediaDevices API
## Integration points
- **avatar-metadata**: profile color in avatar upload
- **emoji-stickers**: emojis tab
- **plugins**: plugins tab
- **e2e-and-devices**: devices tab overlaps
- **background-notifications**: notification settings
- **voice-calls**: audio device prefs
## Testing
### Manual
1. Change theme; persists reload.
2. Page zoom 125%; UI scales.
3. Notification keyword add; trigger match.
4. Audio device switch during call.
### Automated
- None
## Known issues & gotchas
- Settings modal vs mobile full-page — `useScreenSizeContext`
- Logout dialog from settings clears session
## Future work
- Settings search
- Export/import settings JSON
## Related docs
- [avatar-metadata HANDOFF](../avatar-metadata/HANDOFF.md)
- [emoji-stickers HANDOFF](../emoji-stickers/HANDOFF.md)
- [plugins HANDOFF](../plugins/HANDOFF.md)
- [e2e-and-devices HANDOFF](../e2e-and-devices/HANDOFF.md)
- [themes-and-appearance HANDOFF](../themes-and-appearance/HANDOFF.md)
## Add your extra things here
- `SettingsPages` enum lists all tabs — add new pages here + menu item

View File

@@ -0,0 +1,97 @@
# Voice & video calls — handoff
## Summary
Paarrot supports Matrix RTC voice/video calls via MSC3401 (`org.matrix.msc3401.call.member`) and LiveKit. Calls can be docked in the sidebar, support screen share, remote cursors, mute/deafen, and are wired to the local HTTP API for external control.
## User-facing behavior
- Join/start calls from room UI (when homeserver exposes RTC/LiveKit via `.well-known`)
- Docked call panel in sidebar; full overlay mode
- Mute, deafen, camera, screen share controls
- Call participants shown in room nav (`CallParticipantsIndicator`)
## Architecture
```
useCallService (CallProvider)
→ CallService (LiveKit room, Matrix call member state)
→ useRtcConfig: .well-known m.rtc + LiveKit focus
→ UI: CallOverlay, DockedCallPanel, SidebarDockedCallPanel
→ getCallService() global ref for Electron API (paarrot-api.ts)
```
## Key files
| Path | Role |
|------|------|
| `cinny/src/app/features/call/useCall.ts` | React context, global CallService ref |
| `cinny/src/app/features/call/CallService.ts` | Core call logic |
| `cinny/src/app/features/call/types.ts` | CallState, ActiveCall, events |
| `cinny/src/app/features/call/useRtcConfig.ts` | Homeserver RTC / LiveKit discovery |
| `cinny/src/app/features/call/CallOverlay.tsx` | Full-screen call UI |
| `cinny/src/app/features/call/DockedCallPanel.tsx` | Docked UI |
| `cinny/src/app/features/call/SidebarDockedCallPanel.tsx` | Sidebar variant |
| `cinny/src/app/features/call/useRemoteCursor.ts` | Screen-share cursor sync |
| `cinny/src/app/features/call/CallSounds.ts` | Join/leave sounds |
| `cinny/src/app/features/room-nav/CallParticipantsIndicator.tsx` | Nav badge |
| `cinny/src/types/matrix/room.ts` | `StateEvent.CallMember` |
## Data model
| Key | Scope | Notes |
|-----|-------|-------|
| `org.matrix.msc3401.call.member` | Room state | Call membership |
| LiveKit JWT / room | Ephemeral | From homeserver RTC focus |
Call mute/deafen/video state lives in `CallService` memory, not Matrix account data.
## Dependencies
- `livekit-client` (verify in package.json)
- Homeserver `.well-known` RTC configuration
- Browser/Electron media permissions (mic, camera, screen capture)
- Electron `desktopCapturer` for screen share (main process)
## Integration points
- **local-api**: mute/deafen/status endpoints
- **Stream Deck**: uses call mute/deafen via API
- **Room nav**: participant indicators
- **Electron main**: screen capture IPC if applicable
## Testing
### Manual
1. Use a homeserver with Matrix RTC + LiveKit configured.
2. Start voice call in a room; verify audio both ways.
3. Toggle mute/deafen; verify API `/status` reflects state.
4. Screen share; verify remote cursor if enabled.
5. Dock/undock call panel.
### Automated
- None identified
## Known issues & gotchas
- `callSupported` false until `.well-known` RTC fetch completes — UI should hide call buttons when unsupported
- `getCallService()` must be set before API mute actions work
- Platform differences: screen capture APIs differ Electron vs browser vs Android
## Future work
- Call recording, noise suppression settings
- Better reconnection / call transfer
- MSC3401 spec tracking as it stabilizes
## Related docs
- [local-api HANDOFF](../local-api/HANDOFF.md)
- Matrix MSC3401 (upstream spec)
## Add your extra things here
- `fetchWellKnownWithRTC`, `getLiveKitFocus`, `getLiveKitHomeserverPriority` in `useRtcConfig.ts`
- Global singleton: `globalCallServiceInstance` in `useCall.ts` for non-React consumers

View File

@@ -27,6 +27,10 @@
<meta name="theme-color" content="#000000" />
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Space+Grotesk:wght@400;600;700&display=swap"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />

9987
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,105 +20,112 @@
"author": "Ajay Bura",
"license": "AGPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource/inter": "4.5.14",
"@atlaskit/pragmatic-drag-and-drop": "2.0.1",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "3.0.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
"@fontsource-variable/inter": "5.2.8",
"@fontsource/caveat": "5.3.0",
"@fontsource/inter": "5.2.8",
"@paarrot/plugin-manager": "git+http://synbox.ruv.wtf:8418/litruv/plugin-manager.git",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
"@tauri-apps/api": "2.9.1",
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/plugin-fs": "2.4.5",
"@tauri-apps/plugin-http": "2.5.7",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-query-devtools": "5.101.2",
"@tanstack/react-virtual": "3.14.5",
"@tauri-apps/api": "2.11.1",
"@tauri-apps/plugin-dialog": "2.7.1",
"@tauri-apps/plugin-fs": "2.5.1",
"@tauri-apps/plugin-http": "2.5.9",
"@tauri-apps/plugin-notification": "2.3.3",
"@tauri-apps/plugin-opener": "2",
"@tauri-apps/plugin-process": "2.3.1",
"@tauri-apps/plugin-updater": "2.10.0",
"@tauri-apps/plugin-updater": "2.10.1",
"@types/pako": "2.0.4",
"@vanilla-extract/css": "1.9.3",
"@vanilla-extract/recipes": "0.3.0",
"@vanilla-extract/vite-plugin": "3.7.1",
"@vanilla-extract/css": "1.21.1",
"@vanilla-extract/recipes": "0.5.7",
"@vanilla-extract/vite-plugin": "5.2.4",
"await-to-js": "3.0.0",
"badwords-list": "2.0.1-4",
"blurhash": "2.0.4",
"blurhash": "2.0.5",
"browser-encrypt-attachment": "0.3.0",
"chroma-js": "3.1.2",
"classnames": "2.3.2",
"chroma-js": "3.2.0",
"classnames": "2.5.1",
"dateformat": "5.0.3",
"dayjs": "1.11.10",
"domhandler": "5.0.3",
"emojibase": "15.3.1",
"emojibase-data": "15.3.2",
"dayjs": "1.11.21",
"domhandler": "6.0.1",
"emojibase": "17.0.0",
"emojibase-data": "17.0.0",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "2.4.0",
"focus-trap-react": "12.0.3",
"folds": "2.7.0",
"gif.js": "0.2.0",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "2.5.2",
"immer": "9.0.16",
"html-dom-parser": "8.0.0",
"html-react-parser": "6.1.4",
"i18next": "26.3.5",
"i18next-browser-languagedetector": "8.2.1",
"i18next-http-backend": "4.0.0",
"immer": "11.1.11",
"is-hotkey": "0.2.0",
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"livekit-client": "2.17.0",
"jotai": "2.20.1",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"livekit-client": "2.20.0",
"lottie-web": "5.13.0",
"matrix-js-sdk": "38.2.0",
"lucide-react": "1.23.0",
"matrix-js-sdk": "41.9.0",
"millify": "6.1.0",
"pako": "2.1.0",
"pdfjs-dist": "4.2.67",
"pako": "3.0.1",
"pdfjs-dist": "6.1.200",
"prismjs": "1.30.0",
"react": "18.2.0",
"react-aria": "3.29.1",
"react-blurhash": "0.2.0",
"react-colorful": "5.6.1",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.13",
"react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0",
"react-range": "1.8.14",
"react-router-dom": "6.20.0",
"sanitize-html": "2.12.1",
"slate": "0.112.0",
"slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
"ua-parser-js": "1.0.35"
"react": "19.2.7",
"react-aria": "3.50.0",
"react-blurhash": "0.3.0",
"react-colorful": "5.7.0",
"react-dom": "19.2.7",
"react-error-boundary": "6.1.2",
"react-google-recaptcha": "3.1.0",
"react-i18next": "17.0.8",
"react-range": "1.10.0",
"react-router-dom": "7.18.1",
"sanitize-html": "2.17.5",
"slate": "0.124.1",
"slate-dom": "0.124.1",
"slate-history": "0.113.1",
"slate-react": "0.125.1",
"ua-parser-js": "2.0.10"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
"@types/chroma-js": "3.1.1",
"@types/file-saver": "2.0.5",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
"@types/file-saver": "2.0.7",
"@types/is-hotkey": "0.1.10",
"@types/node": "18.11.18",
"@types/prismjs": "1.26.0",
"@types/react": "18.2.39",
"@types/react-dom": "18.2.17",
"@types/react-google-recaptcha": "2.1.8",
"@types/sanitize-html": "2.9.0",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
"@vitejs/plugin-react": "4.2.0",
"@types/node": "26.1.1",
"@types/prismjs": "1.26.6",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "2.1.9",
"@types/sanitize-html": "2.16.1",
"@types/ua-parser-js": "0.7.39",
"@typescript-eslint/eslint-plugin": "8.63.0",
"@typescript-eslint/parser": "8.63.0",
"@vitejs/plugin-react": "6.0.3",
"buffer": "6.0.3",
"eslint": "8.29.0",
"eslint": "10.6.0",
"eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "4.6.0",
"prettier": "2.8.1",
"typescript": "4.9.4",
"vite": "5.4.19",
"vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4"
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"prettier": "3.9.4",
"typescript": "6.0.3",
"vite": "8.1.3",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-top-level-await": "1.6.0"
},
"allowScripts": {
"@swc/core@1.15.43": true,
"esbuild@0.28.1": true
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.05922,0,0,1.05922,-0.562821,-0.480534)">
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
<path d="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z"/>
</g>
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
<path d="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z"/>
</g>
<g transform="matrix(1.6762,0,0,1.6762,-4.50707,-10.7901)">
<path d="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,19 +1,6 @@
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Text,
Icon,
Icons,
IconButton,
Input,
Button,
TextArea as TextAreaComponent,
color,
Spinner,
Chip,
Scroll,
config,
} from 'folds';
import { Box, Text, IconButton, Input, Button, TextAreaComponent as TextArea, color, Spinner, Chip, Scroll, config } from 'folds';
import { Icon, Icons } from './icons';
import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area';
import { syntaxErrorPosition } from '../utils/dom';

View File

@@ -1,16 +1,53 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
const decodeSegment = (segment: string): string => {
let decoded = segment;
try {
let next = decodeURIComponent(decoded);
while (next !== decoded) {
decoded = next;
next = decodeURIComponent(decoded);
}
} catch {
// keep partially decoded value
}
return decoded;
};
/**
* Wrapper for Outlet that adds route-based animation
* Forces remount on route change by using location as key
* Room routes are `:roomIdOrAlias/:eventId?/`. Jumping to an event (or clearing it)
* changes the pathname but not the room — keep the outlet mounted so we don't replay
* the route enter animation or remount drawers like Shared Media.
*
* Path segments are decoded so `!room:server` and `%21room%3Aserver` share a key.
*/
const getOutletTransitionKey = (pathname: string): string => {
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
if (segments.length === 0) return pathname;
// Matrix event IDs start with `$`
if (segments[segments.length - 1].startsWith('$')) {
segments.pop();
}
return `/${segments.join('/')}/`;
};
/**
* Wrapper for Outlet that adds route-based animation.
* Remounts (and animates) when leaving a room / switching rooms, not on same-room event hops.
*/
export function AnimatedOutlet() {
const location = useLocation();
const transitionKey = useMemo(
() => getOutletTransitionKey(location.pathname),
[location.pathname]
);
return (
<div
key={location.pathname}
key={transitionKey}
data-route-transition="true"
style={{
flex: 1,

View File

@@ -1,86 +1,11 @@
import { ReactNode, useCallback } from 'react';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import {
getDirectPath,
getExplorePath,
getHomePath,
getInboxPath,
getSpacePath,
} from '../pages/pathUtils';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
import { ReactNode } from 'react';
import { useBackRoute } from '../hooks/useBackRoute';
type BackRouteHandlerProps = {
children: (onBack: () => void) => ReactNode;
};
export function BackRouteHandler({ children }: BackRouteHandlerProps) {
const navigate = useNavigate();
const location = useLocation();
const goBack = useCallback(() => {
if (
matchPath(
{
path: HOME_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getHomePath());
return;
}
if (
matchPath(
{
path: DIRECT_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getDirectPath());
return;
}
const spaceMatch = matchPath(
{
path: SPACE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
);
if (spaceMatch?.params.spaceIdOrAlias) {
navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias));
return;
}
if (
matchPath(
{
path: EXPLORE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getExplorePath());
return;
}
if (
matchPath(
{
path: INBOX_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getInboxPath());
}
}, [navigate, location]);
const { goBack } = useBackRoute();
return children(goBack);
}

View File

@@ -1,23 +1,8 @@
import React, { MouseEventHandler, useCallback, useState } from 'react';
import { useAtom } from 'jotai';
import { CryptoApi, KeyBackupInfo } from 'matrix-js-sdk/lib/crypto-api';
import {
Badge,
Box,
Button,
color,
config,
Icon,
IconButton,
Icons,
Menu,
percent,
PopOut,
ProgressBar,
RectCords,
Spinner,
Text,
} from 'folds';
import { Badge, Box, Button, color, config, IconButton, Menu, percent, PopOut, ProgressBar, RectCords, Spinner, Text } from 'folds';
import { Icon, Icons } from './icons';
import FocusTrap from 'focus-trap-react';
import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore';
import { InfoCard } from './info-card';

View File

@@ -6,21 +6,8 @@ import {
} from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import {
Box,
Button,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
} from 'folds';
import { Box, Button, config, Dialog, Header, IconButton, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text } from 'folds';
import { Icon, Icons } from './icons';
import FocusTrap from 'focus-trap-react';
import {
useVerificationRequestPhase,

View File

@@ -1,18 +1,6 @@
import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react';
import {
Dialog,
Header,
Box,
Text,
IconButton,
Icon,
Icons,
config,
Button,
Chip,
color,
Spinner,
} from 'folds';
import { Dialog, Header, Box, Text, IconButton, config, Button, Chip, color, Spinner } from 'folds';
import { Icon, Icons } from './icons';
import FileSaver from 'file-saver';
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';

View File

@@ -1,18 +1,6 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import {
config,
Box,
MenuItem,
Text,
Icon,
Icons,
IconSrc,
RectCords,
PopOut,
Menu,
Button,
Spinner,
} from 'folds';
import { config, Box, MenuItem, Text, RectCords, PopOut, Menu, Button, Spinner } from 'folds';
import { Icon, Icons, IconSrc } from './icons';
import { JoinRule } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';

View File

@@ -1,17 +1,6 @@
import React, { MouseEventHandler, ReactNode, useCallback, useState } from 'react';
import {
Box,
Text,
Chip,
Icon,
Icons,
RectCords,
PopOut,
Menu,
config,
MenuItem,
color,
} from 'folds';
import { Box, Text, Chip, RectCords, PopOut, Menu, config, MenuItem, color } from 'folds';
import { Icon, Icons } from './icons';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
import { SettingTile } from './setting-tile';

View File

@@ -2,24 +2,8 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { FormEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import {
Box,
Button,
Chip,
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
PopOut,
RectCords,
Scroll,
Spinner,
Text,
as,
config,
} from 'folds';
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
import { Icon, Icons } from '../icons';
import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver';
import * as css from './PdfViewer.css';

View File

@@ -159,6 +159,24 @@ export function RenderMessageContent({
</>
);
// Check for custom renderer from plugins BEFORE standard type handling
// so plugins can intercept any msgtype including custom ones.
const customRendererEarly = pluginRegistry.getRenderer('message');
if (customRendererEarly) {
try {
const messageDataEarly = {
msgtype: msgType,
...getContent(),
};
const customContentEarly = customRendererEarly(messageDataEarly, () => null);
if (customContentEarly) {
return customContentEarly as React.ReactElement;
}
} catch (error) {
console.error('[RenderMessageContent] Custom renderer error (early):', error);
}
}
if (msgType === MsgType.Text) {
return (
<MText

View File

@@ -1,4 +1,5 @@
import { Box, config, Icon, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { Box, config, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { Icon } from './icons';
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';

View File

@@ -1,20 +1,20 @@
import React, { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { css } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
const transitionContainer = css({
const transitionContainer = style({
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
});
const transitionContent = css({
const transitionContent = style({
width: '100%',
height: '100%',
});
const animateIn = css({
const animateIn = style({
animation: 'routeFadeSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'@keyframes': {
routeFadeSlideIn: {
@@ -30,7 +30,7 @@ const animateIn = css({
},
});
const animateOut = css({
const animateOut = style({
animation: 'routeFadeSlideOut 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
'@keyframes': {
routeFadeSlideOut: {

View File

@@ -1,17 +1,6 @@
import React, { ReactNode } from 'react';
import {
Overlay,
OverlayBackdrop,
Box,
config,
Text,
TooltipProvider,
Tooltip,
Icons,
Icon,
Chip,
IconButton,
} from 'folds';
import { Overlay, OverlayBackdrop, Box, config, Text, TooltipProvider, Tooltip, Chip, IconButton } from 'folds';
import { Icons, Icon } from './icons';
import FocusTrap from 'focus-trap-react';
export type UIAFlowOverlayProps = {

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Avatar, Box, Icon, Icons, MenuItem, Text, config } from 'folds';
import { Avatar, Box, MenuItem, Text, config } from 'folds';
import { Icon, Icons } from '../icons';
import { useAtomValue } from 'jotai';
import { sessionsAtom, Session } from '../../state/sessions';
import { setLocalStorageItem } from '../../state/utils/atomWithLocalStorage';

View File

@@ -1,20 +1,5 @@
import {
Box,
Button,
Chip,
config,
Icon,
Icons,
Input,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Text,
toRem,
} from 'folds';
import { Box, Button, Chip, config, Input, Line, Menu, MenuItem, PopOut, RectCords, Scroll, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';
import React, {

View File

@@ -7,7 +7,8 @@ import React, {
useState,
} from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
import { Box, color, Input, Spinner, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import { isKeyHotkey } from 'is-hotkey';
import { getMxIdServer } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { Box, Text, config } from 'folds';
import { Icon, Icons, IconSrc } from '../icons';
import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile';

View File

@@ -1,17 +1,6 @@
import React, { MouseEventHandler, useState } from 'react';
import {
Box,
Button,
Chip,
config,
Icon,
Icons,
Menu,
PopOut,
RectCords,
Text,
toRem,
} from 'folds';
import { Box, Button, Chip, config, Menu, PopOut, RectCords, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import FocusTrap from 'focus-trap-react';
import { SettingTile } from '../setting-tile';
import { SequenceCard } from '../sequence-card';

View File

@@ -82,6 +82,12 @@ export const createRoomEncryptionState = () => ({
},
});
/**
* Partial power levels for MatrixRTC call membership (MSC3401).
* Do NOT put this in createRoom initial_state — homeservers reject incomplete
* m.room.power_levels with 403. Apply after create via sendStateEvent instead,
* or rely on TrustedPrivateChat (both users PL 100) for DMs.
*/
export const createRoomPowerLevelsState = () => ({
type: StateEvent.RoomPowerLevels,
state_key: '',

View File

@@ -1,20 +1,7 @@
import React, { MouseEventHandler, forwardRef, useMemo, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { Avatar, Box, Button, IconButton, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';

View File

@@ -16,6 +16,8 @@ export const EditorOptions = style([
DefaultReset,
{
padding: config.space.S200,
display: 'flex',
alignItems: 'center',
},
]);
@@ -26,7 +28,7 @@ export const EditorTextarea = style([
{
flexGrow: 1,
height: '100%',
padding: `${toRem(13)} ${toRem(1)}`,
padding: `${toRem(10)} ${toRem(1)}`,
selectors: {
[`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13),
@@ -54,7 +56,7 @@ export const EditorPlaceholderTextVisual = style([
DefaultReset,
{
display: 'block',
paddingTop: toRem(13),
paddingTop: toRem(10),
paddingLeft: toRem(1),
},
]);

View File

@@ -1,17 +1,8 @@
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
config,
Icon,
IconButton,
Icons,
Line,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import { config, IconButton, Line, Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import { Icon, Icons } from '../icons';
import { CustomEditor, useEditor } from './Editor';
import { Toolbar } from './Toolbar';
import { stopPropagation } from '../../utils/keyboard';

View File

@@ -8,7 +8,7 @@ import React, {
useState,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
import { Descendant, Editor, createEditor, Element as SlateElement, Text as SlateText } from 'slate';
import {
Slate,
Editable,
@@ -83,6 +83,8 @@ type CustomEditorProps = {
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
/** Stretch the editable area to fill the parent (e.g. post modal). */
fillHeight?: boolean;
editor: Editor;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
@@ -99,6 +101,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
before,
after,
maxHeight = '50vh',
fillHeight = false,
editor,
placeholder,
onKeyDown,
@@ -179,11 +182,27 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[]
);
const scrollStyle = fillHeight
? { flex: '1 1 auto', minHeight: maxHeight, height: '100%' }
: { maxHeight };
return (
<div className={css.Editor} ref={ref}>
<div
className={css.Editor}
ref={ref}
style={
fillHeight
? { display: 'flex', flexDirection: 'column', height: '100%', minHeight: maxHeight }
: undefined
}
>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
<Box
alignItems={fillHeight ? 'Start' : 'Center'}
grow={fillHeight ? 'Yes' : undefined}
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
>
{before && (
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
{before}
@@ -192,8 +211,8 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
<Scroll
className={css.EditorTextareaScroll}
variant="SurfaceVariant"
style={{ maxHeight }}
size="300"
style={scrollStyle}
size="0"
visibility="Hover"
hideTrack
>

View File

@@ -82,13 +82,21 @@ function RenderEmoticonElement({
const selected = useSelected();
const focused = useFocused();
// Void inline: attributes + contentEditable={false} on the same root, children
// as a sibling of the visual (not nested inside another non-editable span).
// Nesting {children} under an inner contentEditable={false} breaks Slate's
// DOM↔node map and crashes ReactEditor.focus after insert.
return (
<span className={css.EmoticonBase} {...attributes}>
<span
{...attributes}
contentEditable={false}
className={css.EmoticonBase}
style={{ userSelect: 'none' }}
>
<span
className={css.Emoticon({
focus: selected && focused,
})}
contentEditable={false}
>
{element.key.startsWith('mxc://') ? (
<img
@@ -99,8 +107,8 @@ function RenderEmoticonElement({
) : (
element.key
)}
{children}
</span>
{children}
</span>
);
}

View File

@@ -1,22 +1,6 @@
import FocusTrap from 'focus-trap-react';
import {
Badge,
Box,
config,
Icon,
IconButton,
Icons,
IconSrc,
Line,
Menu,
PopOut,
RectCords,
Scroll,
Text,
Tooltip,
TooltipProvider,
toRem,
} from 'folds';
import { Badge, Box, config, IconButton, Line, Menu, PopOut, RectCords, Scroll, Text, Tooltip, TooltipProvider, toRem } from 'folds';
import { Icon, Icons, IconSrc } from '../icons';
import React, { MouseEventHandler, ReactNode, useState } from 'react';
import { ReactEditor, useSlate } from 'slate-react';
import {
@@ -80,7 +64,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
radii="300"
disabled={disableInline}
>
<Icon size="200" src={icon} />
<Icon size="100" src={icon} />
</IconButton>
)}
</TooltipProvider>
@@ -111,7 +95,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
size="400"
radii="300"
>
<Icon size="200" src={icon} />
<Icon size="100" src={icon} />
</IconButton>
)}
</TooltipProvider>
@@ -168,7 +152,7 @@ export function HeadingBlockButton() {
size="400"
radii="300"
>
<Icon size="200" src={Icons.Heading1} />
<Icon size="100" src={Icons.Heading1} />
</IconButton>
)}
</TooltipProvider>
@@ -183,7 +167,7 @@ export function HeadingBlockButton() {
size="400"
radii="300"
>
<Icon size="200" src={Icons.Heading2} />
<Icon size="100" src={Icons.Heading2} />
</IconButton>
)}
</TooltipProvider>
@@ -198,7 +182,7 @@ export function HeadingBlockButton() {
size="400"
radii="300"
>
<Icon size="200" src={Icons.Heading3} />
<Icon size="100" src={Icons.Heading3} />
</IconButton>
)}
</TooltipProvider>
@@ -215,8 +199,8 @@ export function HeadingBlockButton() {
size="400"
radii="300"
>
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
<Icon size="100" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
<Icon size="100" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
</IconButton>
</PopOut>
);
@@ -352,7 +336,7 @@ export function Toolbar() {
radii="300"
disabled={disableInline || !!isAnyMarkActive(editor)}
>
<Icon size="200" src={Icons.Markdown} filled={isMarkdown} />
<Icon size="100" src={Icons.Markdown} filled={isMarkdown} />
</IconButton>
)}
</TooltipProvider>

View File

@@ -1,6 +1,7 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { Avatar, MenuItem, Text } from 'folds';
import { Icon, Icons } from '../../icons';
import { JoinRule, MatrixClient } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';

View File

@@ -1,6 +1,7 @@
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { Avatar, MenuItem, Text } from 'folds';
import { Icon, Icons } from '../../icons';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery';

View File

@@ -1,4 +1,5 @@
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { BlockType, MarkType } from './types';
import {
CommandElement,
@@ -206,6 +207,29 @@ export const moveCursor = (editor: Editor, withSpace?: boolean) => {
if (withSpace) editor.insertText(' ');
};
/**
* Focus the editor after React has committed DOM for recent transforms.
* Calling ReactEditor.focus immediately after insertNode/insertText (e.g. emoji +
* trailing space) races Slate's DOM map and throws:
* "Cannot resolve a DOM node from Slate node: {"text":" "}"
*/
export const safeFocusEditor = (editor: Editor) => {
const tryFocus = () => {
try {
ReactEditor.focus(editor);
return true;
} catch {
return false;
}
};
// Defer past the current React commit; retry once if the DOM map isn't ready yet.
requestAnimationFrame(() => {
if (tryFocus()) return;
setTimeout(tryFocus, 0);
});
};
interface PointUntilCharOptions {
match: (char: string) => boolean;
reverse?: boolean;

View File

@@ -9,7 +9,8 @@ import React, {
useMemo,
useRef,
} from 'react';
import { Box, config, Icons, Scroll } from 'folds';
import { Box, config, Scroll } from 'folds';
import { Icons } from '../icons';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
@@ -21,7 +22,7 @@ import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { useEmojiUsage } from '../../hooks/useEmojiUsage';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
@@ -53,6 +54,7 @@ import {
import { EmojiBoardTab, EmojiType } from './types';
import { VirtualTile } from '../virtualizer';
const MOST_USED_GROUP_ID = 'most_used_group';
const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
@@ -72,19 +74,29 @@ const useGroups = (
imagePacks: ImagePack[]
): [EmojiGroupItem[], StickerGroupItem[]] => {
const mx = useMatrixClient();
const { getTopEmojis } = useEmojiUsage();
const recentEmojis = useRecentEmoji(mx, 21);
const labels = useEmojiGroupLabels();
const emojiGroupItems = useMemo(() => {
const g: EmojiGroupItem[] = [];
if (tab !== EmojiBoardTab.Emoji) return g;
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
items: recentEmojis,
});
// Add most-used emojis
const topShortcodes = getTopEmojis(21);
if (topShortcodes.length > 0) {
const mostUsedEmojis = topShortcodes
.map((shortcode) => emojis.find((e) => e.shortcode === shortcode))
.filter((e): e is IEmoji => e !== undefined);
if (mostUsedEmojis.length > 0) {
g.push({
id: MOST_USED_GROUP_ID,
name: 'Most Used',
items: mostUsedEmojis,
});
}
}
imagePacks.forEach((pack) => {
let label = pack.meta.name;
@@ -108,7 +120,7 @@ const useGroups = (
});
return g;
}, [mx, recentEmojis, labels, imagePacks, tab]);
}, [mx, labels, imagePacks, tab, getTopEmojis]);
const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = [];
@@ -187,10 +199,10 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
<Sidebar>
<SidebarStack>
<GroupIcon
active={activeGroupId === RECENT_GROUP_ID}
id={RECENT_GROUP_ID}
label="Recent"
icon={Icons.RecentClock}
active={activeGroupId === MOST_USED_GROUP_ID}
id={MOST_USED_GROUP_ID}
label="Most Used"
icon={Icons.Heart}
onClick={handleScrollToGroup}
/>
</SidebarStack>
@@ -378,13 +390,30 @@ export function EmojiBoard({
addToRecentEmoji = true,
}: EmojiBoardProps) {
const mx = useMatrixClient();
const { trackEmojiUsage, getMostUsedEmoji } = useEmojiUsage();
const emojiTab = tab === EmojiBoardTab.Emoji;
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
// Get default emoji - use most used or fallback to slight_smile
const defaultPreview = useMemo((): PreviewData => {
if (!emojiTab) return undefined;
const mostUsedShortcode = getMostUsedEmoji();
if (mostUsedShortcode) {
// Find emoji by shortcode
const emoji = emojis.find((e) => e.shortcode === mostUsedShortcode);
if (emoji) {
return { key: emoji.unicode, shortcode: emoji.shortcode };
}
}
// Fallback to default
return DefaultEmojiPreview;
}, [emojiTab, getMostUsedEmoji]);
const previewAtom = useMemo(
() => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined),
[emojiTab]
() => createPreviewDataAtom(defaultPreview),
[defaultPreview]
);
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
@@ -439,6 +468,10 @@ export function EmojiBoard({
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
addRecentEmoji(mx, emojiInfo.data);
// Track emoji usage for favorites
trackEmojiUsage(emojiInfo.shortcode).catch((err) => {
console.error('Failed to track emoji usage:', err);
});
}
}
if (emojiInfo.type === EmojiType.CustomEmoji) {

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Box, toRem, config, Icons, Icon, Text } from 'folds';
import { Box, toRem, config, Text } from 'folds';
import { Icons, Icon } from '../../icons';
export function NoStickerPacks() {
return (
<Box

View File

@@ -1,5 +1,6 @@
import React, { ChangeEventHandler, useRef } from 'react';
import { Input, Chip, Icon, Icons, Text } from 'folds';
import { Input, Chip, Text } from 'folds';
import { Icon, Icons } from '../../icons';
import { mobileOrTablet } from '../../../utils/user-agent';
type SearchInputProps = {

View File

@@ -1,17 +1,6 @@
import React, { ReactNode } from 'react';
import {
Box,
Scroll,
Line,
as,
TooltipProvider,
Tooltip,
Text,
IconButton,
Icon,
IconSrc,
Icons,
} from 'folds';
import { Box, Scroll, Line, as, TooltipProvider, Tooltip, Text, IconButton } from 'folds';
import { Icon, IconSrc, Icons } from '../../icons';
import classNames from 'classnames';
import * as css from './styles.css';

View File

@@ -1,5 +1,6 @@
import { IconSrc, Icons } from '../icons';
import { useMemo } from 'react';
import { IconSrc, Icons } from 'folds';
import { EmojiGroupId } from '../../plugins/emoji';

View File

@@ -1,18 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import {
Avatar,
Box,
Header,
Icon,
IconButton,
Icons,
MenuItem,
Scroll,
Text,
as,
config,
} from 'folds';
import { Avatar, Box, Header, IconButton, MenuItem, Scroll, Text, as, config } from 'folds';
import { Icon, Icons } from '../icons';
import { Room } from 'matrix-js-sdk';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { getMemberDisplayName } from '../../utils/room';

View File

@@ -0,0 +1,43 @@
import React, { CSSProperties, forwardRef } from 'react';
import classNames from 'classnames';
import type { LucideIcon } from 'lucide-react';
import { ICON_PIXEL_SIZES } from './iconSizes';
import type { IconSize } from './types';
import * as iconStyles from './style.css';
export type IconProps = {
src: LucideIcon;
size?: IconSize;
filled?: boolean;
className?: string;
style?: CSSProperties;
fill?: string;
} & Omit<React.SVGAttributes<SVGSVGElement>, 'fill'>;
export const Icon = forwardRef<SVGSVGElement, IconProps>(
({ className, size = '400', filled = false, src: LucideIconComponent, style, fill, ...props }, ref) => {
const pixelSize = ICON_PIXEL_SIZES[size];
return (
<LucideIconComponent
ref={ref}
className={classNames(iconStyles.Icon({ size }), className)}
style={
size === 'Inherit'
? { width: '1em', height: '1em', ...style }
: pixelSize
? { width: pixelSize, height: pixelSize, ...style }
: style
}
// Lucide icons are stroke-based; solid fill turns faces (e.g. Smile) into blobs.
// Emphasize selection with a heavier stroke instead.
strokeWidth={filled ? 2.75 : 2}
fill={fill ?? 'none'}
aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true}
{...props}
/>
);
}
);
Icon.displayName = 'Icon';

View File

@@ -0,0 +1,13 @@
import type { IconSize } from './types';
/** App chrome icons; ~4% above the original folds scale after +30% then -20%. */
export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = {
'50': 10,
'100': 13,
'200': 17,
'300': 21,
'400': 25,
'500': 29,
'600': 34,
Inherit: undefined,
};

View File

@@ -0,0 +1,4 @@
export { Icon } from './Icon';
export type { IconProps } from './Icon';
export { Icons } from './lucideIcons';
export type { IconName, IconSrc, IconSize } from './types';

View File

@@ -0,0 +1,238 @@
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpDown,
AtSign,
Ban,
Bell,
BellDot,
BellOff,
BellRing,
Bold,
Bookmark,
Boxes,
CaseSensitive,
Check,
CheckCheck,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Circle,
CirclePlus,
Clock,
Code,
Code2,
Compass,
CornerUpLeft,
CornerUpRight,
Download,
ExternalLink,
Eye,
EyeOff,
File,
FileText,
Filter,
Flag,
Globe,
Hash,
Heading1,
Heading2,
Heading3,
Headphones,
Heart,
History,
Home,
Image,
ImagePlay,
Inbox,
Info,
Italic,
Leaf,
Lightbulb,
Link,
List,
ListOrdered,
Lock,
LogOut,
Mail,
MailPlus,
MessageSquare,
MessageSquareDot,
MessageSquarePlus,
MessagesSquare,
Mic,
MicOff,
Minus,
Monitor,
MoreHorizontal,
MoreVertical,
Paperclip,
Pause,
Pencil,
Phone,
Pin,
Play,
Plus,
Power,
Quote,
RefreshCw,
Reply,
Search,
Send,
Server,
Settings,
Shield,
ShieldUser,
Smile,
SmilePlus,
Sparkles,
Star,
Strikethrough,
Sun,
Terminal,
Trash2,
TriangleAlert,
Trophy,
Type,
Underline,
User,
UserPlus,
Video,
VideoOff,
Volume2,
VolumeX,
X,
} from 'lucide-react';
export const Icons = {
Alphabet: CaseSensitive,
AlphabetUnderline: Underline,
ArrowBottom: ArrowDown,
ArrowDropBottom: ChevronDown,
ArrowDropLeft: ChevronLeft,
ArrowDropRight: ChevronRight,
ArrowDropTop: ChevronUp,
ArrowGoLeft: CornerUpLeft,
ArrowGoRight: CornerUpRight,
ArrowGoRightCross: LogOut,
ArrowGoRightPlus: ExternalLink,
ArrowLeft,
ArrowRight,
ArrowTop: ArrowUp,
ArrowUpDown,
Attachment: Paperclip,
Ball: Circle,
Bell,
BellMute: BellOff,
BellPing: BellDot,
BellRing,
BlockCode: Code,
BlockQuote: Quote,
Bold,
Bookmark,
Bulb: Lightbulb,
Category: Boxes,
Check,
CheckTwice: CheckCheck,
ChevronBottom: ChevronDown,
ChevronLeft,
ChevronRight,
ChevronTop: ChevronUp,
Clock,
Code: Code2,
Cross: X,
Cup: Trophy,
Delete: Trash2,
Download,
Explore: Compass,
External: ExternalLink,
Eye,
EyeBlind: EyeOff,
File,
Filter,
Flag,
Funnel: Filter,
Hash,
HashGlobe: Globe,
HashLock: Lock,
HashPlus: CirclePlus,
HashSearch: Search,
Heading1,
Heading2,
Heading3,
Headphone: Headphones,
Heart,
Home,
HorizontalDots: MoreHorizontal,
Inbox,
Info,
Italic,
Leaf,
Link,
Lock,
Mail,
MailPlus,
Markdown: FileText,
Mention: AtSign,
Message: MessageSquare,
MessageUnread: MessageSquareDot,
Mic,
MicMute: MicOff,
Minus,
Monitor,
NoEntry: Ban,
OrderList: ListOrdered,
Pause,
Peace: Sparkles,
Pencil,
Phone,
Photo: Image,
Pin,
Play,
Plus,
PlusCircle: CirclePlus,
Power,
Prohibited: Ban,
RecentClock: History,
Reload: RefreshCw,
ReplyArrow: Reply,
Search,
Send,
Server,
Setting: Settings,
Shield,
ShieldLock: Lock,
ShieldUser,
Smile,
SmilePlus,
Sort: ArrowUpDown,
Space: Boxes,
SpaceGlobe: Globe,
SpaceLock: Lock,
SpacePlus: Plus,
SpaceSearch: Search,
Star,
Sticker: Sparkles,
Strike: Strikethrough,
Sun,
Terminal,
Text: Type,
Thread: MessagesSquare,
ThreadPlus: MessageSquarePlus,
ThreadUnread: MessageSquareDot,
Underline,
UnorderList: List,
User,
UserPlus,
VerticalDots: MoreVertical,
VideoCamera: Video,
VideoCameraMute: VideoOff,
Vlc: Play,
VolumeHigh: Volume2,
VolumeMute: VolumeX,
Warning: TriangleAlert,
ImagePlay,
} as const;

View File

@@ -0,0 +1,24 @@
import { recipe } from '@vanilla-extract/recipes';
import { config } from 'folds';
export const Icon = recipe({
base: {
display: 'block',
flexShrink: 0,
},
variants: {
size: {
'50': { width: config.size.X50, height: config.size.X50 },
'100': { width: config.size.X100, height: config.size.X100 },
'200': { width: config.size.X200, height: config.size.X200 },
'300': { width: config.size.X300, height: config.size.X300 },
'400': { width: config.size.X400, height: config.size.X400 },
'500': { width: config.size.X500, height: config.size.X500 },
'600': { width: config.size.X600, height: config.size.X600 },
Inherit: { width: '1em', height: '1em' },
},
},
defaultVariants: {
size: '400',
},
});

View File

@@ -0,0 +1,5 @@
import type { LucideIcon } from 'lucide-react';
export type IconSrc = LucideIcon;
export type IconSize = '50' | '100' | '200' | '300' | '400' | '500' | '600' | 'Inherit';
export type IconName = keyof typeof import('./lucideIcons').Icons;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { Icon, Icons } from '../icons';
import * as css from './ImageEditor.css';
export type ImageEditorProps = {

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
import { Box, IconButton, Text, Scroll, Chip } from 'folds';
import { Icon, Icons } from '../icons';
import { PackAddress } from '../../plugins/custom-emoji';
import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient';

View File

@@ -1,5 +1,6 @@
import React, { FormEventHandler, ReactNode, useMemo, useState } from 'react';
import { Badge, Box, Button, Chip, Icon, Icons, Input, Text } from 'folds';
import { Badge, Box, Button, Chip, Input, Text } from 'folds';
import { Icon, Icons } from '../icons';
import { UsageSwitcher, useUsageStr } from './UsageSwitcher';
import { mxcUrlToHttp } from '../../utils/matrix';
import * as css from './style.css';

View File

@@ -1,16 +1,6 @@
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
import {
Box,
Text,
Avatar,
AvatarFallback,
Button,
Icon,
Icons,
Input,
TextArea,
Chip,
} from 'folds';
import { Box, Text, Avatar, AvatarFallback, Button, Input, TextArea, Chip } from 'folds';
import { Icon, Icons } from '../icons';
import Linkify from 'linkify-react';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';

View File

@@ -1,5 +1,6 @@
import React, { MouseEventHandler, useMemo, useState } from 'react';
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { Box, Button, config, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { Icon, Icons } from '../icons';
import FocusTrap from 'focus-trap-react';
import { ImageUsage } from '../../plugins/custom-emoji';
import { stopPropagation } from '../../utils/keyboard';

View File

@@ -2,7 +2,8 @@
import React from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { Icon, Icons } from '../icons';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';

View File

@@ -7,28 +7,8 @@ import React, {
useRef,
useState,
} from 'react';
import {
Overlay,
OverlayBackdrop,
OverlayCenter,
Box,
Header,
config,
Text,
IconButton,
Icon,
Icons,
Input,
Button,
Spinner,
color,
TextArea,
Dialog,
Menu,
toRem,
Scroll,
MenuItem,
} from 'folds';
import { Overlay, OverlayBackdrop, OverlayCenter, Box, Header, config, Text, IconButton, Input, Button, Spinner, color, TextArea, Dialog, Menu, toRem, Scroll, MenuItem } from 'folds';
import { Icon, Icons } from '../icons';
import { Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';

View File

@@ -1,21 +1,7 @@
import React, { FormEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
Button,
Input,
color,
} from 'folds';
import { Dialog, Overlay, OverlayCenter, OverlayBackdrop, Header, config, Box, Text, IconButton, Button, Input, color } from 'folds';
import { Icon, Icons } from '../icons';
import { stopPropagation } from '../../utils/keyboard';
import { isRoomAlias, isRoomId } from '../../utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';

View File

@@ -1,25 +1,7 @@
import React, { useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
color,
Button,
Spinner,
Scroll,
Checkbox,
Avatar,
Badge,
} from 'folds';
import { Dialog, Overlay, OverlayCenter, OverlayBackdrop, Header, config, Box, Text, IconButton, color, Button, Spinner, Scroll, Checkbox, Avatar, Badge } from 'folds';
import { Icon, Icons } from '../icons';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';

Some files were not shown because too many files have changed in this diff Show More